Skip to content
Richard Katz
  • Home
  • Tech
  • Art
  • About
August 10, 2026 by katz3d

CATS: Core (part 2)

CATS: Core (part 2)
August 10, 2026 by katz3d

Modules referenced in this chapter:

connect.py
data.py
logging.py
axis.py

curve.py
math.py
undo.py

Continuing the breakdown of modules in the CATS.core subpackage.

Connect

As one of the pillars of the rigging system was going to be using Maya 2020+’s matrix nodes instead of constraints, I needed to have a central location for functions that do the work of connecting nodes with matrix node networks.

I also wanted to be able to “zero out” controls without additional parent group hierarchies. If we move a node’s local transformation into the offsetParentMatrix attribute, then we can zero out the translate and rotate channels and maintain the node’s offset from its parent and also have zeroed-out transform channels.

So we have three levels of connections for nodes:

  • At the simplest level, we create a local offset for the node (worldMatrix * parentInverseMatrix) and store it in an attribute. Then we just plug that attribute into the node’s own offsetParentMatrix attribute. This doesn’t seem to be an issue with creating dependency loops in the graph since _orig_matrix is a constant and not used elsewhere by the node in its transformation calculations.
ORIG_MATRIX_ATTR = "_orig_matrix"

def _set_orig_matrix(node: (str | om2.MObject), world: bool = False):
    """cache a node's original local matrix to an attribute"""
    node_name = pathname(node)
    if not cmds.attributeQuery(ORIG_MATRIX_ATTR, node=node_name, exists=True):
        cmds.addAttr(node_name, longName=ORIG_MATRIX_ATTR, dt="matrix")

    if world:
        node_matrix = cmds.getAttr(f"{node_name}.worldMatrix")
    else:
        node_matrix = cmds.getAttr(f"{node_name}.worldMatrix")

        node_parent = cmds.listRelatives(node_name, parent=True)
        if node_parent:
            parent_matrix = cmds.getAttr(f"{node_parent[0]}.worldMatrix")
            node_matrix = om2.MMatrix(node_matrix) * om2.MMatrix(parent_matrix).inverse()

    cmds.setAttr(f"{node_name}.{ORIG_MATRIX_ATTR}", node_matrix, type="matrix")
  • For some situations, we need to create a multMatrix node to modify the offsetParentMatrix. So the _orig_matrix attribute connects to the multMatrix as well as other matrices that need to be multiplied to transform the node into the desired space.

  • Finally, we can build upon a number of multMatrix nodes and create a space switching system using blendMatrix nodes.

The initial setup for these various connection strategies is done in the freeze() function that calls _set_orig_matrix if necessary.

def freeze(node: (str | om2.MObject)):
    """
    bake local transformation matrix into parentOffsetMatrix 
    and zero out transform channels
    """
    node_name = pathname(node)

    current_connections = cmds.listConnections(f"{node_name}.offsetParentMatrix", 
                                               source=True, 
                                               destination=False, 
                                               plugs=True) or []
    if current_connections:
        return

    _set_orig_matrix(node)

    if cmds.objectType(node_name, isType='joint'):
        cmds.setAttr(f"{node_name}.jointOrient", 0, 0, 0)

    cmds.connectAttr(f'{node_name}.{ORIG_MATRIX_ATTR}', 
                     f'{node_name}.offsetParentMatrix', force=True)

    cmds.setAttr(f'{node_name}.translate', 0, 0, 0)
    cmds.setAttr(f'{node_name}.rotate', 0, 0, 0)
    cmds.setAttr(f'{node_name}.scale', 1, 1, 1)

This is the simple connect.connect() function that mimics what a one-to-one parentConstraint would be used for. It attaches a node to a target node, with an option offset, so it follows another object’s transformation outside of a DAG hierarchy.

def connect(node: (str | om2.MObject, None), 
            target: (str | om2.MObject, None), 
            keep_offset: bool = False):
    """equivalent of a parentConstraint with matrix nodes"""
    node_name = pathname(node)
    target_name = pathname(target)
    freeze(node_name)
    node_parent = cmds.listRelatives(node_name, parent=True)
    node_transform = om2.MMatrix(cmds.xform(node_name, q=True, m=True, ws=True))

    # source_attrs is a list of attributes to connect to the multMatrix node 
    # to multiply in order
    source_attrs = []

    # if target is a node, use its worldMatrix attribute
    if cmds.objExists(target_name) and ('.' not in target_name):
        source_attrs = [f'{target_name}.worldMatrix']

        if keep_offset:
            target_transform = om2.MMatrix(cmds.xform(target_name, 
                                           q=True,
                                           m=True, 
                                           ws=True))
            offset_transform = node_transform * target_transform.inverse()
            source_attrs = [offset_transform] + source_attrs

    # if target is a specific attribute
    elif cmds.objExists(target):
        targets = target_name.split('.')
        if (len(targets) > 1):
            raw_attr = targets[1] if ('[' not in targets[1]) \
                       else targets[1].split('[')[0]
            if cmds.objExists(f'{targets[0]}') and cmds.attributeQuery(raw_attr,
                                                   node=targets[0], exists=True):
                source_attrs = [target]

    # multiply into node's parent's space
    if node_parent:
        source_attrs.append(f'{node_parent[0]}.worldInverseMatrix')

    mult_matrix = create_blend_multmatrix(name=Name(node).change_type('mmx'),
                                          source_attrs=source_attrs)

    cmds.connectAttr(f"{mult_matrix}.matrixSum", 
                     f'{node_name}.offsetParentMatrix', force=True)

Blend spaces are managed by a few functions:

def create_blend_multmatrix(name: str = None, 
                            source_attrs: (list[str] | None) = None) -> str | None:
    """creates a multMatrix node and makes connections for use with blend spaces"""

def get_blend_space(node: (str | om2.MObject)) -> str | None:
    """return the blendMatrix that drives node's offsetParentMatrix"""

def create_blend_space(node: (str | om2.MObject)):
    """create or return a node's blend space blendMatrix"""

def _get_next_blend_index(node: (str | om2.MObject, None)) -> int:
    """get next available index on node's blendMatrix"""

def add_blend_space(node: (str | om2.MObject, None), 
                    target: (str | om2.MObject, None), 
                    default_value=0.0, 
                    rotate_only=False, 
                    explicit=False) -> str | None:
    """add a blend space to node"""

The logic I settled on works like this:

  • add_blend_space(node, target) is called to get the next available blend index on node and connect target to the next index.
  • add_blend_space calls create_blend_space(node) to create or get a blendMatrix node driving node‘s offsetParentMatrix.
    • create_blend_space() calls get_blend_space(node).
      • If a blendMatrix node already exists, it returns it to create_blend_space()
    • If no blendMatrix exists for this blendSpace, create_blend_space() creates it
  • add_blend_space calls _get_next_blend_index() and connects target to the next index.
  • add_blend_space returns the weight attribute for the new blend index

get_blend_space is only called by create_blend_space to see if it needs to create it or pass along an existing blendMatrix node. create_blend_space is only called within add_blend_space to get or create a blendMatrix to append to. So the user will only need to call add_bend_space and any additional work should happen under the hood to create additional nodes as necessary.

The connect module also contains a function to compensate for jointOrient transformations, but I ended up not using it.

  • (Note: I refactored some of the blend space functionality here when I worked on formalizing a Control Space system recently).

ik_blend and pin

I added functions to do IK/FK switching in this module since it makes use of the blend, as well as a pin() function that uses the uvPin matrix node to attach nodes to a mesh or nurbsSurface.

def ik_blend(fk_chain: list[str | om2.MObject], 
             ik_chain: list[str | om2.MObject], 
             blend_chain: list[str | om2.MObject], 
             blend_attr: str) -> dict[int, str]:
   """create blend setup between fk and ik chains"""

def pin(node: (str | om2.MObject), target: (str | om2.MObject)):
   """ pin node to mesh or nurbs surface """

For pin(), it took some trial and error to find the correct multiplication order for the matrices. What I ended up with looks like this:

   if node_parent:
        connections = [
            f'{pathname(node)}.{ORIG_MATRIX_ATTR}',
            f'{parent_hold}.outMatrix',
            f'{inv_hold}.outMatrix',
            f'{uvpin}.outputMatrix[0]',
            f"{node_parent[0]}.worldInverseMatrix",
        ]
  1. The original local transform attribute of the pinned node
  2. parent_hold = The initial state of the world parentInverseMatrix (original world transform of node)
  3. inv_hold = The inverse of the uvPin’s original matrix (transforms to initial uvPin matrix space)
  4. The current uvPin matrix value
  5. Transform back into node’s parent’s current world space

Data

This module is small and simple, just a central location for definitions of file and directory paths for the rigging project, template files, and user documents.

class Data:
    """rig data paths"""
    @staticmethod
    def base_path() -> str:
        return os.path.normpath(
                   os.path.join(
                       os.path.dirname(
                           os.path.realpath(__file__)
                       ), 
                       '..'
                   )
               )

    @classmethod
    def data_path(cls) -> str:
        return os.path.join(cls.base_path(), 'data')

    @classmethod
    def characters_path(cls) -> str:
        """Character root path, in CATS_CHARACTER_PATH environment variable"""
        return os.environ.get('CATS_CHARACTER_PATH',
                              os.path.join(cls.user_documents(),
                              'characters'))

    @staticmethod
    def user_documents() -> str:
        """windows internal call to get current user's documents directory"""
        #return shell.SHGetFolderPath(0, shellcon.CSIDL_PERSONAL, None, 0)
        return os.path.expanduser(r'~\documents')

    @classmethod
    def character_path(cls, character_name: str) -> str:
        """returns rig data path for a character"""
        return os.path.join(cls.characters_path(), character_name)

    @classmethod
    def settings_file(cls):
        """
        settings file path
        stores UI window geometry and recently loaded rig description files
        """
        appdata = os.environ.get("LOCALAPPDATA")
        settings_dir = os.path.join(appdata, "CATS")
        if not os.path.exists(settings_dir):
            os.makedirs(settings_dir)
        settings_file = os.path.join(settings_dir, "settings.json")
        return settings_file
  • The user_documents() method was a fun tidbit: it gets the current user’s documents folder from windows calls rather than piecing it together (potentially incorrectly) from guessing the location based on the username.

    Source: https://stackoverflow.com/questions/3858851/how-to-get-windows-special-folders-for-currently-logged-in-user

  • I ended up not using that approach since it required an external package win32com, and just used the default documents folder via os.path.expanduser() instead.

Logging

A solid foundation for logging is a must for any large project. This module uses the standard library Logging module to fork log output to file, buffer, and stdout.

During development, I ended up with duplicate logging handlers writing the same info to multiple places, so I had to write some extra cleanup methods, and I think I got them all sorted out.

I created my own singleton to manage the “cats_logger” logger and the StreamHandlers that get attached to it.

LOGGER_NAME = "cats_logger"
LOGGER_LEVEL = logging.DEBUG
_log_file = os.path.join(os.environ.get('LOCALAPPDATA'), 'cats', 'logs', LOGGER_NAME + '.log')

class Logger(object):
    """logging wrapper - write log output to stdout, file, and UI"""
    _logger = None
    _stream = io.StringIO()
    _stdout_handler = logging.StreamHandler(sys.stdout)
    _stream_handler = logging.StreamHandler(_stream)
    _file_handler = logging.FileHandler(_log_file)
    _streams = []
    fmt = logging.Formatter(fmt='[%(asctime)s] %(message)s', 
                            datefmt='%Y-%m-%d %H:%M:%S')

    def __init__(self):
        if not self._logger:
            self.init_handlers()

    def __new__(cls):
        if not hasattr(cls, '_instance'):
            cls._instance = super().__new__(cls)
            cls._logger = logging.getLogger(LOGGER_NAME)
            cls.clean_handlers()
        return cls._instance

    def __getattr__(self, item):
        if hasattr(self._logger, item):
            return self._logger.__getattribute__(item)
        else:
            return super().__getattribute__(item)

The init and cleanup methods here try to prevent superfluous streams from hanging around.

   @classmethod
    def clean_handlers(cls):
        """
        prevent duplicate logging handlers
        happened a lot while reloading modules during development
        """
        if not cls._logger.handlers:
            return
        for handler in reversed(cls._logger.handlers):
            if handler.get_name() == LOGGER_NAME:
                cls._logger.removeHandler(handler)

    def init_handlers(self):
        """sets up logging handlers"""
        # write to stdout
        if self._stdout_handler not in self._logger.handlers:
            self._logger.addHandler(logging.StreamHandler(sys.stdout))

        # save to var
        if self._stream_handler not in self._logger.handlers:
            self._logger.addHandler(self._stream_handler)

        # save to file
        if self._file_handler not in self._logger.handlers:
            if not os.path.exists(os.path.dirname(_log_file)):
                os.makedirs(os.path.dirname(_log_file))
            self._file_handler = logging.FileHandler(_log_file)
            self._file_handler.setLevel(logging.DEBUG)
            self._file_handler.setFormatter(self.fmt)
            self._logger.addHandler(self._file_handler)

The Logger class has a subscribe() method for attaching the log directly to the UI window.

   def subscribe(self, stream: io.StringIO):
        """create a StreamHandler for a Qt widget"""
        _stream_handler = logging.StreamHandler(stream)
        _stream_handler.set_name(stream.name)
        _stream_handler.setLevel(logging.INFO)
        _stream_handler.setFormatter(self.fmt)
        self.addHandler(_stream_handler)
        self._streams.append(_stream_handler)
        stream.widget.destroyed.connect(partial(self.remove_stream, stream))

   def remove_stream(self, stream_handler: logging.StreamHandler):
        """remove a StreamHandler"""
        self.removeHandler(stream_handler)
        self._streams.remove(stream_handler)

So all log entries get output to stdout (the maya script editor), the UI QTextEdit control, and a log file in appdata.

The stream type that subscribe() expects is this subclass of io.StringIO. It re-implements write() to prevent extra line breaks in the window, and adds the widget as a property to track the widget that the log is attached to and to remove the stream when the widget is destroyed.

class LogStream(io.StringIO):
    """
    stream for connecting the window log output widget 
    to the python logging system
    """
    def __init__(self, widget, name='rig_build'):
        super().__init__()
        self.widget = widget
        self.name = name

    def write(self, s):
        self.widget.append(str(s).strip())

Axis

The Axis class allows for standardized conversions for vector axes. Maya isn’t exactly consistent with how you interact with various functions and nodes.

  • Sometimes you want a list form of a vector like [1, 0, 0]
  • Sometimes you want an index of the correct axis, like “0” for X.
  • Sometimes you want a string representation, like “-z”.
  • And sometimes I want to take one of those values and convert it into an MVector.

The constructor takes any of the above as an argument and converts it into the internal format (list)

class Axis(object):
    """helper class for translating vectors into different formats"""
    str_table = {
        'x': [1,0,0],
        'y': [0,1,0],
        'z': [0,0,1],
        '-x': [-1,0,0],
        '-y': [0,-1,0],
        '-z': [0,0,-1]
    }
    int_table = [
        [1, 0, 0],
        [0, 1, 0],
        [0, 0, 1]
    ]
    def __init__(self, value: (int | list | om2.MVector | str)):
        self._value = self._convert(value)

    def __repr__(self):
        return f"{self.__class__.__name__}({self._value})"

    def _convert(self, value: (int | list | om2.MVector | str)) -> list:
        """convert an axis vector into internal format"""
        if isinstance(value, list) and len(value) == 3:
            return value

        if isinstance(value, om2.MVector):
            return list(value)

        if isinstance(value, str) and value.lower() in self.str_table:
            return self.str_table[value.lower()]

        if isinstance(value, int) and (0 <= value <= 2):
            return self.int_table[value]

        raise ValueError(f"Unsupported value {value}")

There are a few methods for conversion to other formats:

   def to_char(self) -> str:
        """return a string representation of the major axis"""
        index = self.to_int()
        sign = "-" if self._value[index] < 0 else ""
        return (sign + ['x','y','z'][index])

    def to_list(self) -> list:
        """return a list representation of the major axis"""
        return self._value

    def to_vector(self) -> om2.MVector:
        """return this axis in OpenMaya MVector format"""
        return om2.MVector(self.to_list())

    def to_int(self) -> int:
        """return a single integer representation [0 | 1 | 2] of the major axis"""
        for i,v in enumerate(self._value):
            if v != 0:
                return i
        raise ValueError(f"unknown integer value {self._value}")

    def is_negative(self) -> bool:
       """return True if the axis has any negative values"""
        for v in self._value:
            if v < 0:
                return True
        return False

I refactored some other functions together into this class method that returns an Axis instance for the row of a matrix that points closest to a world space vector. It’s used in a few places in the project:

  • Figuring out where to position IK foot controls based on guides
  • Positioning the head aim control
  • Orienting the plane for the spine ribbon
  • Some other stuff related to orienting control shapes
@staticmethod
def get_closest_index(vector: (list[float] | om2.MVector), 
                      matrix: (list[float] | om2.MMatrix)) -> int:
    """returns the matrix row index of the closest axis to the vector"""
    axis_list = [om2.MVector(list(matrix)[0:3]),
                 om2.MVector(list(matrix)[4:7]), 
                 om2.MVector(list(matrix)[8:11])]
    dot_list = [abs(a * om2.MVector(vector)) for a in axis_list]
    closest = max(dot_list)
    return dot_list.index(closest)

@classmethod
def get_closest_axis(cls, 
                     vector: (list[float] | om2.MVector), 
                     matrix: (list[float] | om2.MMatrix)) -> AxisType:
    """returns the closest matrix axis to given world space vector"""
    node_axis = cls.get_closest_index(vector, matrix)

    node_vector = get_matrix_vector(matrix=om2.MMatrix(matrix), row=node_axis)
    aim_axis = cls(node_axis) if ((node_vector * om2.MVector(vector)) > 0) \
                              else cls(list(Axis(node_axis).to_vector() * -1))

    return aim_axis

Curve

This module contains some functions for calculating points on Hermite and Bezier (cubic and quadratic) curves. These aren’t really used in the rigs, but I did use them for testing and they may be useful in the future.

def cubic(A: typing.Iterable[float],
          B: typing.Iterable[float],
          C: typing.Iterable[float],
          D: typing.Iterable[float],
          t: float) -> om2.MVector:
    """
    cubic bezier curve, parametric form
    Args:
        A: first control point
        B: second control point
        C: third control point
        D: fourth control point
        t: 't' value between 0 and 1, normalized position on curve

    Returns:
        A point on the curve with given control points
    """
    return (
             (((1 - t) ** 3) * om2.MVector(A)) + 
             ((3 * (1 - t) ** 2) * t * om2.MVector(B)) + 
             (3 * (1 - t) * t ** 2 * om2.MVector(C)) + 
             (t ** 3 * om2.MVector(D))
           )

def quadratic(A: typing.Iterable[float],
              B: typing.Iterable[float],
              C: typing.Iterable[float],
              t: float) -> om2.MVector:
    # B(t) = (1 - t)²P0 + 2(1 - t) tP1 + t²P2
    return (
             (1 - t)**2)*om2.MVector(A) + 
             ((2*(1 - t)) * t * om2.MVector(B)) + 
             (t**2 * om2.MVector(C)
           )


def hermite_basis(t: float) -> typing.List[float]:
    """hermite basis function for a given t value"""
    h1 = (2.0 * t**3) - (3.0 * t**2) + 1.0
    h2 = (-2.0 * t**3) + (3.0 * t**2)
    h3 = t**3 - (2.0 * t**2) + t
    h4 = t**3 - t**2
    return [h1, h2, h3, h4]


def hermite(p0: typing.Iterable[float],
            p1: typing.Iterable[float],
            t0: typing.Iterable[float],
            t1: typing.Iterable[float],
            t: float,
            a: float = 3.0) -> om2.MVector:
    """
    Args:
        p0: first cv
        p1: second cv
        t0: first cv's tangent vector
        t1: second cv's tangent vector
        t: 0-1 parameter
        a: tension value

    Returns: world position at t along curve
    """
    h1, h2, h3, h4 = hermite_basis(t)
    return (
             (h1 * om2.MVector(p0)) + 
             (h2 * om2.MVector(p1)) + 
             (a * h3 * (om2.MVector(p0) - om2.MVector(t0))) + 
             (a * h4 * (om2.MVector(t1) - om2.MVector(p1)))
           )

I got into a “Hermite phase” and wrote a function to build out a node network to drive a chain as a Hermite curve. Hermite curves work as a start and end point, and direction vectors on each end point instead of inner control points like bezier curves. But they’re not really that different, since the inner control points of a bezier curve kind of act in a pretty similar way, changing the continuity of the in and out flow of the curves. Hermite curves also include a bias value (“a”) that can affect the shape of the curve.

def hermite_nodes(p0: str,
                  p1: str,
                  t0: str,
                  t1: str,
                  t: str,
                  a: str):
    """create a hermite node network in Maya to drive node 't'"""

I also wrote a Maya Python Plugin that did the same calculations in a single node rather than a network of over 100 nodes, but it was considerably slower than the large Maya network. I’d be interested in trying to see if a C++ plugin version would be competitive with the node network. Maya “thinks” in nodes, the DG is Maya’s native language, so of course it runs much faster than what’s basically a Python script trying to run in realtime.

I plan to create a similar function for constructing a bezier curve node network for future components.

I did a little R&D after talking with some other Tech Artists about how to do curve fitting to match a Bezier curve’s controls (CV’s) to an existing baked curve. Consider a rig where you drive a tentacle component that consists of 16 joints with 4 CV-like controls, then bake out that data. Then you want to re-import the baked joint matrices and snap the controls to the correct positions that would generate that curve. I did a bit of back and forth with ChatGPT and some source reference material, I ended up with a function that gave predictable, reliable results.

def fit(t_list: list[Tuple[float,float,float]]) \
        -> Tuple[Tuple[float, float, float],Tuple[float, float, float]]:
    """
    Find P1 and P2 control points assuming P0 == t_list[0] and P4 == t_list[-1]
    Args:
        t_list: list of tuples (x,y,z) of curve points, assuming uniform spacing

    Returns:
        tuple of control points P1 and P2
    """

Here are some of the reference links I used in the process of writing this function:

  • https://numpy.org/devdocs/reference/generated/numpy.matrix.html
  • https://chatgpt.com/c/6893d19c-81c4-832f-a86c-9d8424af861c
  • https://math.stackexchange.com/questions/2720000/how-to-understand-equation-parallel-x-x-prime2-2?noredirect=1&lq=1
  • https://www.youtube.com/watch?v=xavgv1m9feE&t=8s
  • https://math.stackexchange.com/questions/301736/how-do-i-find-a-bezier-curve-that-goes-through-a-series-of-points?rq=1
  • https://stackoverflow.com/questions/5936953/how-to-calculate-control-points-in-cubic-beizer-curve?noredirect=1&lq=1
  • https://en.wikipedia.org/wiki/B%C3%A9zier_curve#
  • https://geometrictools.com/Documentation/BSplineCurveLeastSquaresFit.pdf
  • https://math.stackexchange.com/questions/2599669/find-control-points-to-produce-a-given-curve
  • https://en.wikipedia.org/wiki/Bernstein_polynomial

Visual Lines for Rig

This function is only curve-adjacent, but it seemed the best place to put it for now. It just creates a 1-degree curve and attaches the two ends to two nodes with cluster deformers. A use-case example is for showing the relationship of a pole vector control to the IK joint chain.

Creating clusters (cluster deformers, not skinClusters) from scratch in code wasn’t as easy as I thought, and I had to dig into Maya’s MEL scripts to figure out how Maya was doing it through the UI. I wanted to just create the cluster deformers without accompanying parent transforms, but I guess I settled on doing it through maya.cmds and then deleting the transform it automatically creates.

def create_line(source, dest, suffix='_link', parent=None, allow_dupes=False):
    """creates a deforming 1-degree ep curve between two nodes"""
    # create 1 degree ep curve
    base_name = Name(dest).base
    crv_name = Name(dest).change(node_type='crv', 
                                 base=(base_name + suffix)).build()

    if (not allow_dupes) and (cmds.objExists(crv_name)):
        cmds.delete(crv_name)

    curve_line = cmds.curve(name=crv_name, 
                            degree=1, 
                            editPoint=[[0, 0, 0], [0, 0, 0]])

    crv_shape = cmds.listRelatives(curve_line, shapes=1)
    cmds.setAttr((crv_shape[0] + '.drawOverride.overrideEnabled'), True)
    # reference
    cmds.setAttr((crv_shape[0] + '.drawOverride.overrideDisplayType'), 2) 
   
    cmds.setAttr((curve_line + '.inheritsTransform'), False)

    # create clusters, assign cvs
    for index, obj in enumerate([source, dest]):
        cmds.select(clear=True)
        cls0 = cmds.cluster(name=Name(crv_name).change(node_type='cls'))
        cmds.cluster(cls0[0], edit=1, geometry=f'{crv_shape[0]}.cv[{index}]')
        cmds.connectAttr(f'{pathname(obj)}.worldMatrix', 
                         f'{cls0[0]}.matrix', force=True)
        cmds.disconnectAttr(f'{cls0[1]}.clusterTransforms[0]',  
                            f'{cls0[0]}.clusterXforms')
        cmds.delete(cls0[1])

    cmds.parent(curve_line, parent)
    cmds.lockNode(curve_line)

    return curve_line

Math

A few matrix/vector utility functions.

def get_plane_normal(plane: typing.Iterable[om2.MVector]) -> om2.MVector:
    """returns a normal of a plane given 3 points"""
    plane_vector1 = (plane[1] - plane[0]).normal()
    plane_vector2 = (plane[2] - plane[0]).normal()
    return (plane_vector1 ^ plane_vector2).normal()


def project_point_on_plane(point: om2.MVector, 
                           plane: typing.Iterable[om2.MVector]) -> om2.MVector:
    """returns the closest point on a plane from given point"""
    plane_normal = get_plane_normal(plane)
    shortest_dist = plane_normal * (point - plane[0])
    vector_to_plane = plane_normal * shortest_dist
    return point + (vector_to_plane * -1)



def get_matrix_vector(matrix: om2.MMatrix, row: int = 3) -> om2.MVector:
    """return a single MMatrix row as a MVector"""
    start_index = (row * 4)
    end_index = start_index + 3
    return om2.MVector(list(matrix)[start_index:end_index])


def point_line_projection(start: om2.MVector, 
                          end: om2.MVector, 
                          point: om2.MVector) -> om2.MVector:
    """returns the closest point on a line from given point"""
    vector = end - start
    diagonal = point - start
    perp = vector.normal() * diagonal.normal()
    return (start + (vector * (perp * (diagonal.length() / vector.length()))))



Undo

By default, OpenMaya API calls don’t get added to the undo buffer. Marcus Ottosson of Qt.py fame wrote a Python plugin that lets you manually add undo/redo stubs into Maya’s undo cache. But it’s a lot of work.

https://github.com/mottosso/apiundo

I spent some time testing this when I was going to do more API-based manipulation, but I had mixed results, probably due to user error. Maybe it was overkill even when using API calls? If I’m building a rig in code, there’s nothing I need to immediately undo. I might need to revisit this when I build more tools that utilize some of the API functionality in this project.

Core Summary

The nature of a library like this is that while it should form a solid base, it will grow as the project grows. It grew while I was working on it, as I found reusable pieces of code while developing the higher-level rigging components I would transplant them into appropriate places in the Core subpackage. I’d like to revisit the Node module in particular and break it up into several parts now that it’s grown much larger than originally designed.

In the next chapter, we’ll take a look at the “Class” modules meant to wrap Maya nodes or sets of nodes.

If you find this retrospective informative, useful, enjoyable, or some other adjective, you can buy me a coffee here.

Previous articleCATS: Core (Part 1)Next article CATS: Classes
  • September 2026
  • August 2026
  • July 2026
  • June 2021
  • March 2021