Traversing the Graph with the Maya Python API 2.0
Or 'Building an animation exporter pt. 2'
On a previous entry we watched how we could extract data from animation curve nodes in Maya using new classes (for the Python API 2.0) like the MFnAnimCurve. There, we had a pretty simple way of getting the outputs of a curve, as we were taking into account only pairBlend nodes. In this entry we will try to have a more robust system that will allow us to find the plug connected to a target node, going through all the dependency nodes that are in the way.
Let's see a scenario where we have a dag node that has been animated, parent constrained and has some attributes muted.
If we need to get all the animation nodes that affect our dag node 'pCube1', we need to do a recursive search. It needs to be the same with the other way around: if we have our animation nodes and know they affect 'pCube1', we need to go through the graph until we get the affected attributes.
First, let's get all the connections to our dag node:
import maya.api.OpenMaya as om
dag_name = 'pCube1'
mlist = om.MSelectionList()
mlist.add(dag_name)
mobj = mlist.getDependNode(0)
mdep = om.MFnDagNode(mobj)
connections = mdep.getConnections() # returns a MPlugArray
Now, let's see how we can get to the source of an attribute:
mplug = mdep.findPlug('scaleZ', False)
msource = mplug.source()
msource.isNull # if null, we know we did not have an actual source plug
msource.node().apiTypeStr # know the node type of the source plug
In this case, msource.node().apiTypeStr returns kAnimCurveTimeToUnitless, which is our target kind of node. We want all animation curves that affect our dag node.
Knowing this, the other way around is pretty straight forward:
import maya.api.OpenMayaAnim as omanim
manimcurve = omanim.MFnAnimCurve(msource.node()) # we know msource is an animation curve
manimcurve.name() # test the name, this fails if the node does not exist
manimcurve_out_plug = manimcurve.findPlug('output', False)
manimcurve_out_plug.destinations() # get all plug's destinations
We're almost ready for traversing the graph recursively, but first, we need to adress a little issue:
When going through the nodes, we're going to get dag nodes, and if we want to know if we have reached our target by name, we need to do it by it's full path. MDependencyNode does not have the getPath() method, so we just need to define a simple function to avoid errors and keep simplicity when searching through the graph:
mdag = om.MFnDagNode(mobj)
dg_name = mdag.getPath().fullPathName() # this is valid
mdag2 = om.MFnDagNode(msource.node())
mdag2.getPath() # this will fail as msource.node() is an animation curve
# workaround
def getMDependencyNodePath(mobject):
mdepnode = om.MFnDagNode(mobject)
try:
node_dg_name = mdepnode.getPath().fullPathName()
except RuntimeError: # dag node does not exist, try a dependency node instead
mdepnode = om.MFnDependencyNode(mobject)
node_dg_name = mdepnode.name()
return mdepnode, node_dg_name
mdep, dg_path = getMDependencyNodePath(mobj)
mdep2, dg_path2 = getMDependencyNodePath(msource.node()) # it works now
Now we can ask for a dependency graph path of any node by just providing an MObject.
We can now search for all the curves that influence a node. We won't go further with constraints or transforms as an animCurve node's influence stops there. When traversing we will store our found animation curves and our searched objects in two different MSelectionLists, so that we won't have cyclic problems.
def _getNodeAnimCurves(dg_node, found, searched):
for c in dg_node.getConnections():
source_plug = c.source()
if source_plug.isNull or searched.hasItem(source_plug): # skip empty plugs
continue
searched.add(source_plug)
source_node = source_plug.node()
source_type = source_node.apiTypeStr
# care only for curveTime nodes
if source_type.startswith('kAnimCurveTime') and not found.hasItem(source_node):
found.add(source_node)
elif source_type.endswith('Constraint') or source_type=='kTransform':
continue
else:
found, searched = _getNodeAnimCurves(om.MFnDependencyNode(source_node), found, searched)
return found, searched
found, searched = _getNodeAnimCurves(mdep, om.MSelectionList(), om.MSelectionList())
Let's iterate over the found MSelectionList to verify that we have the correct animation curves, independently of how further away they were from our dag node:
mitsel = om.MItSelectionList(found)
while not mitsel.isDone():
mcurve = omanim.MFnAnimCurve(mitsel.getDependNode())
print mcurve.name()
mitsel.next()
We have skipped muted and pairBlend nodes successfully!
Now, how can we know the attribute that is being affected on our dag node just from the animation curves? We should not try to guess just by the name of the curves, as that would be prone to failure.
Let's see what we get by just asking the destinations of a curve:
mitsel = om.MItSelectionList(found)
while not mitsel.isDone():
mcurve = omanim.MFnAnimCurve(mitsel.getDependNode())
mcurve_out = mcurve.findPlug('output', False)
outputs = mcurve_out.destinations()
for o in outputs:
print o, o.node().apiTypeStr
mitsel.next()
We get the proper kTransform, kMute and kPairBlend nodes that we have had since the beginning. How can we get to our dag nodes's plug?
To tackle this issue, we will try to get the proper output for each plug (e.g. input affects output on kMute nodes. Knowing the attributes affected by a plug is relatively easy:
out_mobj = o.node() # get the node of the plug
out_attr = o.attribute() # get the attribute the plug is refering to
print(om.MFnDependencyNode(out_mobj).getAffectedAttributes(out_attr)) # returns all attributes affected by this plug
mitsel = om.MItSelectionList(found)
while not mitsel.isDone():
mcurve = omanim.MFnAnimCurve(mitsel.getDependNode())
mcurve_out = mcurve.findPlug('output', False)
outputs = mcurve_out.destinations()
for o in outputs:
print o, o.node().apiTypeStr
print om.MFnDependencyNode(o.node()).getAffectedAttributes(o.attribute())
mitsel.next()
The tricky part is that some plugs, like pairBlend's inTranslateX1, affects not only outTranslateX, but outTranslate, outTranslateY and outTranslateZ, too. Let's find some consistency with this. We know that:
For mute nodes, we only care when we go from the input to its output.
For pairBlend nodes, we need a way to know that: inTranslateX1 is outTranslateX, and inRotateY2 is outRotateY.
So in turns into out, plug attribute can not change (Translate goes to Translate) and output does not have an index, so we can use a regular expression here:
import re
ATTR_REGEX = re.compile(r'(?P<plug>(in|out))(?P<attribute>[a-zA-Z]+)(?P<axis>[XYZ]?)(?P<index>\d?)')
for re_test in 'inTranslateX1', 'inRotateY2', 'inScaleZ', 'input':
match = ATTR_REGEX.match(re_test)
print 'out{attribute}{axis}'.format(**match.groupdict())
# should print:
# outTranslateX
# outRotateY
# outScaleZ
# output
Awesome!
Now we are ready to traverse the graph going forward in the destinations looking for a direct connection to a target dependency node! Notice that if it is a dag node we shall use the long name, as our getMDependencyNodePath function returns full paths.
def findPlug(target_dg_name, mplugs, depth=0, depth_limit=5, found_plug=None):
if depth>depth_limit:
return
depth+=1
for mplug in mplugs:
mdepnode, dg_name = getMDependencyNodePath(mplug.node())
if dg_name==target_dg_name:
return mplug
affected_plugs = {}
for p in mdepnode.getAffectedAttributes(mplug.attribute()):
mp = om.MPlug(mplug.node(), p)
affected_plugs[mp.partialName(useLongNames=True)] = mp
if len(affected_plugs)!=1:
match = ATTR_REGEX.match(mplug.partialName(useLongNames=True))
if not match:
continue # not a valid or supported attribute
out_plug_name = 'out{attribute}{axis}'.format(**match.groupdict())
plugs = affected_plugs[out_plug_name].destinations()
else:
plugs = mp.destinations()
return findPlug(target_dg_name, plugs, depth, depth_limit, found_plug)
print(findPlug('|pCube1', outputs)) # returns the target node's plug connected in the graph
print(findPlug('unexistent_object', outputs)) # returns None
mitsel = om.MItSelectionList(found)
while not mitsel.isDone():
mcurve = omanim.MFnAnimCurve(mitsel.getDependNode())
mcurve_out = mcurve.findPlug('output', False)
outputs = mcurve_out.destinations()
print(findPlug('|pCube1', outputs))
mitsel.next()
# prints out:
# pCube1.translateX
# pCube1.translateY
# [...]
# pCube1.scaleY
# pCube1.scaleZ
# pCube1.blendParent1
The depth_limit keyword argument will put a limit to avoid searching through a lot of the graph, but we could set it higher or even remove it if we are more in control of what happens in our scenes.
That's it for now, we have successfully created some help functions that allow us to traverse the dependency graph going backwards in the inputs to find the animation curve nodes, and forward in destinations to find a plug that is connected to a target node starting with a list of other plugs.
I've updated the GitHub repository with this and some other modules / packages, so feel free to check that out.
Also, please feel free to contact me if you have any doubts or comments:)
Thank you so much and I'll see you around!
Cheers!