Creating a Maya control shape manager

So, if you have been rigging for a while you have probably felt annoyed by having to create and adjust control shapes every time you build a new rig. You have probably found also that mirroring just the shape of a control or copying it to another one is a bit too tedious. There are some scripts and tools online to help you with this, such as the classic comet menu and the mz_ctrlcreator, but they do not offer all the functions we need and also extending them is not very practical. So, let us write our own control shape manager.

tl;dr I am going to walk you through the process of creating your own control shape manager, but if you would rather just use the final code you can find it here. If you would prefer it combined into one large file, you can grab it from here.

Here is a quick demo of some of the features we are going to look at.

Control shape manager in Maya - Demo

Table of contents

  1. Introduction
  2. Part 1: Control Shape Manager
  3. Getting and setting shapes
  4. Saving and loading
  5. Additional
  6. Part 2: Control Shape Functions
  7. Getting lists for the UI
  8. Assigning shapes and colours
  9. Saving to library
  10. Copying and pasting shapes
  11. Flipping shapes
  12. Rebuilding
  13. Part 3: Simpe UI
  14. Conclusion

Introduction

What we want is a python package that allows us to load and save control shapes to a library, copy and paste them to multiple other controls, change colours, flip them, mirror them, etc.

The full code can be found here. I have built it as a package with a few different modules, to be a bit clearer and nicer to maintain. I have also combined everything into one file as well, if anyone wants to just grab it and use it immediately. What we are going to do here though, is go through the code and learn how to build our own control shape manager, because it is much nicer when you actually understand how it works, as then you can extend it and adjust it to suit your needs. For example I have built upon this a bit more in my pipeline, so I can save and load shape versions for each control on a rig, so I can easily rebuild them when I am making changes.

Part 1: Control Shape Manager

We are going to be using a couple of commands from the Maya API, but if you are not very familiar with it, worry not I will explain what each function does. You can always read up on it on the Autodesk docs page or if you prefer more of a tutorial approach have a look at Chad Vernon’s Maya API web page.

Getting and setting shapes

Let us start with the two most important functions – getShape() and setShape().

def getShape(crv=None):
    crvShapes = validateCurve(crv)

    crvShapeList = []

    for crvShape in crvShapes:
        crvShapeDict = {
            "points": [],
            "knots": [],
            "form": mc.getAttr(crvShape + ".form"),
            "degree": mc.getAttr(crvShape + ".degree"),
            "colour": mc.getAttr(crvShape + ".overrideColor")
        }
        points = []

        for i in range(mc.getAttr(crvShape + ".controlPoints", s=1)):
            points.append(mc.getAttr(crvShape + ".controlPoints[%i]" % i)[0])

        crvShapeDict["points"] = points
        crvShapeDict["knots"] = utils.getKnots(crvShape)

        crvShapeList.append(crvShapeDict)

    return crvShapeList

What this function does is, it gets all the data from a nurbsCurve node that we need to rebuild that curve down the line. We are going to look at the validateCurve() function a bit later, but it essentially checks if the curve we have passed is actually a valid curve and if so returns the shape node.

A list is initialized here which will later be populated with dictionaries for each shape node on the curve in order to work with compound curves.

The crvShapeDict is where the actual data is stored. All of the keys in the dictionary are just the needed data for building a curve. If you do not know what the knots and degree are when it comes to curve, you can read up on it here, but it is not necessary. We will be thinking of them as the essential building blocks of a curve.

You can see that very easily we can get the form, degree and colour ones as they are just attributes on the nurbsCurve node.

To get the points what we need to do is loop through all of the controlPoints of the curve. Initially, I was just using the cv attribute, but it does not work with closed curves, as the cvs are just representation of these points, so we can interact with them, but under the hood maya changes them a bit and they are stored in the controlPoints attribute. So, we just get the number of control points using the s flag on the getAttr command and we store each point in a list.

Now, for the knots initially I used this snippet from Serge Scherbakov, but it does not work with closed curves. I could have gone in and tried to create my own function to do that, but then maya has made it easy for us to get the knots from the API, so I thought I would just use that.

def getKnots(crvShape=None):
    mObj = om.MObject()
    sel = om.MSelectionList()
    sel.add(crvShape)
    sel.getDependNode(0, mObj)

    fnCurve = om.MFnNurbsCurve(mObj)
    tmpKnots = om.MDoubleArray()
    fnCurve.getKnots(tmpKnots)

    return [tmpKnots[i] for i in range(tmpKnots.length())]

The first part of this function deals with getting an API reference to our curve. It basically, adds the passed in crvShape to a virtual selection (without actually selecting anything in the viewport) and gets an MObject from it. That’s the base class in the API and from there we can cast it to the type we actually need – MFnNurbsCurve. Then we create an empty MDoubleArray, which we populate from the curve with the getKnots function. And that’s it. Lastly, we return it as a python list, just so we can interact with it easier.

And with that we have a list of dictionaries containing all the necessary information for rebuilding that curve.

Let’s look at setting the shape now. What is nice about this code is that if you understand how the getShape() works, the setShape() is going to be trivial. The one thing I do not like about this code is that we are not assigning the data to the existing curve, but we delete it and create a new one in place. This could cause issues if there are any connections to or from the shape node, but you can always store and rebuild those. I have not yet found a way around it though.

def setShape(crv, crvShapeList):
    crvShapes = validateCurve(crv)

    oldColour = mc.getAttr(crvShapes[0] + ".overrideColor")
    mc.delete(crvShapes)

    for i, crvShapeDict in enumerate(crvShapeList):
        tmpCrv = mc.curve(p=crvShapeDict["points"], k=crvShapeDict["knots"], d=crvShapeDict["degree"], per=bool(crvShapeDict["form"]))
        newShape = mc.listRelatives(tmpCrv, s=1)[0]
        mc.parent(newShape, crv, r=1, s=1)

        mc.delete(tmpCrv)
        newShape = mc.rename(newShape, crv + "Shape" + str(i + 1).zfill(2))

        mc.setAttr(newShape + ".overrideEnabled", 1)

        if "colour" in crvShapeDict.keys():
            setColour(newShape, crvShapeDict["colour"])
        else:
            setColour(newShape, oldColour)

We go through the same call to validateCurve() as before and then we store the "overrideColor" of the curve, so we can apply it back after we rebuild the shape. It is important to note that the colour is the one assigned to the first shape child of the curve. And since we have everything we need from the old shapes – the colour – we delete them.

Then for each shape in the list we just use our points, knots, degree and form data from the dictionary to build a new curve with the mc.curve() command. The per flag refers to periodic and basically defines whether our curve is one whole or does it have a start and an end. A bit more info about periodic curves in here.

Once we have created the new shape we parent it to the crv object with the r=1 and s=1 flags for mc.parent() to define that we are working with shapes and to maintain their relative positions. We then can rename the new shape according to our convention. Lastly, we just reapply the colour or we get it from the dictionary.

As I said these two are the most important functions as they are dealing with the actual data. Now that we have them in place we can give them a quick test. Create a nurbsCurve with whatever shape you want. Then let’s create a simple circle and copy the first shape to the circle. Assuming that the first curve is called curve1 and the circle is nurbsCircle1 run the following snippet.

shapeDict = getShape("curve1")
setShape("nurbsCircle1", shapeDict)

I realize this is not very exciting as there are easier ways to do this, but the cool thing is when we start saving and loading them.

Saving and loading

We have been looking only at the manager.py file for know. In the utils.py we have a few more functions mainly dealing with the saving and loading of json data. Loading and saving JSON files is a very popular and fairly trivial python task, but let’s deconstruct it.

def loadData(path=None):
    if os.path.isfile(path):
        f = open(path, "r")
        data = json.loads(f.read())
        f.close()
        return data
    else:
        mc.error("The file " + path + " doesn't exist")

def saveData(path=None,
             data=None):
    if validatePath(path):
        f = open(path, "w")
        f.write(json.dumps(data, sort_keys=1, indent=4, separators=(",", ":")))
        f.close()
        return 1
    return 0

For loading we start by checking if the file exists and if it does, we use python’s open function to get the raw data and we pass it to a json.loads() function to convert the raw data to a Python dict object.

When saving, we are doing the same thing but instead of reading and converting from raw data to a dict we are passing a dict to the json.dumps() function which serializes our dictionary to JSON and then we write it to the file. You will notice that there is a call to another validation function – validatePath().

def validatePath(path=None):
    if os.path.isfile(path):
        confirm = mc.confirmDialog(title='Overwrite file?',
                                   message='The file ' + path + ' already exists.Do you want to overwrite it?',
                                   button=['Yes', 'No'],
                                   defaultButton='Yes',
                                   cancelButton='No',
                                   dismissString='No')
        if confirm == "No":
            mc.warning("The file " + path + " was not saved")
            return 0
    return 1

All we do here is check if the file we are trying to save already exists and if so gives the option to overwrite it or cancel the save process.

Now that we know how our dictionary data is being load and saved, we just need to have a wrapper function in our manager module to load and save to the defined shape library directory.

Before looking at those though, you need to have the SHAPE_LIBRARY_PATH set at the top of the file. Keep in mind that if the path does not exist, Python will not create it for us but error out.

def loadFromLib(shape=None):
    path = os.path.join(SHAPE_LIBRARY_PATH, shape + ".json")
    data = utils.loadData(path)
    return data

What we do here is define the path to the file we want to load and use the loadData function we talked about to load the actual dictionary.

def saveToLib(crv=None,
              shapeName=None):
    crvShape = getShape(crv=crv)
    path = os.path.join(SHAPE_LIBRARY_PATH, re.sub("\s", "", shapeName) + ".json")
    for shapeDict in crvShape:
        shapeDict.pop("colour", None)
    utils.saveData(path, crvShape)

Then when saving we use re.sub("s", "", shape) in order to strip spaces from the name as they can cause issues and pass the path to the saveData() function. Also, we get rid of the colour keys as we want to save only the shape of the curve.

Additional commands

The rest of the functions in the module are fairly self-explanatory.

def validateCurve(crv=None):
    if mc.nodeType(crv) == "transform" and mc.nodeType(mc.listRelatives(crv, c=1, s=1)[0]) == "nurbsCurve":
        crvShapes = mc.listRelatives(crv, c=1, s=1)
    elif mc.nodeType(crv) == "nurbsCurve":
        crvShapes = mc.listRelatives(mc.listRelatives(crv, p=1)[0], c=1, s=1)
    else:
        mc.error("The object " + crv + " passed to validateCurve() is not a curve")
    return crvShapes

The validateCurve() function just checks if we have passed a valid curve and if so it returns the nurbsCurve shape nodes to work with. Otherwise it errors.

Then we have the colour functions which are just simple wrappers around mc.getAttr() and mc.setAttr() commands to interact with the "overrideColor" attribute of shapes.

def setColour(crv, colour):
    if mc.nodeType(crv) == "transform":
        crvShapes = mc.listRelatives(crv)
    else:
        crvShapes = [crv]
    for crv in crvShapes:
        mc.setAttr(crv + ".overrideColor", colour)

def getColour(crv):
    if mc.nodeType(crv) == "transform":
        crv = mc.listRelatives(crv)[0]
    return mc.getAttr(crv + ".overrideColor")

Part 2: Control Shape Functions

Now that we have our core functionality in place we can stop here and just use the code we have so far through our script editor, which is absolutely fine, but is not very scalable and not really user friendly. Additionally, we are still lacking the mirroring and flipping functionality, so let us create a functions.py file which will act as a wrapper to our manager module. The reason we would want this is to prevent messing about with our manager too much and provide a higher level control so we can literally only care about using the tool instead of how it works. Altogether, it is much nicer working with simple short functions. Okay, let us go through the functions.py commands that help us interact with the manager.

Getting lists for the UI

def getAvailableControlShapes():
    lib = manager.SHAPE_LIBRARY_PATH
    return [(x.split(".")[0], functools.partial(assignControlShape, x.split(".")[0])) for x in os.listdir(lib)]

def getAvailableColours():
    return [("index" + str(i).zfill(2), functools.partial(assignColour, i), "shapeColour" + str(i).zfill(2) + ".png") for i in range(32)]

These two functions are mainly here to help us later when we are going to build some sort of UI for our manager. Essentially they return lists of tuples containing the names, commands and in the case of getAvailableColours() images of the available shapes and colours. These are going to be used when building menus that look similar to the following.

Control shape manager - Shapes dropdown
Shapes
Control shape manager - Colours dropdown
Colours

Notice that the second item in the tuple is a functools.partial() call. For more info refer to the docs, but briefly it allows us to get a reference to a function with added arguments as well. So the first argument is a function and then we have a number of arguments which are going to be provided to the function as *args. Let’s have a look at the functions themselves to see how this works.

Assigning shapes and colours

def assignColour(*args):
    for each in mc.ls(sl=1, fl=1):
        manager.setColour(each, args[0])

def assignControlShape(*args):
    sel = mc.ls(sl=1, fl=1)
    for each in sel:
        manager.setShape(each, manager.loadFromLib(args[0]))
    mc.select(sel)

So, both these functions receive *args as an argument, which means that we can provide lots of arguments and they are going to be passed to the function as a list which we can acces by args[n]. In the previous paragraph, we saw that we pass these functions and a single argument to the functools.partial, which means that the first element of args is going to be the second argument of the functools.partial() code. So in the case of functools.partial(assignColour, i), we are going to receive a call equivalent to assignColour(i).

Additionally, keep in mind if these functions that we are defining here are meant to be used from a maya UI, and a lot of the buttons in maya are passing arguments to their commands, so we need to have the *args, because otherwise the functions will error.

Notice that we reselect our initial selection at the end of the function. We will do this in all functions that call the setShape() one, because the creation of the curve inside of it deselects our selection and instead selects the newly created curve, which is not very intuitive.

Saving to library

def saveCtlShapeToLib(*args):
    result = mc.promptDialog(title="Save Control Shape to Library",
                             m="Control Shape Name",
                             button=["Save", "Cancel"],
                             cancelButton="Cancel",
                             dismissString="Cancel")
    if result == "Save":
        name = mc.promptDialog(q=1, t=1)
        manager.saveToLib(mc.ls(sl=1, fl=1)[0], name)
    rebuildUI()

As we said the goal here is to make interacting with our control shape manager as smooth as possible. Therefore, we create a wrapper to our saveToLib() command to let us add a name in a nice and familiar dialog. In the end we are calling the rebuildUI() function which we will look at the end of this part, but the reason it is here is that every time we save a new control shape we would like the UI to be rebuild, in order for the menu containing all of our shapes to be up to date.

Copying and pasting shapes

def copyCtlShape(*args):
    global ctlShapeClipboard
    ctlShapeClipboard = manager.getShape(mc.ls(sl=1, fl=1)[0])
    for ctlShape in ctlShapeClipboard:
        ctlShape.pop("colour")

def pasteCtlShape(*args):
    sel = mc.ls(sl=1, fl=1)
    for each in sel:
        manager.setShape(each, ctlShapeClipboard)
    mc.select(sel)

As we saw previously, it is really easy to copy and paste shapes with the manager alone, but to provide a quick and easy interface these two functions seem to do a good job. Essentially, we are creating a global variable and store the selected shape dictionary inside of it. Again we pop the “colour” key, as we just want to copy the shape. Then we just use the setShape() function on all selected controls with that global variable.

Flipping shapes

Then there are a few functions for flipping the shapes. It’s a bit of a pain to have to do that manually, but it is really easy to scale the points by -1 through script so let’s have a look at the _flipCtlShape() function. You will notice that there are a few more functions for flipping – flipCtlShape(), flipCtlShapeX(), flipCtlShapeY() and flipCtlShapeZ(). They all just make a call to the _flipCtlShape() one, but with different arguments, so we will just look at that one.

def _flipCtlShape(crv=None, axis=[-1, -1, -1]):
    shapes = manager.getShape(crv)
    newShapes = []
    for shape in shapes:
        for i, each in enumerate(shape["points"]):
            shape["points"][i] = [each[0] * axis[0], each[1] * axis[1], each[2] * axis[2]]
        newShapes.append(shape)
    manager.setShape(crv, newShapes)
    mc.select(crv)

All we do in this one, is just go through each CV and scale it’s x, y and z coordinates by -1 in order to flip the shape. The above mentioned other functions just call this one with the axis set to [-1, 1, 1] for x, [1,-1,1] for y, etc.

I skipped the mirrorCtlShapes() function earlier, because I wanted to already have the flip one in place as we are going to be using it again.

def mirrorCtlShapes(*args):
    sel = mc.ls(sl=1, fl=1)
    for ctl in sel:
        if ctl[0] not in ["L", "R"]:
            continue
        search = "R_"
        replace = "L_"
        if ctl[0] == "L":
            search = "L_"
            replace = "R_"
        shapes = manager.getShape(ctl)
        for shape in shapes:
            shape.pop("colour")
        manager.setShape(ctl.replace(search, replace), shapes)
        _flipCtlShape(ctl.replace(search, replace))
    mc.select(sel)

The bulk of the code here is really for defining the search and replace strings. Since the naming convention that I use is SIDE_NAME_NODETYPE my search and replace strings vary between “L_” and “R_”. Have a look at your convention and modify these strings to make it work. Once they are defined, all we do is copy the shape from the current side to the other one and once done, flip it in all axis. In my pipeline, I have made it so this function does not work with a selection, but instead goes through all my left controls and mirrors them to the right. It is just because I always work from left to right, so I do not need this functionality.

Rebuilding

def rebuildUI(*args):
    mc.evalDeferred("""
import controlShapeManager
reload(controlShapeManager)
""")

Lastly, there is a simple function to rebuild the UI. All it does is import the package, as the way I have set it up is that importing just builds the UI which in turn makes the references to all the needed functions. The UI example that I give is very primitive, but obviously you can replace this code with one that will work with your own UI. Keep in mind that it is best to use the mc.evalDeferred() command as otherwise, the rebuild might error as it is being called from the UI that needs to be rebuilt.

Part 3: Simple UI

Now that we have all functions that we need we can build an UI to interact with them. Since everybody has a different pipeline for rigging at place, I am hesitant to suggest any specific way of handling that UI. One might prefer it in a window, other a tool menu or others yet a shelf button like I do. So I have added a very simple shelf button build in the managerUI.py to demonstrate how would we go about it. Additionally, remember how when generating the lists for the available colours we had a third item in the tuple for an image? You can get these here. They are just images of solid colour, corresponding to the index of the overrideColor attribute.

For a more comprehensive intro to building shelves with buttons and popups have a look at my Building custom maya shelves post.

Let’s have a look at it then.

import maya.cmds as mc


# Local import
import functions
reload(functions)

SHELF_NAME = "Custom"
ICON_PATH = "C:/PATH_TO_ICONS"

if SHELF_NAME and mc.shelfLayout(SHELF_NAME, ex=1):
    children = mc.shelfLayout(SHELF_NAME, q=1, ca=1) or []
    for each in children:
        try:
            label = mc.shelfButton(each, q=1, l=1)
        except:
            continue
        if label == "ctlShapeManager":
            mc.deleteUI(each)

    mc.setParent(SHELF_NAME)
    mc.shelfButton(l="ctlShapeManager", i="commandButton.png", width=37, height=37, iol="CTL")
    popup = mc.popupMenu(b=1)
    mc.menuItem(p=popup, l="Save to library", c=functions.saveCtlShapeToLib)

    sub = mc.menuItem(p=popup, l="Assign from library", subMenu=1)

    for each in functions.getAvailableControlShapes():
        mc.menuItem(p=sub, l=each[0], c=each[1])

    mc.menuItem(p=popup, l="Copy", c=functions.copyCtlShape)
    mc.menuItem(p=popup, l="Paste", c=functions.pasteCtlShape)

    sub = mc.menuItem(p=popup, l="Set colour", subMenu=1)

    for each in functions.getAvailableColours():
        mc.menuItem(p=sub, l=each[0], c=each[1], i=ICON_PATH + each[2])

    mc.menuItem(p=popup, l="Flip", c=functions.flipCtlShape)
    mc.menuItem(p=popup, l="Mirror", c=functions.mirrorCtlShapes)

So what happens here is we import the functions.py file which in turn imports the manager.py which then imports the utils.py file. Then there are the two variables – SHELF_NAME and ICON_PATH for declaring the shelf to add the button to and the path to the icons. Then we check if a button with the same name already exists in the shelf and if it does we delete it so we can replace it with our new one.

From then on we have simple maya UI commands to build our buttons and menus. If you are not familiar with UIs in maya it is worth having a look at the docs. Essentially, all we do is create a single mc.shelfButton() and we attach a mc.popupMenu() to it. Which then we populate with mc.menuItem()s where the l flag stands for label and the c for command. So there we pass our functions commands. Notice that we are not adding the () after the function name as that would call it and return the output. Instead we want to pass a reference to that function.

Then for the shapes and colours menuItems we add the subMenu flag so they become deeper level menus and we populate them with the results of our functions.getAvailableControlShapes() and functions.getAvailableColours() commands, which results in lists containing the shapes in our library and all 32 available colours.

Conclusion

And that is it. We have built our own control shape manager. With some easy extensions you can improve it to have almost like a version control system for your rigs, so you do not ever have to worry about your control shapes anymore.

Again, here is the link to the repo and the gist containing everything in one file if you would rather just use something quickly.

Leave a Reply

Your email address will not be published. Required fields are marked *