Tyler Thornock
Technical Animator
Home Tutorials Tools Rigs About/Resume
Maya – Easy Undoable Python / API

One common thing needed when working with Maya’s API and even certain cases with Python (or both), is undo and redo support. Typically this means you would create a plugin that would handle your use case, and then repeat as needed for any more use cases that come up. This can cause people to either have a bunch of individual plugins or some sort of uber plugin.

However, this will show an example of how you can use a single plugin that can be used for all your use cases, and you just have to setup a class with the appropriate functions to call. No need to add a new plugin to load at startup, or make sure your plugin is loaded when your command is called.

import functools
from maya import cmds
from maya.api import OpenMaya


class RunUndoablePython(OpenMaya.MPxCommand):
    call_class = None
    kCommandName = 'runUndoablePython'

    def __init__(self):
        """
        Allows you to run cmds.runUndoablePython(self) during the __init__ of your custom python class, which will
        cause maya to see it as an undoable command.  Then this class will call into the doIt, redoIt, and undoIt of
        your class.  This helps prevent the need to create a bunch of random plug-ins for various operations.
        """
        super(RunUndoablePython, self).__init__()
        self.py_class = None

    def isUndoable(self):
        """
        Make sure we support undoing.
        """
        return True

    def doIt(self, args):
        """
        Your doIt should do the bulk of the work, eg calculating values to apply during the redoIt/undoIt call.  You
        do not have to specify a doIt method if you already had the information on self.py_class and are ready to apply. 
        """
        self.py_class = self.call_class
        if hasattr(self.py_class, 'doIt'):
            result = self.py_class.doIt()
            if result is not None:
                self.setResult(result)
        self.redoIt()

    def redoIt(self):
        """
        Just calls your class's required redoIt method.  Although this can use setResult, normally this is not needed.
        """
        result = self.py_class.redoIt()
        if result is not None:
            self.setResult(result)

    def undoIt(self):
        """
        Just calls your class's required undoIt.
        """
        # the undo queue can get into a bad state, so this is normally disabled, might want to add an option to skip
        state = cmds.undoInfo(query=True, state=True)
        if state:
            cmds.undoInfo(stateWithoutFlush=False)
        try:
            result = self.py_class.undoIt()
            if result is not None:
                self.setResult(result)
        finally:
            if state:
                cmds.undoInfo(stateWithoutFlush=True)

    @classmethod
    def wrap_command(cls):
        """
        In order to bypass the normal argument required for commands due to trying to be compatible with MEL, we have
        to wrap the created cmds.runUndoablePython so the class can be passed.
        """
        cmd_func = getattr(cmds, cls.kCommandName)

        @functools.wraps(cmd_func)
        def wrapped(py_class):
            # make sure we store the class so it wont go out of scope and then call it
            cls.call_class = py_class
            cmds.undoInfo(openChunk=True, chunkName=cls.kCommandName)
            try:
                return cmd_func()
            finally:
                cmds.undoInfo(closeChunk=True)

        # now overwrite the one in cmds with this new wrapped version
        wrapped.__wrapped__ = cmd_func
        setattr(cmds, cls.kCommandName, wrapped)


def maya_useNewAPI():
    pass


def creator():
    return RunUndoablePython()


def initializePlugin(m_object):
    m_plugin = OpenMaya.MFnPlugin(m_object)
    try:
        # let maya create the command, and then replace it with a wrapped version that can accept a python class
        m_plugin.registerCommand(RunUndoablePython.kCommandName, creator)
        RunUndoablePython.wrap_command()
    except:
        raise Exception('Unable to initialize: {}'.format(RunUndoablePython.kCommandName))


def uninitializePlugin(m_object):
    m_plugin = OpenMaya.MFnPlugin(m_object)
    try:
        m_plugin.deregisterCommand(RunUndoablePython.kCommandName)
    except:
        raise Exception('Unable to uninitialize: {}'.format(RunUndoablePython.kCommandName))

Now just load the plugin in Maya and lets create our first class so we can test how it works.

class ExampleClass(object):
    def __init__(self):
        super(ExampleClass, self).__init__()
        self.redo_text = ''
        self.undo_text = ''

        # run the command that will call the doIt, redoIt, undoIt functions at the appropriate times
        cmds.runUndoablePython(self)

    def doIt(self):
        # gather / store data to use during redo / undo, eg doIt is only called once, then redo/undo happen while
        # moving backwards/forward the undo stack... optionally you can do this work in your __init__ before
        # you call the cmd.runUndoablePython
        print('Preparing World!')
        self.redo_text = 'Hello World!'
        self.undo_text = 'Goodbye World!'

    def redoIt(self):
        # apply your changes here, usually something like MPlug.setValue, MFkSkinCluster.setWeights, etc
        print(self.redo_text)

    def undoIt(self):
        # undo the changes to restore the previous state
        print(self.undo_text)

# initializing the class will also run the command
ExampleClass()

Notice how when you call the class the first time, it prints for the doIt and redoIt, but then only the redoIt and undoIt while you navigate through the undo queue. This is important because you do not want to do any heavy lifting calculations in the redoIt or undoIt. It should do the minimum work needed to apply the desired state. Also, you can skip having the doIt function and handle it in the __init__ before you call the cmd.runUndoablePython.

I have used this for all sorts of use cases while working with Maya’s API like setting skin weights, vertex colors, mesh points, etc, but also to manage keeping a custom data storage class in sync during the use of an Artisan Painting tool.