Files
Nexus/2023/scripts/animation_tools/studiolibrary/mutils/animation.py
2025-11-24 00:15:32 +08:00

840 lines
25 KiB
Python

# Copyright 2020 by Kurt Rathjen. All Rights Reserved.
#
# This library is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. This library is distributed in the
# hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
import os
import shutil
import logging
from studiovendor.Qt import QtWidgets
import mutils
import mutils.gui
try:
import maya.cmds
except ImportError:
import traceback
traceback.print_exc()
logger = logging.getLogger(__name__)
MIN_TIME_LIMIT = -10000
MAX_TIME_LIMIT = 100000
DEFAULT_FILE_TYPE = "mayaBinary" # "mayaAscii"
# A feature flag that will be removed in the future.
FIX_SAVE_ANIM_REFERENCE_LOCKED_ERROR = True
class PasteOption:
Insert = "insert"
Replace = "replace"
ReplaceAll = "replace all"
ReplaceCompletely = "replaceCompletely"
class AnimationTransferError(Exception):
"""Base class for exceptions in this module."""
pass
class OutOfBoundsError(AnimationTransferError):
"""Exceptions for clips or ranges that are outside the expected range"""
pass
def validateAnimLayers():
"""
Check if the selected animation layer can be exported.
:raise: AnimationTransferError
"""
if maya.cmds.about(q=True, batch=True):
return
animLayers = maya.mel.eval('$gSelectedAnimLayers=$gSelectedAnimLayers')
# Check if more than one animation layer has been selected.
if len(animLayers) > 1:
msg = "More than one animation layer is selected! " \
"Please select only one animation layer for export!"
raise AnimationTransferError(msg)
# Check if the selected animation layer is locked
if len(animLayers) == 1:
if maya.cmds.animLayer(animLayers[0], query=True, lock=True):
msg = "Cannot export an animation layer that is locked! " \
"Please unlock the anim layer before exporting animation!"
raise AnimationTransferError(msg)
def saveAnim(
objects,
path,
time=None,
sampleBy=1,
fileType="",
metadata=None,
iconPath="",
sequencePath="",
bakeConnected=True
):
"""
Save the anim data for the given objects.
Example:
import mutils
mutils.saveAnim(
path="c:/example.anim",
objects=["control1", "control2"]
time=(1, 20),
metadata={'description': 'Example anim'}
)
:type path: str
:type objects: None or list[str]
:type time: (int, int) or None
:type fileType: str or None
:type sampleBy: int
:type iconPath: str
:type sequencePath: str
:type metadata: dict or None
:type bakeConnected: bool
:rtype: mutils.Animation
"""
# Copy the icon path to the temp location
if iconPath:
shutil.copyfile(iconPath, path + "/thumbnail.jpg")
# Copy the sequence path to the temp location
if sequencePath:
shutil.move(sequencePath, path + "/sequence")
# Save the animation to the temp location
anim = mutils.Animation.fromObjects(objects)
anim.updateMetadata(metadata)
anim.save(
path,
time=time,
sampleBy=sampleBy,
fileType=fileType,
bakeConnected=bakeConnected
)
return anim
def clampRange(srcTime, dstTime):
"""
Clips the given source time to within the given destination time.
Example:
print clampRange((15, 35), (20, 30))
# 20, 30
print clampRange((25, 45), (20, 30))
# 25, 30
:type srcTime: (int, int)
:type dstTime: (int, int)
:rtype: (int, int)
"""
srcStart, srcEnd = srcTime
dstStart, dstEnd = dstTime
if srcStart > dstEnd or srcEnd < dstStart:
msg = "The src and dst time do not overlap. " \
"Unable to clamp (src=%s, dest=%s)"
raise OutOfBoundsError(msg, srcTime, dstTime)
if srcStart < dstStart:
srcStart = dstStart
if srcEnd > dstEnd:
srcEnd = dstEnd
return srcStart, srcEnd
def moveTime(time, start):
"""
Move the given time to the given start time.
Example:
print moveTime((15, 35), 5)
# 5, 20
:type time: (int, int)
:type start: int
:rtype: (int, int)
"""
srcStartTime, srcEndTime = time
duration = srcEndTime - srcStartTime
if start is None:
startTime = srcStartTime
else:
startTime = start
endTime = startTime + duration
if startTime == endTime:
endTime = startTime + 1
return startTime, endTime
def findFirstLastKeyframes(curves, time=None):
"""
Return the first and last frame of the given animation curves
:type curves: list[str]
:type time: (int, int)
:rtype: (int, int)
"""
first = maya.cmds.findKeyframe(curves, which='first')
last = maya.cmds.findKeyframe(curves, which='last')
result = (first, last)
if time:
# It's possible (but unlikely) that the curves will not lie within the
# first and last frame
try:
result = clampRange(time, result)
except OutOfBoundsError as error:
logger.warning(error)
return result
def insertKeyframe(curves, time):
"""
Insert a keyframe on the given curves at the given time.
:type curves: list[str]
:type time: (int, int)
"""
startTime, endTime = time
for curve in curves:
insertStaticKeyframe(curve, time)
firstFrame = maya.cmds.findKeyframe(curves, time=(startTime, startTime), which='first')
lastFrame = maya.cmds.findKeyframe(curves, time=(endTime, endTime), which='last')
if firstFrame < startTime < lastFrame:
maya.cmds.setKeyframe(curves, insert=True, time=(startTime, startTime))
if firstFrame < endTime < lastFrame:
maya.cmds.setKeyframe(curves, insert=True, time=(endTime, endTime))
def insertStaticKeyframe(curve, time):
"""
Insert a static keyframe on the given curve at the given time.
:type curve: str
:type time: (int, int)
:rtype: None
"""
startTime, endTime = time
lastFrame = maya.cmds.findKeyframe(curve, which='last')
firstFrame = maya.cmds.findKeyframe(curve, which='first')
if firstFrame == lastFrame:
maya.cmds.setKeyframe(curve, insert=True, time=(startTime, endTime))
maya.cmds.keyTangent(curve, time=(startTime, startTime), ott="step")
if startTime < firstFrame:
nextFrame = maya.cmds.findKeyframe(curve, time=(startTime, startTime), which='next')
if startTime < nextFrame < endTime:
maya.cmds.setKeyframe(curve, insert=True, time=(startTime, nextFrame))
maya.cmds.keyTangent(curve, time=(startTime, startTime), ott="step")
if endTime > lastFrame:
previousFrame = maya.cmds.findKeyframe(curve, time=(endTime, endTime), which='previous')
if startTime < previousFrame < endTime:
maya.cmds.setKeyframe(curve, insert=True, time=(previousFrame, endTime))
maya.cmds.keyTangent(curve, time=(previousFrame, previousFrame), ott="step")
def duplicateNode(node, name):
"""Duplicate the given node.
:param node: Maya path.
:type node: str
:param name: Name for the duplicated node.
:type name: str
:returns: Duplicated node names.
:rtype: list[str]
"""
if maya.cmds.nodeType(node) in ["transform", "joint"]:
new = maya.cmds.duplicate(node, name=name, parentOnly=True)[0]
else:
# Please let us know if this logic is causing issues.
new = maya.cmds.duplicate(node, name=name)[0]
shapes = maya.cmds.listRelatives(new, shapes=True) or []
if shapes:
return [shapes[0], new]
return [new]
def loadAnims(
paths,
spacing=1,
objects=None,
option=None,
connect=False,
namespaces=None,
startFrame=None,
mirrorTable=None,
currentTime=None,
showDialog=False,
):
"""
Load the animations in the given order of paths with the spacing specified.
:type paths: list[str]
:type spacing: int
:type connect: bool
:type objects: list[str]
:type namespaces: list[str]
:type startFrame: int
:type option: PasteOption
:type currentTime: bool
:type mirrorTable: bool
:type showDialog: bool
"""
isFirstAnim = True
if spacing < 1:
spacing = 1
if option is None or option == "replace all":
option = PasteOption.ReplaceCompletely
if currentTime and startFrame is None:
startFrame = int(maya.cmds.currentTime(query=True))
if showDialog:
msg = "Load the following animation in sequence;\n"
for i, path in enumerate(paths):
msg += "\n {0}. {1}".format(i, os.path.basename(path))
msg += "\n\nPlease choose the spacing between each animation."
spacing, accepted = QtWidgets.QInputDialog.getInt(
None,
"Load animation sequence",
msg,
spacing,
QtWidgets.QInputDialog.NoButtons,
)
if not accepted:
raise Exception("Dialog canceled!")
for path in paths:
anim = mutils.Animation.fromPath(path)
if startFrame is None and isFirstAnim:
startFrame = anim.startFrame()
if option == "replaceCompletely" and not isFirstAnim:
option = "insert"
anim.load(
option=option,
objects=objects,
connect=connect,
startFrame=startFrame,
namespaces=namespaces,
currentTime=currentTime,
mirrorTable=mirrorTable,
)
duration = anim.endFrame() - anim.startFrame()
startFrame += duration + spacing
isFirstAnim = False
class Animation(mutils.Pose):
IMPORT_NAMESPACE = "REMOVE_IMPORT"
@classmethod
def fromPath(cls, path):
"""
Create and return an Anim object from the give path.
Example:
anim = Animation.fromPath("/temp/example.anim")
print anim.endFrame()
# 14
:type path: str
:rtype: Animation
"""
anim = cls()
anim.setPath(path)
anim.read()
return anim
def __init__(self):
mutils.Pose.__init__(self)
try:
timeUnit = maya.cmds.currentUnit(q=True, time=True)
linearUnit = maya.cmds.currentUnit(q=True, linear=True)
angularUnit = maya.cmds.currentUnit(q=True, angle=True)
self.setMetadata("timeUnit", timeUnit)
self.setMetadata("linearUnit", linearUnit)
self.setMetadata("angularUnit", angularUnit)
except NameError as msg:
logger.exception(msg)
def select(self, objects=None, namespaces=None, **kwargs):
"""
Select the objects contained in the animation.
:type objects: list[str] or None
:type namespaces: list[str] or None
:rtype: None
"""
selectionSet = mutils.SelectionSet.fromPath(self.poseJsonPath())
selectionSet.load(objects=objects, namespaces=namespaces, **kwargs)
def startFrame(self):
"""
Return the start frame for anim object.
:rtype: int
"""
return self.metadata().get("startFrame")
def endFrame(self):
"""
Return the end frame for anim object.
:rtype: int
"""
return self.metadata().get("endFrame")
def mayaPath(self):
"""
:rtype: str
"""
mayaPath = os.path.join(self.path(), "animation.mb")
if not os.path.exists(mayaPath):
mayaPath = os.path.join(self.path(), "animation.ma")
return mayaPath
def poseJsonPath(self):
"""
:rtype: str
"""
return os.path.join(self.path(), "pose.json")
def paths(self):
"""
Return all the paths for Anim object.
:rtype: list[str]
"""
result = []
if os.path.exists(self.mayaPath()):
result.append(self.mayaPath())
if os.path.exists(self.poseJsonPath()):
result.append(self.poseJsonPath())
return result
def animCurve(self, name, attr, withNamespace=False):
"""
Return the animCurve for the given object name and attribute.
:type name: str
:type attr: str
:type withNamespace: bool
:rtype: str
"""
curve = self.attr(name, attr).get("curve", None)
if curve and withNamespace:
curve = Animation.IMPORT_NAMESPACE + ":" + curve
return curve
def setAnimCurve(self, name, attr, curve):
"""
Set the animCurve for the given object name and attribute.
:type name: str
:type attr: str
:type curve: str
"""
self.objects()[name].setdefault("attrs", {})
self.objects()[name]["attrs"].setdefault(attr, {})
self.objects()[name]["attrs"][attr]["curve"] = curve
def read(self, path=None):
"""
Read all the data to be used by the Anim object.
:rtype: None
"""
path = self.poseJsonPath()
logger.debug("Reading: " + path)
mutils.Pose.read(self, path=path)
logger.debug("Reading Done")
def isAscii(self, s):
"""Check if the given string is a valid ascii string."""
return all(ord(c) < 128 for c in s)
@mutils.unifyUndo
@mutils.restoreSelection
def open(self):
"""
The reason we use importing and not referencing is because we
need to modify the imported animation curves and modifying
referenced animation curves is only supported in Maya 2014+
"""
self.close() # Make sure everything is cleaned before importing
if not self.isAscii(self.mayaPath()):
msg = "Cannot load animation using non-ascii paths."
raise IOError(msg)
nodes = maya.cmds.file(
self.mayaPath(),
i=True,
groupLocator=True,
ignoreVersion=True,
returnNewNodes=True,
namespace=Animation.IMPORT_NAMESPACE,
)
return nodes
def close(self):
"""
Clean up all imported nodes, as well as the namespace.
Should be called in a finally block.
"""
nodes = maya.cmds.ls(Animation.IMPORT_NAMESPACE + ":*", r=True) or []
if nodes:
maya.cmds.delete(nodes)
# It is important that we remove the imported namespace,
# otherwise another namespace will be created on next
# animation open.
namespaces = maya.cmds.namespaceInfo(ls=True) or []
if Animation.IMPORT_NAMESPACE in namespaces:
maya.cmds.namespace(set=':')
maya.cmds.namespace(rm=Animation.IMPORT_NAMESPACE)
def cleanMayaFile(self, path):
"""
Clean up all commands in the exported maya file that are
not createNode.
"""
results = []
if path.endswith(".mb"):
return
with open(path, "r") as f:
for line in f.readlines():
if not line.startswith("select -ne"):
results.append(line)
else:
results.append("// End")
break
with open(path, "w") as f:
f.writelines(results)
@mutils.timing
@mutils.unifyUndo
@mutils.showWaitCursor
@mutils.restoreSelection
def save(
self,
path,
time=None,
sampleBy=1,
fileType="",
bakeConnected=True
):
"""
Save all animation data from the objects set on the Anim object.
:type path: str
:type time: (int, int) or None
:type sampleBy: int
:type fileType: str
:type bakeConnected: bool
:rtype: None
"""
objects = list(self.objects().keys())
fileType = fileType or DEFAULT_FILE_TYPE
if not time:
time = mutils.selectedObjectsFrameRange(objects)
start, end = time
# Check selected animation layers
validateAnimLayers()
# Check frame range
if start is None or end is None:
msg = "Please specify a start and end frame!"
raise AnimationTransferError(msg)
if start >= end:
msg = "The start frame cannot be greater than or equal to the end frame!"
raise AnimationTransferError(msg)
# Check if animation exists
if mutils.getDurationFromNodes(objects or [], time=time) <= 0:
msg = "No animation was found on the specified object/s! " \
"Please create a pose instead!"
raise AnimationTransferError(msg)
self.setMetadata("endFrame", end)
self.setMetadata("startFrame", start)
end += 1
validCurves = []
deleteObjects = []
msg = u"Animation.save(path={0}, time={1}, bakeConnections={2}, sampleBy={3})"
msg = msg.format(path, str(time), str(bakeConnected), str(sampleBy))
logger.debug(msg)
try:
if bakeConnected:
maya.cmds.undoInfo(openChunk=True)
mutils.bakeConnected(objects, time=(start, end), sampleBy=sampleBy)
for name in objects:
if maya.cmds.copyKey(name, time=(start, end), includeUpperBound=False, option="keys"):
dstNodes = duplicateNode(name, "CURVE")
dstNode = dstNodes[0]
deleteObjects.extend(dstNodes)
if not FIX_SAVE_ANIM_REFERENCE_LOCKED_ERROR:
mutils.disconnectAll(dstNode)
# Make sure we delete all proxy attributes, otherwise pasteKey will duplicate keys
mutils.Attribute.deleteProxyAttrs(dstNode)
maya.cmds.pasteKey(dstNode)
attrs = maya.cmds.listAttr(dstNode, unlocked=True, keyable=True) or []
attrs = list(set(attrs) - set(['translate', 'rotate', 'scale']))
for attr in attrs:
dstAttr = mutils.Attribute(dstNode, attr)
dstCurve = dstAttr.animCurve()
if dstCurve:
dstCurve = maya.cmds.rename(dstCurve, "CURVE")
deleteObjects.append(dstCurve)
srcAttr = mutils.Attribute(name, attr)
srcCurve = srcAttr.animCurve()
if srcCurve:
preInfinity = maya.cmds.getAttr(srcCurve + ".preInfinity")
postInfinity = maya.cmds.getAttr(srcCurve + ".postInfinity")
curveColor = maya.cmds.getAttr(srcCurve + ".curveColor")
useCurveColor = maya.cmds.getAttr(srcCurve + ".useCurveColor")
maya.cmds.setAttr(dstCurve + ".preInfinity", preInfinity)
maya.cmds.setAttr(dstCurve + ".postInfinity", postInfinity)
maya.cmds.setAttr(dstCurve + ".curveColor", *curveColor[0])
maya.cmds.setAttr(dstCurve + ".useCurveColor", useCurveColor)
if maya.cmds.keyframe(dstCurve, query=True, time=(start, end), keyframeCount=True):
self.setAnimCurve(name, attr, dstCurve)
maya.cmds.cutKey(dstCurve, time=(MIN_TIME_LIMIT, start - 1))
maya.cmds.cutKey(dstCurve, time=(end + 1, end + MAX_TIME_LIMIT))
validCurves.append(dstCurve)
fileName = "animation.ma"
if fileType == "mayaBinary":
fileName = "animation.mb"
mayaPath = os.path.join(path, fileName)
posePath = os.path.join(path, "pose.json")
mutils.Pose.save(self, posePath)
if validCurves:
maya.cmds.select(validCurves)
logger.info("Saving animation: %s" % mayaPath)
maya.cmds.file(mayaPath, force=True, options='v=0', type=fileType, uiConfiguration=False, exportSelected=True)
self.cleanMayaFile(mayaPath)
finally:
if bakeConnected:
# HACK! Undo all baked connections. :)
maya.cmds.undoInfo(closeChunk=True)
maya.cmds.undo()
elif deleteObjects:
maya.cmds.delete(deleteObjects)
self.setPath(path)
@mutils.timing
@mutils.showWaitCursor
def load(
self,
objects=None,
namespaces=None,
attrs=None,
startFrame=None,
sourceTime=None,
option=None,
connect=False,
mirrorTable=None,
currentTime=None
):
"""
Load the animation data to the given objects or namespaces.
:type objects: list[str]
:type namespaces: list[str]
:type startFrame: int
:type sourceTime: (int, int) or None
:type attrs: list[str]
:type option: PasteOption or None
:type connect: bool
:type mirrorTable: mutils.MirrorTable
:type currentTime: bool or None
"""
logger.info(u'Loading: {0}'.format(self.path()))
connect = bool(connect) # Make false if connect is None
if not sourceTime:
sourceTime = (self.startFrame(), self.endFrame())
if option and option.lower() == "replace all":
option = "replaceCompletely"
if option is None or option == PasteOption.ReplaceAll:
option = PasteOption.ReplaceCompletely
self.validate(namespaces=namespaces)
objects = objects or []
logger.debug("Animation.load(objects=%s, option=%s, namespaces=%s, srcTime=%s, currentTime=%s)" %
(len(objects), str(option), str(namespaces), str(sourceTime), str(currentTime)))
srcObjects = self.objects().keys()
if mirrorTable:
self.setMirrorTable(mirrorTable)
valid = False
matches = list(mutils.matchNames(srcObjects=srcObjects, dstObjects=objects, dstNamespaces=namespaces))
for srcNode, dstNode in matches:
if dstNode.exists():
valid = True
break
if not matches or not valid:
text = "No objects match when loading data. " \
"Turn on debug mode to see more details."
raise mutils.NoMatchFoundError(text)
# Load the animation data.
srcCurves = self.open()
try:
maya.cmds.flushUndo()
maya.cmds.undoInfo(openChunk=True)
if currentTime and startFrame is None:
startFrame = int(maya.cmds.currentTime(query=True))
srcTime = findFirstLastKeyframes(srcCurves, sourceTime)
dstTime = moveTime(srcTime, startFrame)
if option != PasteOption.ReplaceCompletely:
insertKeyframe(srcCurves, srcTime)
for srcNode, dstNode in matches:
# Remove the first pipe in-case the object has a parent
dstNode.stripFirstPipe()
for attr in self.attrs(srcNode.name()):
# Filter any attributes if the parameter has been set
if attrs is not None and attr not in attrs:
continue
dstAttr = mutils.Attribute(dstNode.name(), attr)
if not dstAttr.exists():
logger.debug('Skipping attribute: The destination attribute "%s" does not exist!' % dstAttr.fullname())
continue
if dstAttr.isProxy():
logger.debug('Skipping attribute: The destination attribute "%s" is a proxy attribute!', dstAttr.fullname())
continue
srcCurve = self.animCurve(srcNode.name(), attr, withNamespace=True)
if srcCurve:
dstAttr.setAnimCurve(
srcCurve,
time=dstTime,
option=option,
source=srcTime,
connect=connect
)
else:
value = self.attrValue(srcNode.name(), attr)
dstAttr.setStaticKeyframe(value, dstTime, option)
finally:
self.close()
maya.cmds.undoInfo(closeChunk=True)
# Return the focus to the Maya window
maya.cmds.setFocus("MayaWindow")
logger.info(u'Loaded: {0}'.format(self.path()))