Skip to content

2024.01.01 - cmdx vs omx

image.png

omx by Animal Logic

The appearance of omx gave me an excellent opportunity to shed some light on the Maya API and how undo is implemented, along with finding some inspiration for things to improve in cmdx.


What is it?

omx and cmdx provide an alternative to maya.cmds and pymel that is both faster and - in the case of cmds - more convenient.

One of the big failings of cmds is nodes being referenced by name; such that when a name changes, the variable containing your node is no longer valid.

from maya import cmds
node = cmds.createNode("transform", name="name")
cmds.rename(node, "newName")
cmds.setAttr(node + ".translateX", 5)
# "name" does not exist!

PyMEL solved this, and added a boatload of additional features like exposing Maya's native math classes for e.g. easy vector addition.

from pymel import core as pm
vec1 = pm.dt.Vector(1, 2, 3)
vec2 = pm.dt.Vector(4, 5, 6)
result = vec1 + vec2

But it also added a performance overhead - importing the library can take seconds and generally using the library made code take several times longer than it would have via cmds.

Performance Comparison

See below for a brief comparison, along with the README for cmdx and documentation for omx for more.

For Ragdoll, we needed both performance and maths.

For example, the Load Physics command can read a JSON and generate a complete Maya scene with Maya nodes, Ragdoll nodes, connections between them and attributes restored from disk in a handful of milliseconds. On par with how long it takes Maya to load a .ma or .mb file off disk.

We built cmdx to provide the terse readability of PyMEL with the performance of cmds. Actually even better than cmds, as you'll find below, due to leveraging the Maya Python API 2.0.

About two weeks ago, Animal Logic announced another open source contender called omx and this page is a comparison between the two. To compare them, we'll need a better understanding of Maya, undo and the "modifiers", so you'll also learn about how these work!

More alternatives

The complete list of all other alternatives I know of, let me know if you know any more!


First Impressions

Let's start with a quick side-by-side.

omx

import omx
from maya.api import OpenMaya as om2

mod = omx.currentModifier()
joint = mod.createNode("joint", name="joint")

fn = om2.MFnNumericAttribute()
attr = fn.create("flash", "flash",
                 om2.MFnNumericData.kInt)
fn.setMin(0)
fn.setMax(10)

mod.addAttribute(joint.object(), attr)

joint.radius.setFloat(1.5)
joint.translate.setCompoundDouble((1, 2, 3))

mod.commandToExecute(
    f"setKeyframe -at tx -t 1 -v 10 {joint}"
)
mod.commandToExecute(
    f"setKeyframe -at tx -t 5 -v 15 {joint}"
)
mod.commandToExecute(
    f"setKeyframe -at tx -t 10 -v 10 {joint}"
)

mod.doIt()

cmdx

import cmdx

with cmdx.DagModifier() as mod:
    joint = mod.createNode("joint", name="joint")
    attr = cmdx.Integer("flash", min=0, max=10)
    mod.addAttr(joint, attr)
    mod.setAttr(joint["radius"], 1.5)
    mod.setAttr(joint["translate"], (1, 2, 3))
    mod.setAttr(joint["tx"], {
        1: 10,
        5: 15,
        10: 10
    })

Highlights

Feature omx cmdx
Maya Support 2022-2024 2016-2024
Attribute Access Dot-access, .attr Dict-access, ["attr"]
Attribute Setter node.attr.setInt(5) node["attr"] = 5
Attribute Getter if node.attr if node["attr"]
Animation mod.commandToExecute mod.setAttr
Undo MPxCommand + MDGModifier Same


Performance

Both omx and cmdx performs better than cmds, PyMEL and MEL. They also all scale linearly with the number of nodes, so let's see how they compare on the heaviest of cases.

10,000 nodes

Units are in seconds.

Test cmds cmdx cmdx noundo omx omx immediate
Create 5.66 3.89 2.68 3.17 4.38
Edit 4.8 4.44 3.13 3.19 7.03
Rename 1.29 0.66 0.65 0.47 0.77
Query 0.73 0.52 0.52 0.68 0.66
Remove 0.89 0.61 0.62 0.41 0.70
Overall 13.5 10.1 7.67 7.94 13.6

Both omx and cmdx thinly wrap the Maya API, so both of their bottlenecks is Maya itself.

Source

Tested on Maya 2024, Windows, source here

Bad performance

The AL performance comparison of cmds ability to create is off by 3x, clocking in at 15 seconds.


Deep Dive

Let's take a closer look, starting with syntax.


Syntax

# omx
node.t.x = 5;

# cmdx
node["tx"] = 5

Somewhat subjective, and we've learnt from PyMEL that accessing attributes via the dot-syntax does work. But not without cost.

Consider this.

# Attribute, function or property?
node.visible = True
node.flash.keyable = True
node.translate(1, 2, 3)

..did these even exist, or did we just add new variables to the Python object?

Wouldn't my IDE warn about it?

Consider readers on GitHub, GitLab or BitBucket; including the source code on this page.

Whenever you call .something on a node, the __getattribute__ method of the object is called. In the case of omx, here's what this looks like.

AL/omx/_xnode.py#L75

class XNode:
    def __getattribute__(self, name):
        # (1)
        if hasattr(XNode, name):
            return object.__getattribute__(self, name)

        mob = object.__getattribute__(self, "object")()

        # (2)
        if name == "apiTypeStr":
            # ...

        if mob == om2.MObject.kNullObj:
            # ...

        nodeClass = XNode._NODE_CLASS_CACHE[mayaType]
        attrs = XNode._ATTRIBUTE_CACHE[mayaType]
        attr = attrs.get(name, None)

        # (3)
        if attr is None:
            if not nodeClass.hasAttribute(name):
                plug = _plugs.findPlug(name, mob)
                if plug:
                    return _xplug.XPlug(plug)

                raise AttributeError(f"Node {mayaType} has no attribute called {name}")

            attr = nodeClass.attribute(name)
            attrs[name] = attr

        # (4)
        return _xplug.XPlug(mob, attr)


Some highlights

  1. First we check if the member is a property or method of the object
  2. If not, we check if you typed node.apiTypeStr in which case we return a special case
  3. Next we check whether the member is a dynamic attribute and return an XPlug if so
  4. Finally we have determined that this is a static attribute and return an XPlug

Here, attributes are shadowed by native functions and properties, and as the count of both native functions and user attributes increases name clashes are inevitable.

It's also not clear when you assign whether you are assigning to a Python property or Maya attribute.

node.name = "My Name"

Conversely, cmdx uses __getitem__ instead. Here's what happens when you call node["attr"]

cmdx.py#L570

class Node:
    def __getitem__(self, key):
        unit = None
        cached = False

        # (1)
        if isinstance(key, (list, tuple)):
            # ...

        # (2)
        if cached:
            # ...

        assert isinstance(key, str), (
            "%s was not the name of an attribute" % key
        )

        try:
            plug = self.findPlug(key)
        except RuntimeError:
            raise ExistError("%s.%s" % (self.path(), key))

        # (3)
        return Plug(self, plug, unit=unit, key=key)

Some highlights.

  1. Optional values as handled, e.g. a non-standard unit node["tx", Meters] or node["rx", Degrees"]
  2. Optional cached return value are is fetched
  3. The Maya plug is discovered and wrapped in a cmdx.Plug


Undo

The first surprise when working with maya.api.OpenMaya is the lack of undo.

from maya import cmds
from maya.api import OpenMaya as om
om.MFnDependencyNode().create("transform")
cmds.undo()  # Nope!

We take it for granted with cmds and pymel but with naked access to OpenMaya we are on our own. And it just so happens that undo/redo is (or, can be) really hard.

To understand why, we need to look closer at undo in general, and how Maya implements this with MPxCommand and MDGModifier.


Undo Primer

The basic premise of undo in any application is that for every action there is an equal and opposite reaction. Wait, that's Newtons Third Law. But it does apply!

def do():
    createNode()

def undo():
    deleteNode()

Maya implements undo via the "Command Pattern".

In a nutshell, it looks like this.

class Command:
    def __init__(self, name):
        self._name = name
        self._node = None

    def do(self):
        self._node = createNode(self._name)

    def undo(self):
        deleteNode(self._node)

And that's about all there is to it! What makes this pattern work, is that we can keep a list of previous commands..

previous_commands = list()

def execute(cmd):
    cmd.do()
    previous_commands.append(cmd)

execute(Command("hello"))
execute(Command("world"))
execute(Command("bye"))

..and call their undo in the reverse order!

def undo():
    last_command = previous_commands.pop()  # Get and remove last item
    last_command.undo()

undo()
undo()
undo()

There's one additional list for redo() but the premise is the same. Append and pop.

Here's a real example of how this is implemented in the Maya Python API 2.0.

# my_command.py
from maya.api import OpenMaya as om

class MyCommand(om.MPxCommand):
    kPluginCmdName = "myCommand"

    def __init__(self):
        super(MyCommand, self).__init__()
        self._node = None
        self._name = None

    def doIt(self, args):
        self.redoIt()
        print("Created '%s'" % self._name)

    def undoIt(self):
        om.MGlobal.deleteNode(self._node)
        print("Deleted '%s'" % self._name)

    def redoIt(self):
        fn = om.MFnDagNode()
        self._node = fn.create("transform")
        self._name = fn.name()
        print("Re-created '%s'" % self._name)

    def isUndoable(self):
        return True

    @staticmethod
    def cmdCreator():
        return MyCommand()


def initializePlugin2(plugin):
    pluginFn = om.MFnPlugin(plugin)
    pluginFn.registerCommand(MyCommand.kPluginCmdName, MyCommand.cmdCreator)


def uninitializePlugin2(plugin):
    pluginFn = om.MFnPlugin(plugin)
    pluginFn.deregisterCommand(MyCommand.kPluginCmdName)

Apart from a few syntactical differences, this is pretty vanilla Command Pattern.

You can call it like this.

from maya import cmds
cmds.loadPlugin("my_command.py")
cmds.myCommand()  # doIt is called
cmds.undo()
cmds.redo()
# Created transform1
# Deleted transform1
# Re-created transform1

There are however two problems with this approach that make it unsuitable for use in scripting with Python. For starters, we must register each command as a plug-in, and the name of each plug-in must be unique as it will be present in Maya's own maya.cmds module. Secondly, you have to implement the opposite command for every command you do!

Consider the case of an auto rigger.

class CreateRig(om.MPxCommand):
    kPluginCmdName = "createRig"

    def doIt(self, args):
        spine = self.create_limb()
        left_arm = self.create_limb(parent=spine)
        right_arm = self.create_limb(parent=spine)
        left_leg = self.create_limb(parent=spine)
        right_leg = self.create_limb(parent=spine)
        head = self.create_head(parent=spine)

    def undoIt(self):
        # Undo everything we just did!
        pass

Imagine the amount of state you would need to keep track of in order to undo such a thing. No longer just a self._node but many dependent nodes and attributes, that need to be deleted and reset in the proper order (children first) and attributes potentially created on nodes outside of those created by this one command.

You'd have to be pretty dedicated to go this route, even Autodesk (Alias, rather) thought so too, which is why they gave us the modifier.


Modifier Primer

Maya provides a means of wrapping one or more commands into an undoable chunk called a "modifier".

There are 2 flavours.

  • MDGModifier for DG related modifications
  • MDagModifier for DAG related modifications

The DG handles things like creating DG nodes, connecting things, renaming things. Whereas the DAG version handles parenting and creating DAG nodes.

mod = maya.api.OpenMaya.MDagModifier()
node1 = mod.createNode("transform", name="hello1")
node2 = mod.createNode("transform", name="hello2")
mod.doIt()

Points of interest:

  • Nothing happens until doIt is called
  • Not every possible command is accessible via MDagModifier
    • Such as changing the keyable state of an attribute
    • Such as changing the min and max of a float
    • Such as playing or pausing the Maya timeline
    • Such as interacting with Maya's UI in any way

But this still doesn't grant you the ability to undo. Instead, you have:

mod.undoIt()

Which has nothing to do with undo you know - i.e. Ctrl + Z - it's merely a command you can call yourself do undo whatever was done up until doIt. The modifier has been keeping a log of every command you've done, so as to perform the opposite in the same order as you did. So to incorporate this with what you know as undo you need one more ingredient, the command.

Here's one way to couple the modifier and command.

class MyUndoCommand(maya.api.OpenMaya.MPxCommand):
    def __init__(self):
        self._modifier = None

    def doIt(self, args):
        self._modifier = _GLOBAL_MODIFIER
        _GLOBAL_MODIFIER = None

    def undoIt(self, args):
        self._modifier.undoIt()

    def redoIt(self, args):
        self._modifier.redoIt()

Maya will create an instance of this command and store it, so by storing the last modifier inside of it, Maya will ensure the right modifier is called at the right time.

Aside from some minutia, this is how both cmdx and omx solves this problem.

from AL import omx
mod = omx.XModifier(immediate=False)
mod.createDagNode("transform")
cmds.AL_OMXCommand()  # Fetch and store this latest modifier

import cmdx
mod = cmdx.DagModifier()
mod.createNode("transform")
cmds.cmdx_0_6_3_command()

And in both cases, this command is hidden from view and is automatically called.

from AL import omx
mod = omx.XModifier(immediate=False)
mod.createDagNode("transform")

import cmdx
with cmdx.DagModifier() as mod:
    mod.createNode("transform")


Modifiers

Given what we now know, you might be thinking "modifiers, where have you been all my life!?". And it's true they provide something rather unique, but they can be both a blessing and a curse.


Blessing

Since nothing happens until you call doIt() that also means that if anything goes wrong up until that point, your Maya scene will be unaffected by anything that preceeded it.

mod.createNode("transform")
cond = mod.createNode("condition")
mod.addAttr(cond, attr)
mod.createNode("BAD")  # <---
mod.doIt()

With MEL, cmds and Pymel, the above would produce an error and leave your scene is a dirty state. As a user, you never know what has been created and what has not; was it enough to carry on? Do you need to undo and try again? Can you even undo, or has the undo queue been disrupted already?

With a modifier, an error is produced..

Traceback (most recent call last):
  File "C:\github\test.py", line 8, in <module>
    a = mod.createNode("BAD")
TypeError: invalid node type

..and since doIt() was never called nothing will have happened! This is very nice.

Consider the case of an auto-rigger, where multiple functions and multiple modules call on each other to produce the final result. If anything breaks, an error is produced and nothing will have changed.


Curse

There are however 2 main drawbacks to this approach.

  1. Source of error is hard to spot
  2. Not every command is available via a modifier

(2) means that there are things you cannot do with a modifier, and thus cannot capture their undo. For example, you cannot cmds.play() and you cannot manipulate the Maya UI in any way and you cannot change the min and max of attributes.

(1) however is the most damning.

Consider the case of a real-world production project, 15 modules, 150 functions, thousands of calls to generate a character rig. Creating nodes, adding and connecting attributes, setting values; the works.

from maya.api import OpenMaya as om

mod = om.MDagModifier()
a = mod.createNode("transform")

fn1 = om.MFnDependencyNode(a)

plug1 = fn1.findPlug("tx", False)
plug2 = fn1.findPlug("rx", False)
plug3 = fn1.findPlug("sx", False)

# Locked attributes cannot be connected
plug2.isLocked = True

mod.connect(plug1, plug2)
mod.connect(plug2, plug3)

mod.doIt()

And then, an error is thrown. Here's what you'll see.

Error: Connection not made: 'unitConversion1.output' -> 'transform2.rotateX'.  Destination is locked.
Traceback (most recent call last):
  File "example.py", line 18, in <module>
    mod.doIt()
RuntimeError: (kFailure): Unexpected Internal Failure

And here's the kicker; the error occurs at line 18. At mod.doIt().

In this example - with only 1 call to connect - the source is obvious. But you can already see how unitConversion1.output is not what you wrote. It's automatically created by the modifier, and is part of the error message. In this hypotethical production example, these errors can start to get near impossible to debug.

Other times, you won't get an error at all until it's too late.


Silently adding duplicate attributes

mod = om.MDGModifier()
node1 = mod.createNode("multMatrix")

fn = om.MFnNumericAttribute()
attr1 = fn.create("myAttr", "at", om.MFnNumericData.kFloat)
attr2 = fn.create("myAttr", "at", om.MFnNumericData.kDouble)

mod.addAttribute(node1, attr1)
mod.addAttribute(node1, attr2)

mod.doIt()
# No error

Now you're left with 2 duplicate attributes. This is not allowed by the Maya API and will likely segfault if you try and operate on either of these once they are done.

Under normal, non-modifier circumstances, an error would occur when attempting to add an attribute that already exists.

from maya import cmds
cmds.addAttr("persp", ln="myAttr", at="float")
cmds.addAttr("persp", ln="myAttr", at="double")
# Warning: Name 'myAttr' of new attribute clashes with an existing attribute of node 'persp'.
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# RuntimeError: Found no valid items to add the attribute to.


Interoperability with non-modifiers

Modifiers know about the surrounding API, but the surrounding API does not know about modifiers.

from maya.api import OpenMaya as om

mod = om.MDagModifier()
node = mod.createNode("transform")

# (1)
fn = om.MFnNumericAttribute()
length = fn.create("length", "le", om.MFnNumericData.kFloat)
mod.addAttribute(node, length)

# (2)
fn = om.MFnDependencyNode(node)
plug = fn.findPlug("at", False)
mod.newPlugValueFloat(plug, 5.3)

print(plug.asFloat())

In this case, (1) is a modifier given data created outside of the modifier, this is fine.

But at (2), a non-modifier is operating on modifier data; this won't work because the data has not yet been created. We haven't yet called doIt(). Neither the attribute nor node exists yet. The MObject passed to MFnDependencyNode is essentially invalid.

We can work around this by calling doIt() multiple times.

from maya.api import OpenMaya as om

mod = om.MDagModifier()
node = mod.createNode("transform")

# (1)
fn = om.MFnNumericAttribute()
length = fn.create("length", "le", om.MFnNumericData.kFloat)
mod.addAttribute(node, length)
mod.doIt()  # Prepare attribute for the below call

# (2)
fn = om.MFnDependencyNode(node)
plug = fn.findPlug("at", False)
mod.newPlugValueFloat(plug, 5.3)
mod.doIt()  # Again prepare the the *next* call

print(plug.asFloat())

Each time doIt() is called, it will only perform the newly created operations since the last doIt(). The undoIt() on the other hand will undo all operations. Just as one would expect.


Armour

Both omx and cmdx had the same brilliant idea of recording commands made with the modifier, such that they can be printed out on error. Something Maya really should be doing already.

node["rx"].lock()

with cmdx.DagModifier() as mod:
    mod.connect(node["tx"], node["rx"])

# cmdx.ModifierError: An unexpected internal failure occurred, these tasks were attempted:
# - connect('|transform1.translateX', '|transform1.rotateX')
# - connect('|transform1.rotateX', '|transform1.scaleX')

cmdx incorporates common sources of error into the modifier, so the above for example would error immediately.

# cmdx.LockedError: Channel locked, cannot connect 'rotateX'


Comparison

Let's compare the use of modifiers and commands between cmdx and omx.


MDGModifier

Let's look at how users interface with modifiers through cmdx and omx.

omx

import omx
from maya.api import OpenMaya as om2

mod = omx.currentModifier()
mod.createNode(...)
mod.addAttribute(...)

# Current modifier implicitly called
joint.radius.setFloat(...)

mod.commandToExecute(...)
mod.doIt()

cmdx

import cmdx

with cmdx.DagModifier() as mod:
    joint = mod.createNode(...)
    mod.addAttr(...)
    mod.setAttr(...)

When I first started writing cmdx I desperately wanted to avoid exposing the modifier directly, as it added another layer of complexity compared to cmds and PyMEL. By having a "current" modifier somewhere globally accessible, you can make naked calls like joint.radius.setFloat take advantage of it without the user explicitly calling on it.

# omx/_xplug.py:119
def setFloat(self, value):
    _currentModifier().newPlugValueFloat(self, value)

The problem is that it was never clear which call made use of this global modifier and which did not.

# We know this does
joint.radius.setFloat(3.5)

# But how about this?
joint.myArray.append(5)

# ..and this?
joint.tx.locked = True

# hmm..
joint.visibility.setDoNotWrite(True)

With cmdx, all undoable things are encapsulated in the modifier. Things outside of it are not immediately undoable.

with cmdx.DagModifier() as mod:
    mod.createNode(...)  # Undoable
    mod.addAttr(...)     # Undoable
    mod.setAttr(...)     # Undoable

node["myArray"].append(5)      # Not undoable
node["attr"].storable = False  # Not undoable

For hand-rolled undoable operations, there is cmdx.commit.

import cmdx

history = []
history.append(cmdx.createNode("transform"))
history.append(cmdx.createNode("condition"))
history.append(cmdx.createNode("multMatrix"))

def undo():
    cmdx.delete(history)

cmdx.commit(undo)

Which is what cmdx.DagModifier uses too.

class Modifier:
    # ...

    def __exit__(...):
        cmdx.commit(self.undoIt, self.redoIt)

Although in practice I have never had to use this in Ragdoll.


MPxCommand

Another subtle difference between omx and cmdx is how they store their modifiers.

omx

_xcommand.py#L40

Highlight Comment
Implicit list of modifiers There exists this notion of a "current" modifier, and apparently there can be many. They are stored together in the same command.
Instances Modifier instances themselves are stored alongside the command. Potentially problematic given that one cannot inspect what commands are in Maya's undo buffer at any given time and thus cannot confirm they do what you expect.
def getAndClearModifierStack():
    global _CURRENT_MODIFIER_LIST
    existingMods = []
    for xmod in _CURRENT_MODIFIER_LIST:
        if isinstance(xmod, XModifier):
            if xmod.isClean():
                continue
            mmod = xmod._modifier  # NOQA
        else:
            mmod = None
        logger.debug("Retrieving mod %r from list for execution", mmod)
        existingMods.append(DoItModifierWrapper(xmod, mmod))
    _CURRENT_MODIFIER_LIST = []
    return existingMods

class XCommand(om2.MPxCommand):
    def __init__(self):
        # ...
        self._modifiers = _xmodifier.getAndClearModifierStack()

cmds.AL_OMXCommand()

cmdx

The undo and redo commands are stored in a shared location, accessible to both the cmdx module and plug-ins made with cmdx.

cmdx.py#L8407

Highlight Comment
unique_command cmdx supports vendoring, whereby there may be multiple instances of cmdx on the sys.path any given time, of different versions. Therefore, they each use a unique name for their command.
Addresses Undo and redo addresses are stored in a shared memory location, accessible from outside of Maya's undo queue for inspection, along with inside of new Maya commands made with cmdx.
class _apiUndo(om.MPxCommand):
    def doIt(self, args):
        self.undoId = shared.undoId
        self.redoId = shared.redoId

def commit(undo, redo=lambda: None):
    shared.undoId = "%x" % id(undo)
    shared.redoId = "%x" % id(redo)
    shared.undos[shared.undoId] = undo
    shared.redos[shared.redoId] = redo

    getattr(cmds, unique_command)()

To the end user, the behavious is identical. There really only is 1 way to undo and redo, anything else is a bug.


omx and the "Current Modifier"

Given what we know know about undo inside of Maya, with modifiers and commands, it was interesting to see the notion of a "current" modifier in omx.

Consider this.

# module1.py
def function1():
    mod = omx.currentModifier()
    mod.createNode(...)

# module2.py
def function2():
    mod = omx.currentModifier()
    mod.setAttr(...)

To the naked eye, both of these functions, in these two separate Python modules, call on the same "current" modifier. In which case, by the end of your multi-file, multi-function call you must be incredibly lucky to not have encountered a single error - user or otherwise - for the function to have executed perfectly and without error. Only then will you get undo and only then will your scene state be safe. Because yes - as opposed to errors occurring prior to calling doIt() - modifiers will still have executed all commands prior to the one that failed, leaving you with a mess and no undo.

But having looked closer at the omx source code, this is not the case. Instead, doIt is frequently called automatically - such as when both creating and deleting new nodes - leaving me wondering what the purpose of a "current" modifier is, given that there is also a omx.newModifier()?

cmdx deals with this by encouraging small batches of modifiers.

def function1():
    with cmdx.DagModifier() as mod:
        mod.createNode(...)

def function2():
    with cmdx.DagModifier() as mod:
        mod.setAttr(...)


XModifier Implementation

omx provides:

  • omx.newModifier()
  • omx.currentModifier()

Whereby currentModifier will create a new modifier if there is no modifier. I was expecting this to keep returning the same modifier until I call newModifier, but this wasn't the case.

mod = omx.currentModifier()
assert mod is omx.currentModifier()
mod.createDagNode("joint")
assert mod is omx.currentModifier()  # AssertionError

Furthermore, the documentation states:

If AL.omx.XModifier._immediate is True, whenever you call its method to edit Maya’s scene data, it will call doIt() to apply the edit to the scene immediately. On the other hand, if AL.omx.XModifier._immediate is False, then you’ll have to manually call AL.omx.doIt() or XModifier.doIt() to apply the edit.

However this does not appear true.

mod = omx.XModifier(immediate=False)
assert not mod._immediate
mod.createDagNode("joint")  # Still creates the joint
# mod.doIt()

There is a comment in the source explaining why.

"To get a valid MObjectHandle in XNode the creation needs to happen right away" - Source


Extras

Let's highlight some other points of interest.


commandToExecute

One of the things I struggled with was incorporating non-modifier commands in a modifier context, like locking attributes.

omx handles this by utilising MDGModifier.commandToExecute which queues a (MEL) command to execute at the right time, which I thought was very nice.

omx

Elegant method of handling this scenario.

class Modifier:
    def setLocked(self, locked):
        cmd = f"setAttr -locked {locked} {self}"
        _currentModifier().commandToExecute(cmd)

There is also pythonCommandToExecute which does the same but with a Python command instead.

cmdx

Manual way, which will likely be converted to pythonCommandToExecute instead.

class Modifier:
    def setLocked(self, plug, value=True):
        self._lockAttrs.append((plug, value))

    def _doLockAttrs(self):
        while self._lockAttrs:
            plug, value = self._lockAttrs.pop(0)
            elements = plug if plug.isArray or plug.isCompound else [plug]

            for el in elements:
                cmds.setAttr(el.path(), lock=value)

    def __exit__(self):
        self.redoIt()
        self._doLockAttrs()


Animation

Shorthand for animating values.

node = cmdx.createNode("transform")
node["tx"] = {
    1: 0.0,
    5: 1.0,
    10: 0.0
}

This sets keyframes on frames 1, 5 and 10, with values 0, 1 and 0 respectively.


Units

It might surprise you to know that cmds returns units relative the units your UI is configured to.

# cm, meters or feet?
height = cmds.getAttr("persp.ty")

Which is convenient sometimes, but not often!

cmdx on the other hand always returns cm and radians, unless you specify otherwide.

height = persp["ty", cmdx.Centimeters]
height = persp["ty", cmdx.Meters]


Math

All of the Maya math classes are available via cmdx and may be directly passed (and gotten) as attribute values.

node = cmdx.createNode("transform")
node["ty"] = 5
mtx = node["worldMatrix"][0].as_matrix()

# Store Y-transform as offset
node["offsetParentMatrix"] = mtx
node["ty"] = 0

# Do some math
mtx_inverse = node["worldMatrix"][0].as_matrix_inverse()
node["newAttr"] = cmdx.MatrixType()
node["newAttr"] = mtx * mtx_inverse


PEP8

All of cmdx is available both as Maya-standard camelCase but also as camel_case.

cmdx.createNode("transform")
cmdx.create_node("transform")

As such, it'll fit into any codebase, no matter the convention!

Under the hood, the members are simply aliases of each other, so the functionality remains the same.

def createNode(...):
    pass

# Alias
create_node = createNode


Generate Curves

Generate curves with a lot less code than the Maya API!

import cmdx

parent = cmdx.createNode("transform")
shape = cmdx.curve(parent, [
    (0, 0, 0),  # first CV point
    (0, 1, 0),  # second CV point
    (0, 2, 0),  # ...
])


cmdc

Have a look at the little brother of cmdx called cmdc, a complete re-implementation of Maya's API, a.k.a. "Maya Python API 3.0"


More

Read more about cmdx and the tons of quality-of-life features incorporated over the years here.


Feedback

Let me know what you think of the above summary! You can reach me at marcus@ragdolldynamics.com or via the Ragdoll forums. The post is intended for Maya developers at large, but also the developers of omx and anyone using omx or cmdx with a desire to better understand the things it does for you, under that hood.


Try it

That's all I got! Take both cmdx and omx for a spin via pip.

mayapy -m pip install AL_omx
mayapy -m pip install cmdx


Read more

There's a topic created in the omx repository with some more discussion.