From 5c8bc871af20d770a2efdeaaabff06d6ed71a957 Mon Sep 17 00:00:00 2001 From: Jeffreytsai1004 Date: Tue, 14 Jan 2025 03:06:35 +0800 Subject: [PATCH] Update --- .../v2/extensions/__init__.py | 0 .../v2/extensions/bake_poses.py | 99 + .../v2/extensions/copy_paste_trs.py | 224 ++ .../v2/extensions/generate_inbetweens.py | 71 + .../Animation/epic_pose_wrangler/v2/main.py | 832 +++++++ .../epic_pose_wrangler/v2/model/__init__.py | 5 + .../v2/model/actions/__init__.py | 0 .../epic_pose_wrangler/v2/model/actions/io.py | 71 + .../v2/model/actions/selection.py | 55 + .../v2/model/actions/zero_pose.py | 42 + .../epic_pose_wrangler/v2/model/api.py | 1965 +++++++++++++++++ .../v2/model/base_action.py | 24 + .../v2/model/base_extension.py | 59 + .../epic_pose_wrangler/v2/model/context.py | 13 + .../epic_pose_wrangler/v2/model/exceptions.py | 53 + .../epic_pose_wrangler/v2/model/export.py | 327 +++ .../v2/model/pose_blender.py | 502 +++++ .../epic_pose_wrangler/v2/model/utils.py | 311 +++ .../epic_pose_wrangler/v2/view/__init__.py | 0 .../epic_pose_wrangler/v2/view/icons/main.png | Bin 0 -> 10406 bytes .../epic_pose_wrangler/v2/view/maya_style.qss | 16 + .../v2/view/pose_wrangler_ui.ui | 839 +++++++ .../v2/view/pose_wrangler_window.py | 978 ++++++++ .../epic_pose_wrangler/v2/view/style.qss | 204 ++ .../epic_pose_wrangler/v2/view/ui_context.py | 45 + .../v2/view/widget/__init__.py | 0 .../v2/view/widget/category.py | 45 + 27 files changed, 6780 insertions(+) create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/extensions/__init__.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/extensions/bake_poses.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/extensions/copy_paste_trs.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/extensions/generate_inbetweens.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/main.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/__init__.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/actions/__init__.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/actions/io.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/actions/selection.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/actions/zero_pose.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/api.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/base_action.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/base_extension.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/context.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/exceptions.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/export.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/pose_blender.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/model/utils.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/__init__.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/icons/main.png create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/maya_style.qss create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/pose_wrangler_ui.ui create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/pose_wrangler_window.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/style.qss create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/ui_context.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/widget/__init__.py create mode 100644 Scripts/Animation/epic_pose_wrangler/v2/view/widget/category.py diff --git a/Scripts/Animation/epic_pose_wrangler/v2/extensions/__init__.py b/Scripts/Animation/epic_pose_wrangler/v2/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/extensions/bake_poses.py b/Scripts/Animation/epic_pose_wrangler/v2/extensions/bake_poses.py new file mode 100644 index 0000000..5b7d59f --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/extensions/bake_poses.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/extensions/copy_paste_trs.py b/Scripts/Animation/epic_pose_wrangler/v2/extensions/copy_paste_trs.py new file mode 100644 index 0000000..e62f93d --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/extensions/copy_paste_trs.py @@ -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()) + ) + ) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/extensions/generate_inbetweens.py b/Scripts/Animation/epic_pose_wrangler/v2/extensions/generate_inbetweens.py new file mode 100644 index 0000000..5ef4f4a --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/extensions/generate_inbetweens.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/main.py b/Scripts/Animation/epic_pose_wrangler/v2/main.py new file mode 100644 index 0000000..290c3be --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/main.py @@ -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_() diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/__init__.py b/Scripts/Animation/epic_pose_wrangler/v2/model/__init__.py new file mode 100644 index 0000000..57163fc --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/__init__.py @@ -0,0 +1,5 @@ +from .actions import ( + selection, + io, + zero_pose +) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/actions/__init__.py b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/actions/io.py b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/io.py new file mode 100644 index 0000000..876f198 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/io.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/actions/selection.py b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/selection.py new file mode 100644 index 0000000..934dcbe --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/selection.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/actions/zero_pose.py b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/zero_pose.py new file mode 100644 index 0000000..a22965d --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/actions/zero_pose.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/api.py b/Scripts/Animation/epic_pose_wrangler/v2/model/api.py new file mode 100644 index 0000000..a68b548 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/api.py @@ -0,0 +1,1965 @@ +# Copyright Epic Games, Inc. All Rights Reserved. + +import math +import re + +import six +import json +from collections import OrderedDict + +from maya import cmds +from maya import OpenMaya +from maya.api import OpenMaya as om + +from epic_pose_wrangler.log import LOG +from epic_pose_wrangler.v2.model import exceptions, pose_blender, utils + + +class RBFNode(object): + node_type = 'UERBFSolverNode' + + def __init__(self, node): + """ + Initialize RBFNode on give node + + >>> node = cmds.createNode('UERBFSolverNode') + >>> RBFNode(node) + """ + + if not cmds.objectType(node, isAType=self.node_type): + raise TypeError('Invalid "{}" node: "{}"'.format(self.node_type, node)) + + self._node = node + + def __repr__(self): + """ + Returns class string representation + """ + return '<{}>: {}'.format(self.node_type, self) + + def __str__(self): + """ + Returns class as string + """ + return str(self._node) + + def __eq__(self, other): + """ + Overrides equals operator to allow for different RBFNode instances to be matched against each other + """ + return str(self) == str(other) + + def set_defaults(self): + """ + Sets node default values + """ + self.set_mode('Interpolative') + self.set_radius(45) + self.set_automatic_radius(False) + + # ------------------------------------------------------------------------- + + @classmethod + def create(cls, name=None): + """ + Create RBF node + + >>> rbf_node = RBFNode.create() + """ + + if name is None: + name = '{}#'.format(cls.node_type) + active_selection = cmds.ls(selection=True) + # create node + node = cmds.createNode(cls.node_type, name=name) + rbf_node = cls(node) + rbf_node.set_defaults() + cmds.select(active_selection, replace=True) + + return rbf_node + + @classmethod + def create_from_data(cls, data): + """ + Creates RBF network from dictionary + + >>> new_joint = cmds.createNode('joint') + >>> data = {'drivers': [new_joint], 'poses': {'drivers': {'default': [[1,0,0,0,0,1,0,0,0,0,1,0,2,0,0,1]]}}} + >>> RBFNode.create_from_data(data) + """ + + if not cmds.objExists(data['solver_name']): + rbf_node = cls.create(name=data['solver_name']) + else: + rbf_node = cls(data['solver_name']) + + # add drivers + drivers = data['drivers'] + for driver in drivers: + if not rbf_node.has_driver(driver): + rbf_node.add_driver(driver) + + # add controllers + controllers = data.get('controllers', []) + for controller in controllers: + if not rbf_node.has_controller(controller): + rbf_node.add_controller(controller) + + driven_transforms = data.get('driven_transforms', []) + if driven_transforms: + rbf_node.add_driven_transforms(driven_nodes=driven_transforms, edit=False) + + # add poses + for pose_name, pose_data in data['poses'].items(): + drivers_matrices = pose_data['drivers'] + controllers_matrices = pose_data.get('controllers', []) + driven_matrices = pose_data.get('driven', {}) + function_type = pose_data.get('function_type', 'DefaultFunctionType') + distance_method = pose_data.get('distance_method', 'DefaultMethod') + scale_factor = pose_data.get('scale_factor', 1.0) + target_enable = pose_data.get('target_enable', True) + + rbf_node.add_pose( + pose_name, drivers=drivers, matrices=drivers_matrices, driven_matrices=driven_matrices, + controller_matrices=controllers_matrices, function_type=function_type, + distance_method=distance_method, scale_factor=scale_factor, target_enable=target_enable + ) + + # set solver attributes + attributes = ['radius', 'automaticRadius', 'weightThreshold', 'normalizeMethod'] + enum_attributes = ['mode', 'distanceMethod', 'normalizeMethod', + 'functionType', 'twistAxis', 'inputMode'] + + for attr in attributes: + if attr in data: + cmds.setAttr('{}.{}'.format(rbf_node, attr), data[attr]) + + for attr in enum_attributes: + if attr in data: + value = data[attr] + attr = "{node}.{attr}".format(node=rbf_node, attr=attr) + rbf_node._set_enum_attribute(attr, value) + + return rbf_node + + @classmethod + def find_all(cls): + """ + Returns all RBF nodes in scene + """ + return [cls(node) for node in cmds.ls(type=cls.node_type)] + + # ---------------------------------------------------------------------------------------------- + # PARAMETERS + # ---------------------------------------------------------------------------------------------- + + def mode(self, enum_value=True): + return cmds.getAttr('{}.mode'.format(self), asString=enum_value) + + def set_mode(self, value): + self._set_enum_attribute('{}.mode'.format(self), value) + + def radius(self): + return cmds.getAttr('{}.radius'.format(self)) + + def set_radius(self, value): + cmds.setAttr('{}.radius'.format(self), value) + + def automatic_radius(self): + return cmds.getAttr('{}.automaticRadius'.format(self)) + + def set_automatic_radius(self, value): + cmds.setAttr('{}.automaticRadius'.format(self), value) + + def weight_threshold(self): + return cmds.getAttr('{}.weightThreshold'.format(self)) + + def set_weight_threshold(self, value): + cmds.setAttr('{}.weightThreshold'.format(self), value) + + def distance_method(self, enum_value=True): + return cmds.getAttr('{}.distanceMethod'.format(self), asString=enum_value) + + def set_distance_method(self, value): + self._set_enum_attribute('{}.distanceMethod'.format(self), value) + + def normalize_method(self, enum_value=True): + return cmds.getAttr('{}.normalizeMethod'.format(self), asString=enum_value) + + def set_normalize_method(self, value): + self._set_enum_attribute('{}.normalizeMethod'.format(self), value) + + def function_type(self, enum_value=True): + return cmds.getAttr('{}.functionType'.format(self), asString=enum_value) + + def set_function_type(self, value): + self._set_enum_attribute('{}.functionType'.format(self), value) + + def twist_axis(self, enum_value=True): + return cmds.getAttr('{}.twistAxis'.format(self), asString=enum_value) + + def set_twist_axis(self, value): + self._set_enum_attribute('{}.twistAxis'.format(self), value) + + def input_mode(self, enum_value=True): + return cmds.getAttr('{}.inputMode'.format(self), asString=enum_value) + + def set_input_mode(self, value): + self._set_enum_attribute('{}.inputMode'.format(self), value) + + def data(self): + """ + Returns dictionary with the setup + """ + data = OrderedDict() + data['solver_name'] = str(self) + data['drivers'] = self.drivers() + data['driven_transforms'] = self.driven_nodes(pose_blender.UEPoseBlenderNode.node_type) + data['controllers'] = self.controllers() + data['poses'] = self.poses() + data['driven_attrs'] = self.driven_attributes() + data['mode'] = self.mode(enum_value=False) + data['radius'] = self.radius() + data['automaticRadius'] = self.automatic_radius() + data['weightThreshold'] = self.weight_threshold() + data['distanceMethod'] = self.distance_method(enum_value=False) + data['normalizeMethod'] = self.normalize_method(enum_value=False) + data['functionType'] = self.function_type(enum_value=False) + data['twistAxis'] = self.twist_axis(enum_value=False) + data['inputMode'] = self.input_mode(enum_value=False) + + return data + + def export_data(self, file_path): + """ + Exports data dictionary to disk + """ + with open(file_path, 'w') as outfile: + json.dump(self.data(), outfile, sort_keys=1, indent=4, separators=(",", ":")) + + return file_path + + def output_weights(self): + """ + Returns output weights + """ + return [cmds.getAttr(attr) for attr in self.output_attributes()] + + def output_attributes(self): + """ + Returns output attributes + """ + attributes = list() + indices = cmds.getAttr('{}.outputs'.format(self), multiIndices=True) + if indices: + for pose_index in indices: + attr = '{}.outputs[{}]'.format(self, pose_index) + attributes.append(attr) + + return attributes + + def driven_attributes(self, type='blendShape'): + """ + Returns output connections + """ + driven_attributes = list() + output_attributes = self.output_attributes() + if output_attributes: + for attr in output_attributes: + con = cmds.listConnections(attr, destination=True, shapes=False, plugs=True, type=type) or [] + driven_attributes.append(con) + + return driven_attributes + + def driven_nodes(self, type='blendShape'): + """ + Returns driven transform nodes + :return: + """ + driven = [] + if type == pose_blender.UEPoseBlenderNode.node_type: + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + # Iterate through all the pose blenders + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)) or []: + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + driven.append(pose_blender_node.driven_transform) + elif type == 'blendShape': + for pose_index in range(self.num_poses()): + attr = '{}.targets[{}].targetName'.format(self, pose_index) + poses = cmds.listConnections(attr, type='transform') or [] + driven.extend(poses) + return driven + + # ---------------------------------------------------------------------------------------------- + # DRIVERS + # ---------------------------------------------------------------------------------------------- + + def num_drivers(self): + """ + Returns number of drivers + """ + return cmds.getAttr('{}.inputs'.format(self), size=True) + + def has_driver(self, transform_node): + """ + Check if node is using this transform as input + """ + return transform_node in self.drivers() + + def drivers(self): + """ + Returns list of drivers + """ + indices = cmds.getAttr('{}.inputs'.format(self), multiIndices=True) + drivers = list() + if indices: + for i in indices: + connections = cmds.listConnections('{}.inputs[{}]'.format(self, i)) + if connections: + drivers.append(connections[0]) + else: + raise RuntimeError('Unable to get driver at index: {}'.format(i)) + + return drivers + + def add_driver(self, transform_nodes=None, controllers=None): + """ + Adds driver to RBF Node + """ + + if transform_nodes is None: + transform_nodes = cmds.ls(selection=True, type='transform') + if self.num_poses() > 1: + raise RuntimeError('Unable to add driver after poses have been added') + + if not isinstance(transform_nodes, (list, set, tuple)): + transform_nodes = [transform_nodes] + + for transform_node in transform_nodes: + if not cmds.objExists(transform_node): + raise RuntimeError('Node not found: "{}"'.format(transform_node)) + + if not cmds.objectType(transform_node, isAType='transform'): + raise RuntimeError('Invalid transform node: "{}"'.format(transform_node)) + + if self.has_driver(transform_node): + raise RuntimeError('Already has driver "{}"'.format(transform_node)) + + index = self.num_drivers() + cmds.connectAttr('{}.matrix'.format(transform_node), '{}.inputs[{}]'.format(self, index)) + # set rest matrix + rest_matrix = cmds.xform(transform_node, q=True, ws=False, matrix=True) + cmds.setAttr('{}.inputsRest[{}]'.format(self, index), rest_matrix, type='matrix') + + def remove_drivers(self, transform_nodes): + """ + Removes the specified drivers from this solver + :param transform_nodes :type list: list of transform node names + """ + # Get the valid driver names - converts from MObject to DagPath if MObjects are specified + valid_drivers = [] + for driver in transform_nodes: + # Check if its an MObject + if isinstance(driver, om.MObject): + # Get the fullPathName and add to list of valid drivers + valid_drivers.append(om.MDagPath.getAPathTo(driver).fullPathName()) + else: + # Check if the driver exists and get the full path name + matching_driver = cmds.ls(driver, long=True) + if matching_driver: + # Add to list of valid drivers + valid_drivers.append(matching_driver[0]) + + LOG.debug("Removing drivers: '{drivers}' from solver: {solver}".format(drivers=valid_drivers, solver=self)) + # Get the existing drivers from the solver + existing_drivers = self.drivers() + # Create an empty dict to store the remaining {driver: pose_node} + remaining_drivers = [] + poses_indices = cmds.getAttr('{solver}.targets'.format(solver=self), multiIndices=True) or [] + # Iterate through the existing drivers in reverse + for index in reversed(range(len(existing_drivers))): + # Grab the driver from the index + existing_driver = existing_drivers[index] + # Check that the driver exists + matching_driver = cmds.ls(existing_driver, long=True) + # If the driver doesn't exist, warn and continue + if not matching_driver: + LOG.warning( + "Could not find driver: {existing_driver} in the scene".format( + existing_driver=existing_driver + ) + ) + continue + # Existing driver found + existing_driver = matching_driver[0] + # Remove the drivers connections from the solver + cmds.removeMultiInstance('{solver}.inputs[{index}]'.format(solver=self, index=index), b=True) + cmds.removeMultiInstance('{solver}.inputsRest[{index}]'.format(solver=self, index=index), b=True) + for pose_index in poses_indices: + cmds.removeMultiInstance( + '{solver}.targets[{pose_index}]'.format( + solver=self, + pose_index=pose_index, + ), + b=True + ) + + # If the driver is in the not valid drivers list we want to keep it + if existing_driver not in valid_drivers: + # Add it back to the list of drivers to reconnect + remaining_drivers.append(existing_driver) + + # Iterate through the remaining drivers and reconnect them + for index, driver in enumerate(remaining_drivers): + cmds.connectAttr( + '{driver}.matrix'.format(driver=driver), '{solver}.inputs[{index}]'.format( + solver=self, + index=index + ) + ) + # set rest matrix + rest_matrix = cmds.xform(driver, q=True, ws=False, matrix=True) + cmds.setAttr('{}.inputsRest[{}]'.format(self, index), rest_matrix, type='matrix') + + # If we have any drivers left, recreate the default pose + if self.drivers(): + self.add_pose_from_current('default') + + # ---------------------------------------------------------------------------------------------- + # CONTROLLERS + # ---------------------------------------------------------------------------------------------- + + def num_controllers(self): + """ + Returns number of controllers + """ + return cmds.getAttr('{}.inputsControllers'.format(self), size=True) + + def has_controller(self, transform_node): + """ + Check if node is using this transform as controller + """ + return transform_node in self.controllers() + + def controllers(self): + """ + Returns list of controllers + """ + + indices = cmds.getAttr('{}.inputsControllers'.format(self), multiIndices=True) + controllers = list() + if indices: + for i in indices: + connections = cmds.listConnections('{}.inputsControllers[{}]'.format(self, i)) + if connections: + controllers.append(connections[0]) + else: + raise RuntimeError('Unable to get controller at index: {}'.format(i)) + + return controllers + + def add_controller(self, transform_nodes): + """ + Adds controller to RBF Node + """ + + if self.num_poses(): + raise RuntimeError('Unable to add controller after poses have been added') + + if not isinstance(transform_nodes, (list, set, tuple)): + transform_nodes = [transform_nodes] + + for transform_node in transform_nodes: + + if not cmds.objExists(transform_node): + raise RuntimeError('Node not found: "{}"'.format(transform_node)) + + if not cmds.objectType(transform_node, isAType='transform'): + raise RuntimeError('Invalid transform node: "{}"'.format(transform_node)) + + if self.has_controller(transform_node): + raise RuntimeError('Already has controller "{}"'.format(transform_node)) + + index = self.num_controllers() + cmds.connectAttr('{}.message'.format(transform_node), '{}.inputsControllers[{}]'.format(self, index)) + + # ---------------------------------------------------------------------------------------------- + # DRIVEN TRANSFORMS + # ---------------------------------------------------------------------------------------------- + def add_driven_transforms(self, driven_nodes=None, edit=False): + """ + Add driven transforms to this node, creating UEPoseBlenderNodes where needed + :param driven_nodes :type list: list of transform_node names + :param edit :type bool: should we be editing the transforms on creation? When set to true, no connection is + made from the output of the UEPoseBlenderNode to the driven transform translate, rotate and scale. When set + to False, a decompose matrix is created and connected between the output of the UEPoseBlenderNode and the + translate, rotate and scale of the driven transform. + """ + # If no driven nodes are specified, grab the current selection + if not driven_nodes: + driven_nodes = cmds.ls(selection=True, type='transform') + # If an iterable wasn't specified, convert it to a list + if not isinstance(driven_nodes, (list, tuple, set)): + driven_nodes = [driven_nodes] + # For each driven node + for node in driven_nodes: + existing_connection = pose_blender.UEPoseBlenderNode.find_by_transform(node) + if existing_connection: + LOG.error( + "Driven transform {node} is already connected to {solver}. " + "Unable to add, skipping.".format( + node=node, + solver=existing_connection.rbf_solver + ) + ) + continue + # Create a UEPoseBlender node + pose_blender_node = pose_blender.UEPoseBlenderNode.create(driven_transform=node) + # Create a message attr connection to the rbf solver node + pose_blender_node.rbf_solver = "{solver}.poseBlenders".format(solver=self) + # Connect each output attribute from the solver to the corresponding weight + for index, attribute in enumerate(self.output_attributes()): + pose_blender_node.set_weight(index=index, in_float_attr=attribute) + # Set the mode to edit + pose_blender_node.edit = edit + + def remove_driven_transforms(self, driven_nodes): + """ + Remove the specified driven transforms from this solver + :param driven_nodes :type list: list of transform names + """ + # Can only remove transforms if we have connected UEPoseBlenderNodes + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + # Iterate through the pose blender nodes + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Generate an API wrapper for the UEPoseBlenderNode + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + # If the pose blender's driven transform is in the list to remove + if pose_blender_node.driven_transform in driven_nodes: + # Remove it from the list + driven_nodes.pop(driven_nodes.index(pose_blender_node.driven_transform)) + # Delete the UEPoseBlenderNode + pose_blender_node.delete() + # Iterate through the driven + for driven_node in driven_nodes: + # See if they are a blendshape and have an associated pose + blendshape_pose = self.get_pose_for_blendshape_mesh(driven_node) + if blendshape_pose: + # Delete the blendshape + self.delete_blendshape(pose_name=blendshape_pose) + + # ---------------------------------------------------------------------------------------------- + # POSES + # ---------------------------------------------------------------------------------------------- + + def num_poses(self): + """ + Returns number of poses + """ + return cmds.getAttr('{}.targets'.format(self), size=True) + + def has_pose(self, pose_name): + """ + Returns if pose already exists + """ + return pose_name in self.poses() + + def pose_index(self, pose_name): + """ + Returns pose index + """ + + poses_indices = cmds.getAttr('{}.targets'.format(self), multiIndices=True) + if poses_indices: + for pose_index in poses_indices: + if pose_name == cmds.getAttr('{}.targets[{}].targetName'.format(self, pose_index)): + return pose_index + + return -1 + + def pose(self, pose_name): + """ + Returns pose matrices + """ + + pose_index = self.pose_index(pose_name) + if pose_index == -1: + raise exceptions.InvalidPose('Pose not found: "{}"'.format(pose_name)) + + # --------------------------------------------------------------------- + # get driver indices + driver_indices = cmds.getAttr('{}.targets[{}].targetValues'.format(self, pose_index), multiIndices=True) + if not driver_indices: + raise exceptions.InvalidPose('Unable to get Driver indices') + + # just to be sure ... + num_drivers = self.num_drivers() + if len(driver_indices) != num_drivers: + raise exceptions.InvalidPose('Invalid Driver Indices') + + # get matrices + matrices = list() + for driver_index in driver_indices: + matrix = cmds.getAttr('{}.targets[{}].targetValues[{}]'.format(self, pose_index, driver_index)) + matrices.append(matrix) + + # --------------------------------------------------------------------- + + # get controller indices + controller_indices = cmds.getAttr( + '{}.targets[{}].targetControllers'.format(self, pose_index), + multiIndices=True + ) or [] + + # just to be sure ... + num_controllers = self.num_controllers() + if len(controller_indices) != num_controllers: + raise RuntimeError('Invalid Controller Indices') + + # get controller matrices + controller_matrices = list() + for controller_index in controller_indices: + matrix = cmds.getAttr('{}.targets[{}].targetControllers[{}]'.format(self, pose_index, controller_index)) + controller_matrices.append(matrix) + + # --------------------------------------------------------------------- + + # get properties + function_type = cmds.getAttr('{}.targets[{}].targetFunctionType'.format(self, pose_index), asString=True) + scale_factor = cmds.getAttr('{}.targets[{}].targetScaleFactor'.format(self, pose_index), asString=True) + distance_method = cmds.getAttr('{}.targets[{}].targetDistanceMethod'.format(self, pose_index), asString=True) + + pose_blender_data = {} + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_matrix = pose_blender_node.get_pose(index=pose_index) + pose_blender_data[pose_blender_node.driven_transform] = pose_matrix + + pose_data = OrderedDict() + pose_data['drivers'] = matrices + pose_data['controllers'] = controller_matrices + pose_data['driven'] = pose_blender_data + pose_data['function_type'] = function_type + pose_data['scale_factor'] = scale_factor + pose_data['distance_method'] = distance_method + pose_data['target_enable'] = cmds.getAttr( + '{solver}.targets[{index}].targetEnable'.format(solver=self, index=pose_index) + ) + pose_data['blendshape_data'] = self.get_blendshape_data_for_pose(pose_name) or [] + + return pose_data + + def go_to_pose(self, pose_name): + """ + Sets drivers or controllers to current pose + :param pose_name :type str: name of the pose to move this solvers drivers/driven transform nodes to + """ + + # get drivers and controllers matrices + pose = self.pose(pose_name) + pose_index = self.pose_index(pose_name) + + # if not controllers, set drivers + if not self.num_controllers(): + + matrices = pose['drivers'] + for driver_index, driver in enumerate(self.drivers()): + # TODO: check if translate, rotate and scale are free to change + cmds.xform(driver, matrix=matrices[driver_index]) + + # if controllers, set controllers + else: + matrices = pose['controllers'] + for controller_index, controller in enumerate(self.controllers()): + # TODO: check if translate, rotate and scale are free to change + cmds.xform(controller, matrix=matrices[controller_index]) + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_blender_node.go_to_pose(index=pose_index) + + def poses(self): + """ + Returns dictionary with poses and their transformation. + """ + + poses = OrderedDict() + poses_indices = cmds.getAttr('{}.targets'.format(self), multiIndices=True) + if poses_indices: + + for pose_index in poses_indices: + # get pose name + pose_name = cmds.getAttr('{}.targets[{}].targetName'.format(self, pose_index)) + # BUG - sometimes an unnamed pose will appear when selecting the node causing issues with the indexing + if pose_name: + # get pose transforms + poses[pose_name] = self.pose(pose_name) + + return poses + + def add_pose( + self, pose_name, drivers=None, matrices=None, controller_matrices=None, driven_matrices=None, + function_type='DefaultFunctionType', distance_method='DefaultMethod', scale_factor=1.0, target_enable=True, + blendshape_data=None + ): + """ + Adds pose to RBF Node + """ + if drivers and matrices is None: + matrices = [cmds.xform(driver, q=True, matrix=True, objectSpace=True) for driver in drivers] + if not self.num_drivers(): + raise exceptions.InvalidPose('You must add a driver first.') + + if self.has_pose(pose_name): + raise exceptions.InvalidPose('Already a pose called: "{}"'.format(pose_name)) + + if len(matrices) != self.num_drivers(): + raise exceptions.InvalidPose('Invalid number of matrices. Must match number of drivers.') + + if controller_matrices and len(controller_matrices) != self.num_controllers(): + raise exceptions.InvalidPose('Invalid number of controller matrices. Must match number of controllers.') + + if self.num_controllers() and not controller_matrices: + raise exceptions.InvalidPose('Invalid number of controller matrices. Must match number of controllers.') + + # get new index + pose_index = self.num_poses() + + # set matrices + for driver_index, matrix in enumerate(matrices): + attr = '{}.targets[{}].targetValues[{}]'.format(self, pose_index, driver_index) + cmds.setAttr(attr, matrix, type='matrix') + + # set controller matrices + if controller_matrices: + for controller_index, matrix in enumerate(controller_matrices): + attr = '{}.targets[{}].targetControllers[{}]'.format(self, pose_index, controller_index) + cmds.setAttr(attr, matrix, type='matrix') + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + output_attr = "{solver}.outputs[{pose_index}]".format(solver=self, pose_index=pose_index) + # Iterate through all the pose blenders + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + # If we have driven matrices we need to set the pose from the matrix provided + if driven_matrices and driven_matrices.get(pose_blender_node.driven_transform, False): + pose_blender_node.set_pose( + index=pose_index, pose_name=pose_name, + matrix=driven_matrices.get(pose_blender_node.driven_transform) + ) + else: + # Create a pose at the current position in the matching index + pose_blender_node.add_pose_from_current(pose_name=pose_name, index=pose_index) + pose_blender_node.set_weight(index=pose_index, in_float_attr=output_attr) + + # set pose name + cmds.setAttr('{}.targets[{}].targetName'.format(self, pose_index), pose_name, type='string') + + # create output instance + cmds.getAttr('{}.outputs[{}]'.format(self, pose_index), type=True) + # set the enabled status of the pose + cmds.setAttr('{solver}.targets[{index}].targetEnable'.format(solver=self, index=pose_index), target_enable) + + if blendshape_data: + for data in blendshape_data: + blendshape_mesh = data['blendshape_mesh'] + blendshape_mesh_orig = data['orig_mesh'] + self.add_existing_blendshape(pose_name, blendshape_mesh, blendshape_mesh_orig) + + def update_pose( + self, pose_name, drivers=None, matrices=None, controller_matrices=None, + function_type='DefaultFunctionType', distance_method='DefaultMethod', scale_factor=1.0 + ): + """ + Updates an existing pose on the RBF Node + """ + if drivers and matrices is None: + matrices = [cmds.xform(driver, q=True, matrix=True) for driver in drivers] + + if not self.has_pose(pose_name): + raise RuntimeError('Pose "{}" does not exist'.format(pose_name)) + + # get new index + pose_index = self.pose_index(pose_name) + + # set matrices + for driver_index, matrix in enumerate(matrices): + attr = '{}.targets[{}].targetValues[{}]'.format(self, pose_index, driver_index) + cmds.setAttr(attr, matrix, type='matrix') + + # set controller matrices + if controller_matrices: + for controller_index, matrix in enumerate(controller_matrices): + attr = '{}.targets[{}].targetControllers[{}]'.format(self, pose_index, controller_index) + cmds.setAttr(attr, matrix, type='matrix') + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_blender_node.set_pose(index=pose_index) + + def add_pose_from_current(self, pose_name, update=False): + """ + Adds current pose to RBF Node + """ + + # drivers + matrices = list() + drivers = self.drivers() + for driver in drivers: + matrix = cmds.xform(driver, q=True, matrix=True, objectSpace=True) + matrices.append(matrix) + + # controllers + controller_matrices = None + if self.num_controllers(): + + controller_matrices = list() + + for controller in self.controllers(): + matrix = cmds.xform(controller, q=True, matrix=True, objectSpace=True) + controller_matrices.append(matrix) + + if update: + self.update_pose(pose_name, drivers, matrices, controller_matrices) + else: + self.add_pose( + pose_name=pose_name, drivers=drivers, matrices=matrices, + controller_matrices=controller_matrices + ) + + def mirror_pose(self, pose_name, mirror_mapping, mirror_blendshapes=True): + """ + Mirrors the specified pose using the given mirror mapping to determine the target drivers/driven transforms + :param pose_name :type str: pose name to mirror + :param mirror_mapping :type pose_wrangler.model.mirror_mapping.MirrorMapping: mirror mapping ref + :param mirror_blendshapes :type bool: option to mirror blendshapes, stops infinite loop when pose is created via + mirror_blendshape + """ + # Get the name of the mirrored solver + target_solver_name = self._get_mirrored_solver_name(mirror_mapping=mirror_mapping) + # Check if the solver already exists + match = [s for s in self.find_all() if str(s) == target_solver_name] + # Force the base pose + for solver in self.find_all(): + if solver == self: + continue + if solver.has_pose('default'): + solver.go_to_pose('default') + # If it does, use it + if match: + new_solver = match[0] + # Otherwise create a new solver that is a mirror of this one + else: + new_solver = self.mirror(mirror_mapping=mirror_mapping, mirror_poses=False) + + # Go to the pose we want to mirror + self.go_to_pose(pose_name=pose_name) + # Store a list of all of the transforms affecting this pose + transforms = self.drivers() + # Generate a list of the mirrored transforms based on the transforms we found + mirrored_transforms = self._get_mirrored_transforms(transforms, mirror_mapping=mirror_mapping) + + for index, source_transform in enumerate(transforms): + target_transform = mirrored_transforms[index] + + rotate = cmds.getAttr("{source_transform}.rotate".format(source_transform=source_transform))[0] + cmds.setAttr("{target_transform}.rotate".format(target_transform=target_transform), *rotate) + + transforms = self.driven_nodes(pose_blender.UEPoseBlenderNode.node_type) + # Generate a list of the mirrored transforms based on the transforms we found + mirrored_transforms = self._get_mirrored_transforms(transforms, mirror_mapping=mirror_mapping) + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=new_solver, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=new_solver)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_blender_node.edit = True + + # Iterate through all of the source transforms + for index, source_transform in enumerate(transforms): + # Get the target transform at the same index + target_transform = mirrored_transforms[index] + # Need to get each transforms parent so that we can get the relative offset + # Get the sources parent matrix + source_parent_matrix = om.MMatrix( + cmds.getAttr( + '{source_transform}.parentMatrix'.format( + source_transform=source_transform + ) + ) + ) + # Get the targets parent matrix + target_parent_matrix = om.MMatrix( + cmds.getAttr( + '{target_transform}.parentMatrix'.format( + target_transform=target_transform + ) + ) + ) + # Calculate the translation + translate = om.MVector( + *cmds.getAttr( + '{source_transform}.translate'.format( + source_transform=source_transform + ) + ) + ) + + driver_mat_fn = om.MTransformationMatrix(om.MMatrix.kIdentity) + driver_mat_fn.setTranslation(translate, om.MSpace.kWorld) + driver_mat = driver_mat_fn.asMatrix() + + scale_matrix_fn = om.MTransformationMatrix(om.MMatrix.kIdentity) + scale_matrix_fn.setScale([-1.0, 1.0, 1.0], om.MSpace.kWorld) + scale_matrix = scale_matrix_fn.asMatrix() + + pos_matrix = driver_mat * source_parent_matrix + pos_matrix = pos_matrix * scale_matrix + pos_matrix = pos_matrix * target_parent_matrix.inverse() + mat_fn = om.MTransformationMatrix(pos_matrix) + + cmds.setAttr( + '{target_transform}.translate'.format(target_transform=target_transform), + *mat_fn.translation(om.MSpace.kWorld) + ) + + # Calculate the rotation + rotate = om.MVector( + *cmds.getAttr( + '{source_transform}.rotate'.format( + source_transform=source_transform + ) + ) + ) + + driver_mat_fn = om.MTransformationMatrix(om.MMatrix.kIdentity) + # set the values to radians + euler = om.MEulerRotation(*[math.radians(i) for i in rotate]) + + driver_mat_fn.setRotation(euler) + driver_matrix = driver_mat_fn.asMatrix() + + world_matrix = driver_matrix * source_parent_matrix + rot_matrix = source_parent_matrix.inverse() * world_matrix + rot_matrix_fn = om.MTransformationMatrix(rot_matrix) + rot = rot_matrix_fn.rotation(asQuaternion=True) + rot.x = rot.x * -1.0 + rot.w = rot.w * -1.0 + rot_matrix = rot.asMatrix() + final_rot_matrix = target_parent_matrix * rot_matrix * target_parent_matrix.inverse() + + rot_matrix_fn = om.MTransformationMatrix(final_rot_matrix) + rot = rot_matrix_fn.rotation(asQuaternion=False) + m_rot = om.MVector(*[math.degrees(i) for i in rot]) + + cmds.setAttr('{target_transform}.rotate'.format(target_transform=target_transform), *m_rot) + + # Assume scale is the same + cmds.setAttr( + "{target_transform}.scale".format(target_transform=target_transform), + *cmds.getAttr("{source_transform}.scale".format(source_transform=source_transform))[0] + ) + # Add a new pose on the mirrored solver with the same name, update if the pose already exists + new_solver.add_pose_from_current(pose_name=pose_name, update=new_solver.has_pose(pose_name=pose_name)) + + # Mirror blendshapes if option is specified + if mirror_blendshapes: + new_solver.delete_blendshape(pose_name=pose_name) + self.mirror_blendshape(pose_name=pose_name, mirror_mapping=mirror_mapping) + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=new_solver, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=new_solver)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_blender_node.edit = False + + def delete_pose(self, pose_name): + """ + Delete the specified pose name + :param pose_name :type str: pose name to delete + """ + # If the pose doesn't exist, raise an exception + if not self.has_pose(pose_name): + raise exceptions.InvalidPose("Pose {pose_name} does not exist.".format(pose_name=pose_name)) + + LOG.debug("Removing pose: '{pose_name}'".format(pose_name=pose_name)) + + # Get the existing poses from the solver + poses = self.poses() + pose_index = self.pose_index(pose_name) + + # Disconnect all of the blendshapes + for pose in poses.keys(): + self.delete_blendshape(pose) + + # Iterate through the existing drivers in reverse + for index in reversed(range(len(list(poses.keys())))): + # Remove the pose from the list of targets + cmds.removeMultiInstance('{solver}.targets[{index}]'.format(solver=self, index=index), b=True) + + # Remove the deleted pose + poses.pop(pose_name) + + # If we have poseBlenders connected we need to create a matching pose on each of those + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + pose_blender_node.delete_pose(index=pose_index) + + # Iterate through the remaining poses and recreate them + for pose_name, pose_data in poses.items(): + pose_data['controller_matrices'] = pose_data.pop('controllers') + pose_data['matrices'] = pose_data.pop('drivers') + pose_data['driven_matrices'] = pose_data.pop('driven') + # Add the pose with the specified kwargs + self.add_pose(pose_name=pose_name, **pose_data) + + def is_pose_muted(self, pose_name="", pose_index=-1): + """ + Gets the current status of the pose if it is muted or not + :param pose_name :type str: optional pose name + :param pose_index :type index, optional pose index + :return :type bool: True if the pose is muted, else False + """ + # Get the pose index if name is specified and the index hasn't been + if pose_index < 0 and pose_name: + pose_index = self.pose_index(pose_name) + elif not pose_name and pose_index < 0: + raise exceptions.InvalidPoseIndex("Unable to query the mute status, no pose name or index specified") + + attr = '{solver}.targets[{index}].targetEnable'.format(solver=self, index=pose_index) + return not cmds.getAttr(attr) + + def mute_pose(self, pose_name="", pose_index=-1, mute=True): + """ + 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 :type str: optional name of the pose + :param pose_index :type index, optional pose index + :param mute :type bool: mute or unmute the pose + """ + # Get the pose index if name is specified and the index hasn't been + if pose_index < 0 and pose_name: + pose_index = self.pose_index(pose_name) + elif not pose_name and pose_index < 0: + raise exceptions.InvalidPoseIndex("Unable to query the mute status, no pose name or index specified") + + attr = '{solver}.targets[{index}].targetEnable'.format(solver=self, index=pose_index) + if mute is None: + mute = not self.is_pose_muted(pose_index=pose_index) + cmds.setAttr(attr, not mute) + return not mute + + def edit_solver(self, edit=True): + """ + Edit or finish editing this solver + :param edit :type bool: set edit mode on or off + """ + # If we have poseBlenders connected we need to toggle the output connection based on the edit param + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + # Iterate through all the pose blenders + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + # Enable or disable edit mode on the blender + pose_blender_node.edit = edit + + # Force the base pose + for solver in self.find_all(): + if solver != self and solver.has_pose('default'): + solver.go_to_pose('default') + + def get_solver_edit_status(self): + """ + Gets this solvers edit status + :return :type bool: True if in edit mode, False if not + """ + # If we have poseBlenders connected we need to toggle the output connection based on the edit param + if cmds.attributeQuery("poseBlenders", node=self, exists=True): + # Iterate through all the pose blenders + for pose_blender_node_name in cmds.listConnections("{solver}.poseBlenders".format(solver=self)): + # Get the pose blender wrapper from the node name + pose_blender_node = pose_blender.UEPoseBlenderNode(pose_blender_node_name) + # If one blender node is in edit mode, then mark the solver as in edit mode + if pose_blender_node.edit: + return True + return False + + def get_pose_for_blendshape_mesh(self, mesh_name): + """ + Get the pose name that the specified blendshape mesh corresponds to + :param mesh_name :type str: mesh name + :return :type str: the pose name this mesh is associated with + """ + if not cmds.ls(mesh_name, type='transform'): + return + # If the blendshape doesn't have a poseName attr, it wasn't connected correctly via the API + if not cmds.attributeQuery('poseName', node=mesh_name, exists=True): + raise exceptions.BlendshapeError("Unable to find pose name for current blendshape") + # Return the poseName value + return cmds.getAttr("{mesh_name}.poseName".format(mesh_name=mesh_name)) + + def create_blendshape(self, pose_name, mesh_name=None): + """ + Create a blendshape for the specified pose name from the given mesh (or current selection if no mesh name is + specified) + :param pose_name :type str: pose name + :param mesh_name :type str: mesh name (or None for current selection) + """ + # If no mesh is specified grab the current selection + if not mesh_name: + mesh_name = cmds.ls(selection=True, type='transform') + # If no mesh, error + if not mesh_name: + raise exceptions.BlendshapeError("Unable to create blendshape. No mesh was found") + mesh_name = mesh_name[0] + # If the mesh_name is not a shape node, get the shape node for the transform + shapes = cmds.listRelatives(mesh_name, shapes=True, noIntermediate=True) + if not shapes: + raise exceptions.BlendshapeError( + "Unable to create blendshape. No shape node was found for selected " + "transform." + ) + + # Find the skin cluster to validate that we are working with a skinned mesh + if not cmds.listConnections(shapes[0], type='skinCluster'): + raise exceptions.BlendshapeError("Target mesh is not skinned") + + # Create the blendshape at the default pose. Once blendshapes have been created at the presumed pose using + # cmds.invertShape() will convert them to the bind pose position, which is where they need to be for + # pre-deformation blendshapes to work. In this case, we create a duplicate of the bind pose so that when we + # go into edit mode it'll be recreated in the desired pose correctly. + self.go_to_pose("default") + self.isolate_blendshape(pose_name=pose_name, isolate=True) + blendshape_mesh = cmds.duplicate( + mesh_name, name="{solver}_{pose_name}_{mesh}".format( + solver=self, + pose_name=pose_name, + mesh=mesh_name + ) + )[0] + self.add_existing_blendshape(pose_name, blendshape_mesh, mesh_name) + return blendshape_mesh + + def add_existing_blendshape(self, pose_name, blendshape_mesh="", base_mesh=""): + """ + Add an existing blendshape mesh and create a new blendshape for the specified pose + :param pose_name :type str: pose name + :param blendshape_mesh :type str: name of the blendshape mesh to add + :param base_mesh :type str: mesh name + """ + if not blendshape_mesh or not base_mesh: + selection = cmds.ls(selection=True) + if len(selection) != 2: + raise exceptions.BlendshapeError( + "Invalid number of meshes selected, please select the blendshape mesh" + " followed by the base mesh." + ) + blendshape_mesh, base_mesh = selection + # Get the current pose index + pose_index = self.pose_index(pose_name) + # Generate the attribute str to get the target name + attr = '{}.targets[{}].targetName'.format(self, pose_index) + # If the blendshape mesh doesn't have a poseName attr, create it so we can link the solver to the blendshape + # mesh + if not cmds.attributeQuery('poseName', node=blendshape_mesh, exists=True): + cmds.addAttr(blendshape_mesh, dt='string', longName='poseName') + # Connect the attrs + utils.connect_attr(attr, '{blendshape_mesh}.poseName'.format(blendshape_mesh=blendshape_mesh)) + + # Generate the name for the blendshape attribute on the base mesh + blendshape_attr = "{base_mesh}.blendShape".format(base_mesh=base_mesh) + blendshape = None + + # Find the blendshape if it already exists + if cmds.attributeQuery('blendShape', node=base_mesh, exists=True): + blendshapes = cmds.listConnections(blendshape_attr, type='blendShape') or [] + if blendshapes: + blendshape = blendshapes[0] + + # Create the blendshape if it does not + if not blendshape: + # Create a new blendshape from the given mesh + blendshape = cmds.blendShape( + base_mesh, + name="{base_mesh}_blendShape".format(base_mesh=base_mesh), + frontOfChain=True + )[0] + # Connect the blendshape_mesh to the blendshape via message attribute + utils.message_connect( + "{mesh}.blendShape".format(mesh=base_mesh), + "{blendshape}.meshOrig".format(blendshape=blendshape) + ) + + if not cmds.attributeQuery("meshes", node=blendshape, exists=True): + cmds.addAttr(blendshape, longName="meshes", attributeType='message', multi=True) + + target_index = utils.is_connected_to_array( + '{mesh}.blendShape'.format(mesh=blendshape_mesh), + "{blendshape}.meshes".format(blendshape=blendshape) + ) + if target_index is None: + target_index = utils.get_next_available_index_in_array("{blendshape}.meshes".format(blendshape=blendshape)) + # Connect the blendshape_mesh to the blendshape via message attribute + utils.message_connect( + "{mesh}.blendShape".format(mesh=blendshape_mesh), + "{blendshape}.meshes".format(blendshape=blendshape), out_array=True + ) + cmds.blendShape(blendshape, edit=True, target=(base_mesh, target_index, blendshape_mesh, 1.0)) + if utils.is_connected_to_array( + "{blendshape_mesh}.meshOrig".format(blendshape_mesh=blendshape_mesh), + "{mesh}.blendShapeMeshes".format(mesh=base_mesh) + ) is None: + # Connect the original mesh to the blendshape mesh via message attribute + utils.message_connect( + "{mesh}.blendShapeMeshes".format(mesh=base_mesh), + "{blendshape_mesh}.meshOrig".format(blendshape_mesh=blendshape_mesh), in_array=True + ) + + self.isolate_blendshape(pose_name=pose_name, isolate=False) + + def edit_blendshape(self, pose_name, edit=False): + """ + Edit the blendshape at the given pose. This will disconnect and isolate the specific blendshape + :param pose_name: Pose name to edit + :param edit: enable or disable editing + :return: + """ + # Go to the target pose so that we can create a new mesh to edit + self.go_to_pose(pose_name=pose_name) + # Get the blendshape data associated with this pose + blendshape_data = self.get_blendshape_data_for_pose(pose_name=pose_name) + if not blendshape_data: + return + for data in blendshape_data: + orig_mesh = data['orig_mesh'] + blendshape_mesh = data['blendshape_mesh'] + blendshape_mesh_shape = data['blendshape_mesh_shape'] + blendshape_mesh_orig_index = data['blendshape_mesh_orig_index'] + mesh_index = data['mesh_index'] + pose_index = data['pose_index'] + blendshape = data['blendshape'] + pose_attr = data['pose_attr'] + + mesh_index_attr = '{blendshape}.meshes[{mesh_index}]'.format(blendshape=blendshape, mesh_index=mesh_index) + weight_index_attr = '{blendshape}.weight[{mesh_index}]'.format(blendshape=blendshape, mesh_index=mesh_index) + output_attr = '{solver}.outputs[{pose_index}]'.format(solver=self, pose_index=pose_index) + blendshape_mesh_plug = ('{blendshape_node}.inputTarget[{mesh_index}].inputTargetGroup[0].' + 'inputTargetItem[6000].inputGeomTarget'.format( + blendshape_node=blendshape, + mesh_index=mesh_index + )) + blendshape_mesh_orig_attr = '{mesh}.blendShapeMeshes[{blendshape_mesh_orig_index}]'.format( + mesh=orig_mesh, blendshape_mesh_orig_index=blendshape_mesh_orig_index + ) + + if edit: + # Isolate the current blendshape, removing all other blendshape influences so this blendshape can be + # worked on in isolation + self.isolate_blendshape(pose_name=pose_name, isolate=True) + # Generate a new blendshape mesh from the current pose (so that we can edit the shape in place) + new_blendshape_mesh = cmds.duplicate( + orig_mesh, name="{blendshape_mesh}_EDIT".format( + blendshape_mesh=blendshape_mesh + ) + )[0] + self.delete_blendshape(pose_name=pose_name) + else: + # Select original mesh then new shape + new_blendshape_mesh = cmds.invertShape(orig_mesh, blendshape_mesh) + # Rename the blendshape mesh + new_blendshape_mesh = cmds.rename(new_blendshape_mesh, blendshape_mesh.replace('_EDIT', '')) + + new_blendshape_mesh_shape = cmds.listRelatives(new_blendshape_mesh, type='shape')[0] + # Handle the disconnect and reconnect of new attributes + if cmds.isConnected( + '{blendshape_mesh_shape}.worldMesh[0]'.format(blendshape_mesh_shape=blendshape_mesh_shape), + blendshape_mesh_plug + ): + cmds.disconnectAttr( + '{blendshape_mesh_shape}.worldMesh[0]'.format( + blendshape_mesh_shape=blendshape_mesh_shape + ), blendshape_mesh_plug + ) + + # Disconnect the old blendshape mesh from the blendshape + cmds.disconnectAttr( + '{blendshape_mesh}.blendShape'.format(blendshape_mesh=blendshape_mesh), + mesh_index_attr + ) + + # Disconnect the old blendshape mesh from the orig mesh + cmds.disconnectAttr( + blendshape_mesh_orig_attr, + '{blendshape_mesh}.meshOrig'.format(blendshape_mesh=blendshape_mesh) + ) + + # Disconnect the old blendshape mesh from the solvers pose + cmds.disconnectAttr(pose_attr, '{blendshape_mesh}.poseName'.format(blendshape_mesh=blendshape_mesh)) + + # Delete the old blendshape mesh + cmds.delete(blendshape_mesh) + + # Connected the new mesh to the blendshape + utils.connect_attr( + '{new_blendshape_mesh}.blendShape'.format(new_blendshape_mesh=new_blendshape_mesh), + mesh_index_attr + ) + + # Connect the original mesh to the blendshape mesh via message attribute + utils.message_connect( + "{mesh}.blendShapeMeshes".format(mesh=orig_mesh), + "{blendshape_mesh}.meshOrig".format(blendshape_mesh=new_blendshape_mesh), + in_array=True + ) + # If the blendshape mesh doesn't have a poseName attr, create it so we can link the solver to the blendshape + # mesh + if not cmds.attributeQuery('poseName', node=new_blendshape_mesh, exists=True): + cmds.addAttr(new_blendshape_mesh, dt='string', longName='poseName') + # Connect the new blendshape mesh up to the solver + utils.connect_attr(pose_attr, '{blendshape_mesh}.poseName'.format(blendshape_mesh=new_blendshape_mesh)) + + if edit: + cmds.select(new_blendshape_mesh, replace=True) + cmds.hide(orig_mesh) + else: + self.add_existing_blendshape( + pose_name=pose_name, blendshape_mesh=new_blendshape_mesh, base_mesh=orig_mesh + ) + self.isolate_blendshape(pose_name=pose_name, isolate=False) + cmds.hide(new_blendshape_mesh) + cmds.showHidden(orig_mesh) + + def isolate_blendshape(self, pose_name, isolate=True): + """ + Will disconnect or reconnect all of the blendshapes that aren't directly controlled by this pose + :param pose_name :type str: name of the pose that the blendshape is at + :param isolate :type bool: True will disconnect the blendshapes, False will reconnect + """ + # Get the output attrs + output_attributes = self.output_attributes() + # Get the specified pose's index + pose_index = self.pose_index(pose_name) + # If we don't have any output attributes, there's nothing to isolate + if output_attributes: + # Force default pose on everything bar this solver + for solver in self.find_all(): + if solver == self: + continue + if solver.has_pose('default'): + solver.go_to_pose(pose_name='default') + # If we are isolating, we want to disconnect all the other blendshapes connected to this solver + if isolate: + # Iterate through the output attributes + for index, attr in enumerate(output_attributes): + # If the index is the specified pose, skip it because we want to keep this one connected + if index == pose_index: + continue + # List all the blendshape connections and disconnect each blendshape + connections = cmds.listConnections( + attr, destination=True, shapes=False, + plugs=True, type='blendShape' + ) or [] + for con in connections: + # Disconnect + cmds.disconnectAttr(attr, con) + # Force the disconnected blendshapes weight to 0 so that it doesn't have any influence on the + # final rendered mesh + cmds.setAttr(con, 0.0) + else: + # Undo the isolation, reconnecting all the attributes + for index, attr in enumerate(output_attributes): + # Generate the pose name attr for this index + pose_name_attr = '{}.targets[{}].targetName'.format(self, index) + # List connections to find all the transforms connected + transforms = cmds.listConnections( + pose_name_attr, type='transform', destination=True, + shapes=False + ) or [] + # For each transform + for transform in transforms: + # Check if it has a blendshape attr, will be missing if blendshapes aren't added via the API + if cmds.attributeQuery("blendShape", node=transform, exists=True): + # Generate the blendshape attribute name + blendshape_attr = "{transform}.blendShape".format(transform=transform) + # Iterate through each connected blendshape + for blendshape in cmds.listConnections(blendshape_attr, type='blendShape') or []: + # Get list of connected blendshape mesh plugs + blendshape_mesh_plugs = cmds.listConnections( + "{blendshape}.meshes".format(blendshape=blendshape), + plugs=True + ) or [] + if blendshape_attr not in blendshape_mesh_plugs: + continue + target_index = blendshape_mesh_plugs.index(blendshape_attr) + # Connect the output attr to the blendshape + target_attr = "{blendshape}.weight[{index}]".format( + blendshape=blendshape, + index=target_index + ) + if not cmds.isConnected(attr, target_attr): + try: + cmds.connectAttr(attr, target_attr) + except RuntimeError as e: + LOG.error(e) + + def mirror_blendshape(self, pose_name, mirror_mapping): + """ + Mirrors the blendshape at the specified pose name. Will mirror the solver if it doesn't exist + :param pose_name :type str: name of the pose this blendshape is associated with + :param mirror_mapping :type pose_wrangler.model.mirror_mapping.MirrorMapping: mirror mapping ref + """ + # Get the pose index for this name + pose_index = self.pose_index(pose_name) + # Generate the attribute str to get the target name + attr = '{}.targets[{}].targetName'.format(self, pose_index) + # Find the blendshape transform node connected + blendshape_transforms = cmds.listConnections(attr, type='transform') or [] + # If we don't find one, the blendshape wasn't created via the API + if not blendshape_transforms: + LOG.debug("Unable to find blendshape associated with '{pose_name}' pose".format(pose_name=pose_name)) + return + + # Get the transform + blendshape_transform = blendshape_transforms[0] + + # Get the original mesh + original_mesh = cmds.listConnections( + '{blendshape}.meshOrig'.format(blendshape=blendshape_transform), + type='transform' + ) or [] + + if not original_mesh: + raise exceptions.BlendshapeError( + "Unable to find original mesh for blendshape: {blendshape}".format( + blendshape=blendshape_transform + ) + ) + + original_mesh = original_mesh[0] + + # Generate the mirrored name for the blendshape + mirrored_blendshape_name = self._get_mirrored_transforms( + [blendshape_transform], mirror_mapping, + ignore_invalid_nodes=True + )[0] + if cmds.objExists(mirrored_blendshape_name): + cmds.delete(mirrored_blendshape_name) + + # Duplicate the mesh with the new name + mirrored_blendshape_mesh = cmds.duplicate(blendshape_transform, name=mirrored_blendshape_name)[0] + # Get the name of the mirrored solver + target_solver_name = self._get_mirrored_solver_name(mirror_mapping=mirror_mapping) + # Check if the solver already exists + match = [s for s in self.find_all() if str(s) == target_solver_name] + # If it does, use it + if match: + new_solver = match[0] + # Otherwise create a new solver that is a mirror of this one + else: + new_solver = self.mirror(mirror_mapping=mirror_mapping, mirror_poses=False) + + # If the solver doesn't have this pose, mirror it + if not new_solver.has_pose(pose_name): + self.mirror_pose(pose_name=pose_name, mirror_mapping=mirror_mapping) + + # Add the new blendshape mesh to the mirrored solver + new_solver.add_existing_blendshape( + pose_name, blendshape_mesh=mirrored_blendshape_mesh, + base_mesh=original_mesh + ) + + blendshape_attr = "{base_mesh}.blendShape".format(base_mesh=original_mesh) + blendshape = None + if cmds.attributeQuery('blendShape', node=original_mesh, exists=True): + blendshapes = cmds.listConnections(blendshape_attr, type='blendShape') or [] + if blendshapes: + blendshape = blendshapes[0] + if not blendshape: + raise exceptions.BlendshapeError( + "Unable to find blendshape for {mesh} pose: {pose}".format( + mesh=original_mesh, + pose=pose_name + ) + ) + target_plug = "{blendshape_mesh}.blendShape".format(blendshape_mesh=mirrored_blendshape_mesh) + blendshape_mesh_plugs = cmds.listConnections( + "{blendshape}.meshes".format(blendshape=blendshape), + plugs=True + ) or [] + if target_plug not in blendshape_mesh_plugs: + raise exceptions.BlendshapeError( + "Unable to find {mesh} connection to {blendshape}".format( + mesh=original_mesh, + blendshape=blendshape + ) + ) + target_index = blendshape_mesh_plugs.index(target_plug) + # Find the mirror axis from the drivers + current_drivers = [cmds.xform(driver, query=True, translation=True, worldSpace=True) for driver in + self.drivers()] + mirrored_drivers = [cmds.xform(driver, query=True, translation=True, worldSpace=True) for driver in + new_solver.drivers()] + + # Find the mirrored axis + mirror_axis = self._get_mirrored_axis(source_transforms=current_drivers, mirrored_transforms=mirrored_drivers) + # HACK For some reason flipTarget fails to flip when called a single time + # Mirror the blendshape along the target axis + cmds.blendShape( + blendshape, edit=True, symmetryAxis=mirror_axis, symmetrySpace=1, + flipTarget=[(0, target_index)] + ) + # Mirror the blendshape along the target axis + cmds.blendShape( + blendshape, edit=True, symmetryAxis=mirror_axis, symmetrySpace=1, + flipTarget=[(0, target_index)] + ) + blendshape_data = new_solver.get_blendshape_data_for_pose(pose_name=pose_name) + parent = cmds.listRelatives(mirrored_blendshape_mesh, parent=True) or [] + cmds.delete(mirrored_blendshape_mesh) + # Regenerate the blendshape mesh from the new mirrored blendshape + new_mesh = cmds.sculptTarget(blendshape, edit=True, target=target_index, regenerate=True)[0] + # Rename the new mesh so that it doesn't match the old blendshape name and get deleted + new_mesh = cmds.rename(new_mesh, "{new_mesh}_TEMP".format(new_mesh=new_mesh)) + # Delete the original blendshape because the geometry isn't mirrored + new_solver.delete_blendshape(pose_name, blendshape_data=blendshape_data) + # Rename the mirrored mesh to the correct mirrored blendshape mesh name + cmds.rename(new_mesh, mirrored_blendshape_mesh) + cmds.hide(mirrored_blendshape_mesh) + if parent: + cmds.parent(mirrored_blendshape_mesh, parent) + # Add the new mesh as a blendshape + new_solver.add_existing_blendshape(pose_name, blendshape_mesh=mirrored_blendshape_mesh, base_mesh=original_mesh) + + def delete_blendshape(self, pose_name, blendshape_data=None, delete_mesh=False): + """ + Delete the blendshape associated with the specified pose + :param pose_name :type str: name of the pose to delete blendshapes for + :param blendshape_data :type dict or None: optional blendshape data + :param delete_mesh :type bool: True = mesh will be deleted, False = connections will be broken + """ + # Delete all the blendshapes associated with the specified pose + for data in blendshape_data or self.get_blendshape_data_for_pose(pose_name=pose_name): + # Get the blendshape, blendshape mesh and target index from the blendshape data + blendshape = data['blendshape'] + blendshape_mesh = data['blendshape_mesh'] + mesh_index = data['mesh_index'] + # Remove the attributes at the target index + cmds.removeMultiInstance( + '{blendshape}.meshes[{index}]'.format( + blendshape=blendshape, + index=mesh_index + ), b=True + ) + cmds.removeMultiInstance( + '{blendshape}.weight[{index}]'.format( + blendshape=blendshape, + index=mesh_index + ), b=True + ) + cmds.removeMultiInstance( + '{blendshape}.inputTarget[0].inputTargetGroup[{index}]'.format( + blendshape=blendshape, + index=mesh_index + ), b=True + ) + cmds.aliasAttr( + 'weight{index}'.format(index=mesh_index), '{blendshape}.weight[{index}]'.format( + blendshape=blendshape, + index=mesh_index + ) + ) + + pose_index = self.pose_index(pose_name=pose_name) + + from_attr = "{self}.targets[{pose_index}].targetName".format(self=self, pose_index=pose_index) + to_attr = "{blendshape}.poseName".format(blendshape=blendshape_mesh) + + cmds.disconnectAttr(from_attr, to_attr) + + # Delete the mesh + if cmds.objExists(blendshape_mesh) and delete_mesh: + cmds.select(blendshape_mesh, replace=True) + cmds.delete(blendshape_mesh) + + def get_blendshape_data_for_pose(self, pose_name): + """ + Get all the blendshape data associated with the specified pose name + :param pose_name :type str: pose name + :return :type list of dictionaries: blendshape data + """ + blendshape_data = [] + # Get the pose index for this name + pose_index = self.pose_index(pose_name) + if pose_index < 0: + return blendshape_data + # Generate the attribute str to get the target name + target_name_attr = '{}.targets[{}].targetName'.format(self, pose_index) + # Find the blendshape transform nodes connected + blendshape_transforms = cmds.listConnections(target_name_attr, type='transform') or [] + # If we don't find one, the blendshape wasn't created via the API + if not blendshape_transforms: + return blendshape_data + # Iterate through the transforms associated with the blendshape + for blendshape_mesh in blendshape_transforms: + blendshape_mesh_shape = cmds.listRelatives(blendshape_mesh, type='shape')[0] + # Get the connected blendshapes + blendshape_plug = '{transform}.blendShape'.format(transform=blendshape_mesh) + blendshapes = cmds.listConnections(blendshape_plug, type='blendShape') or [] + if len(blendshapes) != 1: + raise exceptions.BlendshapeError( + "Unable to find blendshape associated with {pose_name} for mesh: " + "{blendshape_mesh}".format( + pose_name=pose_name, + blendshape_mesh=blendshape_mesh + ) + ) + blendshape = blendshapes[0] + # Get the original mesh that the blendshape is driving + orig_mesh = cmds.listConnections( + "{mesh}.meshOrig".format(mesh=blendshape_mesh, type='transform') + ) + if not orig_mesh: + raise exceptions.BlendshapeError( + "Unable to find original mesh for blendshape: " + "'{mesh}'".format(mesh=blendshape_mesh) + ) + + orig_mesh = orig_mesh[0] + # Get the meshes connected to the blendshape + blendshape_mesh_plugs = cmds.listConnections( + "{blendshape}.meshes".format(blendshape=blendshape), + plugs=True + ) or [] + if blendshape_plug not in blendshape_mesh_plugs: + LOG.warning( + "Could not find connection between mesh: {mesh} and blendshape: {blendshape}".format( + mesh=blendshape_mesh, + blendshape=blendshape + ) + ) + continue + + # Get the index that the blendshape mesh is connected to the blendshape + target_index = blendshape_mesh_plugs.index(blendshape_plug) + + orig_mesh_plugs = cmds.listConnections( + "{orig_mesh}.blendShapeMeshes".format(orig_mesh=orig_mesh), + plugs=True + ) or [] + + blendshape_mesh_orig_plug = "{blendshape_mesh}.meshOrig".format(blendshape_mesh=blendshape_mesh) + + if blendshape_mesh_orig_plug not in orig_mesh_plugs: + LOG.warning( + "Could not find connection between mesh: {mesh} and blendshape mesh: {blendshape}".format( + mesh=orig_mesh, + blendshape=blendshape_mesh + ) + ) + continue + + blendshape_mesh_orig_index = orig_mesh_plugs.index(blendshape_mesh_orig_plug) + + blendshape_data.append( + { + # Blendshape Transform Node + 'blendshape_mesh': blendshape_mesh, + # Blendshape Shape Node + 'blendshape_mesh_shape': blendshape_mesh_shape, + # Original Mesh Transform Node + 'orig_mesh': orig_mesh, + # Blendshape Node + 'blendshape': blendshape, + # orig_mesh.blendShapeMeshes index for this blendshape mesh + 'blendshape_mesh_orig_index': blendshape_mesh_orig_index, + # Index of the blendshape transform node on the blendshape node + 'mesh_index': target_index, + # The attribute name + 'pose_attr': target_name_attr, + 'pose_index': pose_index + } + ) + return blendshape_data + + def pose_driven_attributes(self, pose_name): + """ + Returns attributes driven by the pose output + """ + + pose_driven_attributes = list() + + if self.has_pose(pose_name): + pose_index = self.pose_index(pose_name) + pose_driven_attributes = self.driven_attributes()[pose_index] + + return pose_driven_attributes + + def pose_output_attribute(self, pose_name): + """ + Returns the pose output attribute + """ + + if self.has_pose(pose_name): + pose_index = self.pose_index(pose_name) + return self.output_attributes()[pose_index] + + def pose_name(self, pose_index): + """ + Returns pose name from index + """ + + poses_indices = cmds.getAttr('{}.targets'.format(self), multiIndices=True) + if poses_indices and pose_index in poses_indices: + return cmds.getAttr('{}.targets[{}].targetName'.format(self, pose_index)) + + return None + + def rename_pose(self, pose_index, pose_name): + """ + Renames a pose given an index + """ + + poses_indices = cmds.getAttr('{}.targets'.format(self), multiIndices=True) + if poses_indices: + if pose_index not in poses_indices: + raise RuntimeError('Pose Index "{}" not found'.format(pose_index)) + + if self.has_pose(pose_name): + raise RuntimeError('Pose "{}" already exists'.format(pose_name)) + + cmds.setAttr('{}.targets[{}].targetName'.format(self, pose_index), pose_name, type='string') + + return pose_name + + def unnamed_poses_indices(self): + """ + Returns unnamed poses indices + """ + + unnamed_poses = list() + poses_indices = cmds.getAttr('{}.targets'.format(self), multiIndices=True) + for pose_index in poses_indices: + if not self.pose_name(pose_index): + unnamed_poses.append(pose_index) + + return unnamed_poses + + def name_unnamed_poses(self, basename='pose'): + """ + Names unnamed poses with basename. + + NOTE: this is a utility for existing Metahuman scenes where + the pose names are stored in network nodes. + """ + + pose_names = list() + for pose_index in self.unnamed_poses_indices(): + + # add index to basename + pose_name = '{}_{}'.format(basename, pose_index) + + # make sure is unique name + while self.has_pose(pose_name): + pose_name = '{}_{}'.format(basename, pose_index + 1) + + self.rename_pose(pose_index, pose_name) + pose_names.append(pose_name) + + return pose_names + + def mirror(self, mirror_mapping, mirror_poses=True): + """ + Mirror this RBF Solver + :param mirror_mapping :type pose_wrangler.model.mirror_mapping.MirrorMapping: mirror mapping ref + :param mirror_poses :type bool: should we mirror poses too? + :return :type RBFNode: mirrored RBF Solver wrapper + """ + # Generate the target solver name from the current solver name + target_rbf_solver_name = self._get_mirrored_solver_name(mirror_mapping=mirror_mapping) + + LOG.debug( + "Creating mirrored solver: '{target_pose_driver_name}".format( + target_pose_driver_name=target_rbf_solver_name + ) + ) + # Serialize the solver data + solver_data = self.data() + # Grab the drivers and driven from the serialized data + mirrored_transform_data = { + 'drivers': solver_data.pop('drivers'), + 'driven_transforms': solver_data.pop('driven_transforms') + } + # Iterate through the drivers and driven transforms to find their mirrored counterpart + for key, transforms in mirrored_transform_data.items(): + # Update the dict with the new transforms + mirrored_transform_data[key] = self._get_mirrored_transforms(transforms, mirror_mapping) + + # Update the solver data with the new drivers + solver_data['drivers'] = mirrored_transform_data.pop('drivers') + # Update the solver data with the new driven transforms + solver_data['driven_transforms'] = mirrored_transform_data.pop('driven_transforms') + # Update the solver data with the new solver name + solver_data['solver_name'] = target_rbf_solver_name + + # Remove pose data to iterate over later + pose_data = solver_data.pop('poses') + solver_data['poses'] = {} + + # Delete any existing solvers: + for solver in [solver for solver in RBFNode.find_all() if str(solver) == target_rbf_solver_name]: + solver.delete() + # Create a new solver from the data + mirrored_solver = RBFNode.create_from_data(solver_data) + + # If we are mirroring poses, iterate through each pose and mirror it + if mirror_poses: + for pose_name in pose_data.keys(): + self.mirror_pose(pose_name=pose_name, mirror_mapping=mirror_mapping) + + # Force the base pose + for solver in self.find_all(): + if solver.has_pose('default'): + solver.go_to_pose('default') + # If we aren't mirroring poses, create the default pose + else: + mirrored_solver.add_pose_from_current(pose_name='default') + + return mirrored_solver + + def delete(self): + """ + Delete the Maya node associated with this class + """ + # If we have blendshapes, disconnect them all + if self.driven_nodes(type='blendShape'): + for pose in self.poses().keys(): + self.delete_blendshape(pose) + + cmds.delete(self._node) + + # ---------------------------------------------------------------------------------------------- + # ENUM UTILS + # ---------------------------------------------------------------------------------------------- + + def _set_enum_attribute(self, attribute, value): + """ + Sets enum attribute either by string or index value + """ + + if isinstance(value, six.string_types): + + # get enum index + selection_list = OpenMaya.MSelectionList() + selection_list.add(attribute) + + plug = OpenMaya.MPlug() + selection_list.getPlug(0, plug) + + mfn = OpenMaya.MFnEnumAttribute(plug.attribute()) + index = mfn.fieldIndex(value) + + cmds.setAttr(attribute, index) + + else: + cmds.setAttr(attribute, value) + + def _get_mirrored_axis(self, source_transforms, mirrored_transforms): + axis_names = 'xyz' + for index, driver in enumerate(source_transforms): + mirrored_driver = mirrored_transforms[index] + for i, axis in enumerate(driver): + if axis > 0 and mirrored_driver[i] > 0 or axis < 0 and mirrored_driver[i] < 0: + continue + return axis_names[i] + + def _get_mirrored_solver_name(self, mirror_mapping): + """ + Generate the mirrored solver name using the specified mirror mapping + :param mirror_mapping :type pose_wrangler.model.mirror_mapping.MirrorMapping: mirror mapping ref + :return :type str: mirrored solver name + """ + # Grab the solver expression from the mirror mapping and check that this solver matches the correct naming + match = re.match(mirror_mapping.solver_expression, str(self)) + # If it doesn't match, raise exception + if not match: + raise exceptions.exceptions.InvalidMirrorMapping( + "Unable to mirror solver '{solver}'. The naming conventions do " + "not match the mirror mapping specified: {expression}".format( + solver=self, + expression=mirror_mapping.solver_expression + ) + ) + + # Generate the new pose driver name + target_rbf_solver_name = "" + for group in match.groups(): + # If the the group matches the source solver syntax, change the group to be the target syntax. + # I.e if the source is _l_, change it to _r_ + if group == mirror_mapping.source_solver_syntax: + group = mirror_mapping.target_solver_syntax + # If the group matches the target, change the group to the source and swap the sides in the config + elif group == mirror_mapping.target_solver_syntax: + group = mirror_mapping.source_solver_syntax + mirror_mapping.swap_sides() + # Add the group name to generate the new name for the solver + target_rbf_solver_name += group + return target_rbf_solver_name + + def _get_mirrored_transforms(self, transforms, mirror_mapping, ignore_invalid_nodes=False): + """ + Generate a list of transforms that mirror the list given + :param transforms :type list: list of transforms to get the mirrored name for + :param mirror_mapping :type pose_wrangler.model.mirror_mapping.MirrorMapping: mirror mapping ref + :return :type list: list of mirrored transform names + """ + # Generate empty list to store the newly mapped transforms + new_transforms = [] + for transform in transforms: + # Check if the transform matches the target transform expression + match = re.match(mirror_mapping.transform_expression, transform) + # If it doesn't, raise exception. Can't work with incorrectly named transforms + if not match: + raise exceptions.exceptions.InvalidMirrorMapping( + "Unable to mirror transform '{transform}'. The naming conventions do " + "not match the mirror mapping specified: {expression}".format( + transform=transform, + expression=mirror_mapping.transform_expression + ) + ) + # Generate the new pose transform name + target_transform_name = "" + # Iterate through the groups + for group in match.groups(): + # If the the group matches the source transform syntax, change the group to be the target syntax. + if group == mirror_mapping.source_transform_syntax: + group = mirror_mapping.target_transform_syntax + elif group == mirror_mapping.target_transform_syntax: + group = mirror_mapping.source_transform_syntax + # Add the group name to generate the new name for the transform + target_transform_name += group + # If the generated transform name doesn't exist, raise exception + if not cmds.ls(target_transform_name) and not ignore_invalid_nodes: + raise exceptions.exceptions.InvalidMirrorMapping( + "Unable to mirror transform '{transform}'. Target transform does not exist: '{target}'".format( + transform=transform, + target=target_transform_name + ) + ) + # Add the new transform to the list + new_transforms.append(target_transform_name) + return new_transforms diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/base_action.py b/Scripts/Animation/epic_pose_wrangler/v2/model/base_action.py new file mode 100644 index 0000000..e4c97bd --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/base_action.py @@ -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 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/base_extension.py b/Scripts/Animation/epic_pose_wrangler/v2/model/base_extension.py new file mode 100644 index 0000000..e80228b --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/base_extension.py @@ -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 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/context.py b/Scripts/Animation/epic_pose_wrangler/v2/model/context.py new file mode 100644 index 0000000..4719bf0 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/context.py @@ -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 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/exceptions.py b/Scripts/Animation/epic_pose_wrangler/v2/model/exceptions.py new file mode 100644 index 0000000..492b612 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/exceptions.py @@ -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 + """ diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/export.py b/Scripts/Animation/epic_pose_wrangler/v2/model/export.py new file mode 100644 index 0000000..96a04db --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/export.py @@ -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() diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/pose_blender.py b/Scripts/Animation/epic_pose_wrangler/v2/model/pose_blender.py new file mode 100644 index 0000000..369f3b8 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/pose_blender.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/model/utils.py b/Scripts/Animation/epic_pose_wrangler/v2/model/utils.py new file mode 100644 index 0000000..0fc18c0 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/model/utils.py @@ -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) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/__init__.py b/Scripts/Animation/epic_pose_wrangler/v2/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/icons/main.png b/Scripts/Animation/epic_pose_wrangler/v2/view/icons/main.png new file mode 100644 index 0000000000000000000000000000000000000000..cc9cf783ffa5751ba2f7db4e9b9f582750d66262 GIT binary patch literal 10406 zcmYLvc~p{H_rGQ3Z7?fev&^C4mNzvgA{+qUt~b4{H?z1C-+bJ>q(piunfme~-m<^NG*@uJ^yG(_mpKg*uJ#d^Jna(l-O%KV=rAbJh|iP zk%Gf|zy8|sXTIK(Q#R&rU3cVrtmu$i*-BqYU9Zw_kxEFAJl;uIWJrb^s|dnDOtKJ< zWl-(pmEH#HOKrS=RyW7!d_=|LeCW6Gs5XM>3ET7&@vMaG+A87TOi%nIPc<{^_Ttmr zd`G^~@ahv%6=T#?r`2ah%gr{EilxLXcP|=4AGP>{gRrVwOtyGtjc zYjvV{Hij`VgJ(ATKM+@+tf(t{(nGa$nz>J7d?SK2l~r-%6c`a1@r54?oQ*7tE6O>5 zg-^l~(N@A+64pe4Ug{8S@wb+;kY-)8@ZTwptWq*tRT4xD%rV62qs{0X&hJU17nF`u zb=al2-fstDK2SaIiuE|0y-Z~Ngk;v~4g8@`Y(GjmOBx#T$*To;qycI5Yno3>46rR! zlztN}SFlE7$$dPT;8`H%;&T>qaShh(Tji6Y7%x=1Zmssc={(?2>_}GpmZEQO%Cm3*gIN{;TU;-0@P!QJ%diyD-HIFcsr9$jT62 zRqobPaNY%)CK`A)vRqdRwF?G{43(p1h%mDoQ|Eh7RX&dYrQ=r&2fGgOw-)WOfb@#v zU6MkkHfbfGUKAe8_V1l;57Cle6&O>!CH3&Z{d?@^`?Id(RnB&--pM;%a%v3$$bbs1 zz7#?tU@q*YEO! zBrbE-z1b#7Wi8Z{o8nb-miFnADPQZT`4CQja^-C{xD-%zI<+Gy66z)Uz$7fEN~jN( z@?e9dI$P>2gtn*P@q)*lbK3Q!*(TBzj*w5s}uuHBXw3FCC zbd$`3&Ufw}>Z*&dz~aubFNp7g;h%)(#46-n^BS@-k2S3#XjO=NtP)E67+FkRPc8Ig z=U0qLcXWF3&7`f&s;7RM+6CzBZei*pIQOxw+o$jVkXzr}MYFsm(B~RUhf58*a^>@v zkbiKRz1(lAeQp>cl2=~w)hdGdHyt~cxO)osI$GKE2c4gl!}}Vqp>nn1Kkq0=CzH7g zWhX$9xz|$6qH{ZlpcNl!ae2Kyqjs3`3OBZbCdXX)r6V#=Gi6!@-~0ZrXTVg z-0hch-38t#Lp3<6i9Nouf09uX-R!6XU4po3f|WU{lwO++1i)p6*bsRyKSmI}=$pM* z_jAIa(e0>V7E)kHqz}j{fIfT8x~DTzD`bB3(HcD*STj%VEN%epsi?jaZUdw65+o zxv_>ps)^S^pz*bd2gyN5_|R zTn-PwPZp@#C;^wQ>hv5Y9P*Hy74QBdxqYU8e1CQH%DLh63L;8yO=s~#>BcV1K77M$ zuICyf2bxQSS9S^18rZQA;`b*LXWG_YK*XVsk7I!6`Tf32^7qN@In@#nMSMwQo^5RYt~!T&&fAkV&I3FCDSs&_r_3xQXGnM zA=Oq5tJe0lALFeCI|;1q3Bn>CnnGy?PL_BGPL{xovES&rEY8baIC+1X&?l zdl(Z1Skzq83i+=YXJic+8eaQk&LIts6?OG04h7AcpkzT@Z7{1|-swX;Xt55nSde?( zG^r&Rrh7&|o;sJ@mNy~0+{%3ObSy)Ast!NsXo5gKRZ3?BJ<(JC=B);5 zvH?y3ssu9yH(0}$Bo0gSMu|(s;WwS}1B$U_K8ZJ9PM>Jd+V&yX694 z0B*~ECU~c58)1`0i}SQ5)yhl_pg@IRr?8T)F(HEtm$%#@(CWIgd| zU7yVkHFM+V?zj3TSvEXv)YUgxJU`Rob*;0YXh8J}5jkoIn?H7&@q5ykAHp=LzUXN! z(@pxE`Jv7bKpLB;rt?J-Q4I<7ynhJ2iGOo@$o%QVS;rru18|u|DpRP0X6!AFx{iwx7{(T z7RxoTIA$(6`)wKaPq=Nu_FmLvo(A{XTLL1My-6Sbs)#7zx$-;- zU2SxxYDzaehs}(8KVX+_2#OChP0Gt+%rX`$a;he-H}j7?7&AZ=)6~+{g$`3^!+WRA zl5wgqLBC-%k zPy;{rI(JSaJ`}FycJS4w?YC*yTyFw{mfRn8+p?h=vF+wXA8R(;d5JU#-IJ96Wd_zV zPM*Ox)gXks;K!vyHGqU6+*8By$Qo)nwv{|lsAr5(5j3qupN+{QVI0KhCl$Nk`K^Iy zNUxTsy25DedoFOpVZy2$5swJk@rf$`ti_E5nW6+O!Ul1^U}g=ciDdbrIskKXF=`%- znaV|aR?wL|39?-QpPDXwc<8+6HU(_=NB9aLOtgah6=?sW_WhGpdwOqq0u=|k5)zp~ z&7$VOJdWM^{`(^+0^7^9jhGXNe9)Ie-)ogCIYoAh-%0F#lc>=U+{N_^0H_N8K=8sa zrF-D>TJ@o@Jhe5f#t;oZShkhnRf%+0k5~2cUPr*e+iiCHUf*4?J~VnayzfrEsle_HTSrZAIbTn$JrUwr z%Nhf@?PmHfGyjHbG}HnuRyi4ll^K~#f7YPTgov0-`WW!sftlmYKBszyq4(^?k!NED zkSlV_Af1|+V;wKuQrANf^u3cslARQ7|M32f+=nd4hkNxcxXA$dm4Tvd&U_;YYM(a1 zE-Ji4l?nn$zsC@*L$2t~&S_i+(r!_N23i zBT-n07@W|EWBhW%3^=Ait7vw*RE!^2RCVuppz6(Fmb}-JOeqS9X09!awC7cfLk`Ox z7&>I`1e+o2;Qj_cENebZxmBuIUMN9`F_S>$KSzohqrR#DemKXosB4gToRbFo^@?08 zNPlE%$1cKB_&bzW(aA}Uh|*4NK`PlhL&{X*vxTuhtjW< zV+^whd`gt8!b>!-Tez(JHBMy%z9n0pZK+~Rxe`D#(|6}p;yAWoV0eS}6ngP1@(0F^ z8B&Dp!sY5?NC%lux;t=YY&--RIm0#o;fHyoL!XT~Rh8n*xFGfWve#lwv#Z(squUA2Hv33^?X;t&*$n|HW1H{-Sh@I%$GOa|{ZZJ=yAH%NYOtxm|p|UU#RRIfSD*EUg!}BwS<>CZo+z;4uw584Z7OY;*?`K+O z&J95dKYc2eTaz@M8ud><0py-z-}6#!`|#AQ9+sxS1KbFr3)khd;_XAU$-v}i8zy&j zL8B+ZjkOT{rkX9-tF@X*5_-(&VuU_S2g7KIbu0I2?kSgx*XKvt3mu7xig6C68aj;P zwgYm1Lfw^|5|5Z(&ie7HG1aECm3Onp%eGmt&EEv>1r-S91i6L!dUP+A{N<*PnaZE> zg`N#liZU-cpw2&h_~)dT>aCQF>*2h2!+`9qSq)f`rLB_hh~+j$Hm+%^ya>U73ZDAN zI4jLhUdxwvck-E)JQ%Q%$2`~eXU0KnQZc~>uz(Mz5stgOhbmfTG8{8@s)M!ZgcfdY z!8S0Ra#K(ee5TFcYOW(j6>~?;lnsmcuq`Whg~$I&$f2jaspH!k^V^Mk0iE9+fouY< zkcYY>J!e|UJdf9htekY3@k;!2-GwQG&p6gXTQEv`jV$vCB` zOjL54Lzv}s6?jN!JM@QJHhbB>u1R0tV*cAgDlL+E zfsF50Fl$7?jFmHcDh{bX3bkI`fvELJ!?8ReOn49_v;V54Ap$6lA}qD58H#;4i@Nid zDW7jCt6^q5;o;rE_>{| zwDkA_R09Oe%C#Tk0>#@|mi1m%8quDtJj9tFCL5JOy!-xI{IZ5%*+}E90~0;!{NDmo z47IFe)7U~IDolMk7`x85NAx~DXlJ>|dlq$I!klO&!!F>2d zYa!n#=NLZ9e64SB134K`0`z*WbqWs#_`@TMX_qrl3%Gu3z&=Nc#*p;g`$faFe)ok* zoFch2MtLr1vT^&*b9X-P+LTbZ-&qex4=D0X0|V*vzubCOZ}a`0qhrA)%WVs{xn@P6 zDBdrx<}%_CR#O#`XD$#v9C{ce2 zoGQ#6FcbMS?##Zs?wjustf84uFgdKhTmNRbPq#=Ld4__K-2|^Luu8O%8pgGBbfhUg z=&I~7aV65ykd7|RRzmt>EY`>2GJiv$DgooI=5a z=DQshsgvx;{n`3aag!Fr934#Q!KIeY7u2VI-MPUU2s5?JgYzRan{varFIj6jZHw^D zY-CyEzVcxaoHeF;=d8-ze0Scs;|<y|v9jT62F@p9k*H$=K( zys5J6T~wJl#i>-A36o@M$geQBQct>>78<8JIGpTY5WW zw7q?-(eIb+R+>*F8a{uU>w^$WKP69x4o~S%=F#kq37{-=*~pk`P;`a9%MNtl`&V|X zw`}z?^oZo+)vgZPDJ1O0|6# z-y$#K0rT|FqzvP$t5GJCUWUEG^o%xc{1j|6keRk_Lrt+dKuiFn zL9AA9gkM}fWBA;(N}Q(yh|@ryHq6WRAS%c8uf9rgQyW_543P5Ua7WV9kFRIzBk>a& z`{84Z_2z0son!29uA&LDSWDw4tRCI-0@$8h_IJb9bz+webmfzxE29!2FqhyStkq%M&xu`ox7v^+nGO2vn$wdPgtE5R99~KGk zQPmReP8}NFb#Kqtp*?AIk*i~TyLF=n*RAt>nGy-R8Cfsh?SHq67jtE5lf^7t;!NKIsdsc075B_tg)Bc&A%jsQEjVvo&r7-s8v-jR zgnszKc5-*@Jr?4=RyA7{6S=X!A$~@`;4=Q25^nuZrWz~hdc&U)LzR7oaviMLZSNF7 z$G?1x)ebBBhI)N#&qf7ky?!YzYxU_NZ_k^NnnjY^L~eGlR?U7f?P{A*ba4J5OlA-% zvcyz57qi7;+;Q7#3$*3N=&lPc*9HB48$o4eFbfw}m{pK4Nxjto20wngsI=!v|7^|$ zztxv_n_@#Y;z15h)batUr-a;_+S$3Dp7fFM+LxDwsRoCjGjnzp1r-nM^beRqZ1ZS@ zs(yWjvMxegpKjQ~{oO&5v-I`UHEum)!O8x)DWz#g5_Ff6HasEGb}WF&aJsC1tGhV0Io_K zj(&49$^a87pD{^wN)++Zc+TsqS6zI9P=zcgw!c0mL>`v_jOS9MnMr;be(d*O$1o2} zy@+Z;hh2_mSNTn!hX+g+eXwj~wdqfOF1BIEfP9O@n>AR(7@Ms0nsV0aAp+wQ=n57w z&JBQFFRAEIy6RR$E^F z`;LPpL$-XYB~GfJj3^LyT4Vb&GU|G!;hw8>=-cwfTisr=xl0D0HwlrOic#P@;0)z1 zd%AA&p{skijAwRd>}9)MF8cE;eGF}+c)Azd(#n*7%j|Z7$uOr zarYJJqHqyjLU@Gc^^c`>V8;;?2sH|1(#@7#0X>)j(_2#fc6a2G=iiyq)8&-SNO;O) z)N1U+Ny*C#)5yjr88ZJ>tk^MTUFe-6rLO)(_gyt}mp z!g%ApK^Mz=Y?y@=wRiRtL=#C@eIoUHLS57oJCnB!VKKcN?Uh6q5g_X~0q3X86103y z=Sp6#&Eb-&`3(z$6H=k%lwmwDkkloye7cbAbgx?j)_7`oG5Fl6@zoZnx6(c+i z5f}Mu?oR7^@o7Sjl~VG047$Ch=*!C1IPV11p{&ugpyJm@G_C}NN=JKhz4)KQDJ#{P z>m7<>LI2_o%EnVgo3%y^)1U=;%TqCPy$wSvqX*mGdckByaSI%j zIyM=%wiVFmZ+N$_Y-$g%9z~f77oRbfWXLKwoj+?smsbTrjD9?Up?h z-yEc87d$;#xOTe3hwFVvY|u8Z0V5-et3=iw7FC?jrC__UHgg26-C*%|KwL6-Ro+7! zyKVQC4e5OmBtLO_OkR=dhn$q&XeOZg?OFVZBOR$Grh>9jPZT0*RL+_1KG7@C;VG!x z>x(E5f`EB=3nG0D&i#JkBddhx4-kUE=#u_Je+U}ZE*Jl|cg5qaL-N)q<>tK=zejKD z8%G6=c@>JdA8kH!@Rpqs44`PMmF&s~^cmmE1uf69v0#^#DAfZeCCRbFaVx@wjCL-s zr7v+c5>EEvv{Qs95OBq(g|6o7PIp9wO+FV#xs>PMN81o;T;ru+K=Y2j4WxnPNfW=+ zoVuUwsa8=@OZ%4#uu|?wF^rPq;)~2Kx@J+;uZ{jd?eGI&;Yhn{p)Y5+mDa=IT8&~e z_G7kltc8*BoOj7chZ~HJ z>uF?p4cv?7-AcclCtrsQUP0Z(UC*mKRZs!UGF2Xd#mE6sGe4cr)qLE&GkfzxA8HIE zw&HW|Ca3z~I2!w;S*2*b)|&n*E2}}t?;KH}?S!%$7ZN60%OLl!RhD)PD|`I==3iy_ z^k+pUs%7r-jIHsA{!Os6?@>a~u~1d}I{k(UnE(;U**GX|5ANUH4fwHQI7cv@4;Mrb zQyuBAnX@qnBeCUu+Bd|HMR;K0T}!S7;Zhn}ntVbyNs#|YJ$`O; zXwoZZ&wkg_&=7kJh!)*vq zpc!e?$$cF;pGJEC(Qhi^k39@TFQL>A9fZ>oB;B>8!)t%jW`BV6O)2wl^;|t4P3L`5 z9CwT_2^L#Nmd>9VoWU0k5`0+PEg*3_Vn&8s43 z*2M0{CdZOivh~XD6zNZ&0xv)9PfGQc)}2>;FYjl}&e<1#Z8uTEo$NAesqv#LSE!8P zKsWnySqBra=pj*Ow#<-35GUl3=Bo*b0Dfw})0)dMB3wz<399gT*#M}DYPk$hN>0wy z&gCf8)2&gbr32Ax9}SkDAcn2O_iqp>QZ>%fZ;~4r;}dl?0)syWCm1^yp;_$+a(rTZ!a)oC#O1*Ig@#Z#$ai4ot~)DicW04eO`MAf-v~$38EOa|sx(AnVhjiv zCj%t+OUDR18~MYB`fOn02sRXsq?|pTIWXPLFSq?3MB2of?0^Sw<^o!Bcxv{OUS>)6 z(;Bl5_>i!n?29R9Wc|j|I*LjB z+^v^&`_WT3s+U_I`oBetP;0MkL-z>8tqbKt!WHt>t~y6~NN+pVuXBtbj+{)7fi5*X z*6FxrWQ?dh%ha|Y;h}=@eFg)&-}P0L1rT+yiW$r)9EhRsA7Sdv(-U+gZP;|jy$;gO zx*B$gnfUgQZ9~HkwNIQ?6Wnxjm|f2DD|~}*Ja)fVa@lr4&5lpXn*E;?Aw6RI0uU-b z?q#dJF32nkY88vl1Yw$zi>@XmD(YDe%Mmn?E@{r7cg3-<=I0MSjd?Jioz*e?Hq-LH z^$%O#ZZH4LkCE zHr0c_1HQU6+*Mi*su_`~U?}1T9fKa=8>p~!hUR-iz_i=2avob(KDTQlu^YE}U+on? ziaQeSL2^jMxNUXY^?s6GODL0vTe$@wi4;$I!<=DR;t#c*IT>QDO#E64zu73EbkI&H zA=MG6ix*~Yvlfa91?eCj`n$UGQ4%(qUx}Z=U^|{O0w-~+OO8gRfiZEYUbROMu09#t zJM9)K*E>N-Qs4uxlwnLdMI9Zro~-_3LE>zO*eH>#-I|nNPSk$!hVCF(OH~V3ooK<& zX^ZWIv4gpQ;&7&KKKkl5$#$O#stM{@iFXsiMQ{;pZAQ6WwKJ6bhRm1&Inppdlg91!|C8UjFP0+lT- zngt0^S%@c~n8sA0@-n1(h@VflD)39jdN%TOPkFeZ@u# zAwNR|))-Ud@F5%zViInF z5vGjJ4OBvNUvuFzB~Kx@tnCb}rE%4UZB7B + + MainWindow + + + + 0 + 0 + 883 + 693 + + + + MainWindow + + + + + + + Qt::Horizontal + + + + Qt::Vertical + + + + + 0 + + + + + + 0 + 0 + + + + + 10 + 75 + true + + + + RBF SOLVERS: + + + true + + + + + + + + 16777215 + 16777215 + + + + QAbstractItemView::ExtendedSelection + + + + + + + RMB Solver for options + + + Qt::AlignCenter + + + + + + + 2 + + + + + + 10 + + + + Edit Selected Solver + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 2 + + + + + + + + 2 + + + + + + 10 + 50 + false + + + + Create Solver + + + + + + + + 10 + 50 + false + + + + Refresh Solvers + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 2 + + + + + + + + 2 + + + 0 + + + + + + 10 + 75 + true + + + + Delete Solvers + + + + + + + + 10 + + + + Mirror Solver + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 2 + + + + + + + + Qt::Horizontal + + + + + + + + + + + + 10 + 75 + true + + + + POSES: + + + true + + + + + + + QAbstractItemView::ExtendedSelection + + + + + + + 2 + + + + + true + + + + 10 + 50 + false + + + + Add Pose + + + + + + + true + + + + 10 + 50 + false + + + + Update Pose + + + + + + + true + + + + 10 + 50 + false + + + + Delete Pose + + + + + + + + + 2 + + + + + + 10 + + + + Mute Pose + + + + + + + true + + + + 10 + 50 + false + + + + Mirror Pose + + + + + + + + 10 + + + + Rename Pose + + + false + + + + + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + + + 0 + + + + + + 10 + 75 + true + + + + DRIVER TRANSFORMS: + + + true + + + + + + + QAbstractItemView::ExtendedSelection + + + + + + + 2 + + + + + + 10 + + + + Add Driver Transforms + + + + + + + + 10 + + + + Remove Driver Transforms + + + + + + + + + + + + + + 10 + 75 + true + + + + DRIVEN TRANSFORMS/BLENDSHAPES + + + true + + + + + + + QAbstractItemView::ExtendedSelection + + + + + + + 2 + + + 0 + + + + + + 10 + + + + Add Driven Transforms + + + + + + + + 10 + + + + Remove Selected + + + + + + + + + + + + 10 + + + + Create Blendshape + + + + + + + + + + + + 10 + + + + Add Existing Blendshape + + + + + + + + 10 + + + + Edit Blendshape + + + + + + + + + + + + + + + + 0 + 0 + + + + + 306 + 99 + + + + Qt::LeftDockWidgetArea|Qt::RightDockWidgetArea + + + Utilities + + + 2 + + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + + + 0 + 0 + + + + + 300 + 0 + + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContentsOnFirstShow + + + true + + + + + 0 + 0 + 298 + 642 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + 10 + 75 + true + + + + Settings: + + + true + + + + + + + Mirror Mapping: + + + false + + + + + + + 2 + + + + + true + + + Mirror Mapping File + + + + + + + + 10 + + + + Select File + + + + + + + + + Qt::Vertical + + + + 20 + 585 + + + + + + + + Qt::Horizontal + + + + 40 + 1 + + + + + + + + + + + + + + + + + + + 0 + 0 + 883 + 21 + + + + + Help + + + + + + File + + + + + + + + + Edit + + + + Style + + + + + + + + + + + + Connect Selected Skin Nodes + + + + + Create Geometry From SkinNode + + + + + true + + + true + + + Unbind Geometry When Saving + + + + + Documentation + + + + + Github + + + + + Import Drivers + + + + + Export All Drivers + + + + + Export Selected Drivers + + + + + true + + + Use Maya Style + + + + + + diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/pose_wrangler_window.py b/Scripts/Animation/epic_pose_wrangler/v2/view/pose_wrangler_window.py new file mode 100644 index 0000000..acc553b --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/view/pose_wrangler_window.py @@ -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()) diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/style.qss b/Scripts/Animation/epic_pose_wrangler/v2/view/style.qss new file mode 100644 index 0000000..c584da5 --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/view/style.qss @@ -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; +} \ No newline at end of file diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/ui_context.py b/Scripts/Animation/epic_pose_wrangler/v2/view/ui_context.py new file mode 100644 index 0000000..be7fa9d --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/view/ui_context.py @@ -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 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/widget/__init__.py b/Scripts/Animation/epic_pose_wrangler/v2/view/widget/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Scripts/Animation/epic_pose_wrangler/v2/view/widget/category.py b/Scripts/Animation/epic_pose_wrangler/v2/view/widget/category.py new file mode 100644 index 0000000..0bb1e3d --- /dev/null +++ b/Scripts/Animation/epic_pose_wrangler/v2/view/widget/category.py @@ -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)