Home Website Youtube GitHub

Spring Chain Gravity / Bake Spring Nodes

Maya 2026
mGear 5.3.5 - 5.4.4
Spring Chain Gravity

During animation, all Spring Chain Gravity parameters work perfectly. The result is excellent, the gravity simulation looks fantastic, and everything is fine-tuned exactly the way I want it. None of the Spring Chain parameters are animated!

However, when I use Bake Spring Nodes , the elephant’s trunk gradually starts falling downward.

Is there another way to bake the current Spring Chain Gravity simulation so I can manually refine the animation afterward and create a perfect loop?

I think the ideal solution would be a button that actually bakes the simulated motion directly onto the animation controls as keyframes, allowing us to fine-tune the final animation.

Thanks in advance!

I asked GPT to create a script based on an approach I believe is practical and useful for animators.
It works with both Spring Chain systems. Just run in your py script editor and you will get small UI with a few options.

Let’s go to make a loop :slight_smile:

"""Bake an mGear spring-chain result onto its FK controls.

This is deliberately not a wrapper around mGear's *Bake Spring nodes* menu.
That menu keys the internal ``*_spring##_cns`` PLOT transforms. A spring
chain still owns the corresponding ``*_spring##`` target translations, so the
stored rotations and the live solver can be evaluated together.  The result
is the progressively falling bake seen with the stock tool.

This tool instead:

1. samples every FK control's evaluated world matrix while the spring is live;
2. executes ``setAttr`` only on the animator-facing spring-intensity UI plug;
3. writes the same transforms, frame by frame, to ``*_fk##_ctl`` on a new
   override animation layer.

It never disconnects, keys, or otherwise changes an internal spring node,
constraint, pairBlend, target, or PLOT group. Existing mGear Bake Spring
curves in a referenced rig are treated as read-only evaluated input. The new
layer can be muted or deleted to restore the original animator keys before
another simulation pass.

Usage in Maya's Python Script Editor
------------------------------------

Execute this file directly to open the UI. If the file is installed on
Maya's script path, it can also be opened from a shelf button or Script
Editor with::

    import rig_mGear_bake_spring as spring_bake
    spring_bake.show()

Or call the API directly after selecting one or more chain controls or UI
hosts::

    spring_bake.bake_selected_component()

    # Optional: evaluate frames before the first baked frame.
    # spring_bake.bake_selected_component(preroll=10)

For a standard mGear Shifter component, the only simulation mutation is a
``setAttr(..., 0)`` on the single ``*_spring_intensity`` user attribute found
on that component's UI host.

The tool captures the exact currently evaluated result, including any existing
read-only mGear bake curves in a reference. It never tries to clear them.
"""

from __future__ import absolute_import, division, print_function

import re

from maya import cmds
from maya.api import OpenMaya as om


class SpringBakeError(RuntimeError):
    """A scene layout that would make a control bake ambiguous or unsafe."""


_CONTROL_SUFFIX_RE = re.compile(r"_fk(\d+)_ctl$")
_EULER_ORDERS = (
    om.MEulerRotation.kXYZ,
    om.MEulerRotation.kYZX,
    om.MEulerRotation.kZXY,
    om.MEulerRotation.kXZY,
    om.MEulerRotation.kYXZ,
    om.MEulerRotation.kZYX,
)
_CHANNELS = ("tx", "ty", "tz", "rx", "ry", "rz", "sx", "sy", "sz")
_COMPONENT_ROOT_RE = re.compile(r"^(?P<prefix>.+)(?P<side>_[LRC]\d+)_root$")
_UI_CONTROL_RE = re.compile(r"^(?P<prefix>.+)UI(?P<side>_[LRC]\d+)_ctl$")
_UI_ROOT_RE = re.compile(r"^(?P<prefix>.+)UI(?P<side>_[LRC]\d+)_root$")


def _long_name(node):
    names = cmds.ls(node, long=True) or []
    if not names:
        raise SpringBakeError("Node does not exist: {}".format(node))
    return names[0]


def _leaf_name(node):
    return node.rsplit("|", 1)[-1]


def _control_index(node):
    match = _CONTROL_SUFFIX_RE.search(_leaf_name(node))
    if not match:
        raise SpringBakeError("Not an mGear FK control: {}".format(node))
    return int(match.group(1))


def _parent(node):
    parents = cmds.listRelatives(node, parent=True, fullPath=True) or []
    return parents[0] if parents else None


def _component_root_from_ui_leaf(leaf):
    """Map a normal Shifter ``componentUI`` node back to ``component_root``."""
    match = _UI_CONTROL_RE.match(leaf) or _UI_ROOT_RE.match(leaf)
    if not match:
        return None
    candidate = "{}{}_root".format(match.group("prefix"), match.group("side"))
    candidates = cmds.ls(candidate, long=True) or []
    return candidates[0] if candidates else None


def _find_component_root(node=None):
    """Find the chain component root from ``node`` or the current selection."""
    if node is None:
        selection = cmds.ls(selection=True, long=True) or []
        if not selection:
            raise SpringBakeError(
                "Select a node under the spring-chain component first."
            )
        node = selection[0]

    start_node = _long_name(node)
    current_node = start_node
    while current_node:
        leaf = _leaf_name(current_node)
        ui_component_root = _component_root_from_ui_leaf(leaf)
        if ui_component_root:
            return ui_component_root
        # Animation attributes for this rig live on the separate *UI*_ctl
        # host, not on the component root.  The root is identified purely by
        # its DAG hierarchy here; _component_controls validates it later.
        if leaf.endswith("_root"):
            return current_node
        current_node = _parent(current_node)

    # The UI host is normally outside the component hierarchy. Selecting
    # trunkUI_C0_ctl must therefore resolve trunk_C0_root by name.
    raise SpringBakeError(
        "Could not find a chain spring component root above {}. "
        "Expected a *_root transform with spring_intensity, or its *UI_*_ctl "
        "host.".format(start_node)
    )


def _component_controls(component_root):
    """Return this component's FK controls in root-to-tip order."""
    root_leaf = _leaf_name(component_root)
    if not root_leaf.endswith("_root"):
        raise SpringBakeError("Component root must end in _root: {}".format(component_root))

    component_name = root_leaf[:-len("_root")]
    candidates = cmds.ls(
        component_name + "_fk*_ctl", type="transform", long=True
    ) or []
    controls = [node for node in candidates if _CONTROL_SUFFIX_RE.search(_leaf_name(node))]
    controls.sort(key=_control_index)

    if not controls:
        raise SpringBakeError(
            "No FK controls found for {}.".format(component_name)
        )

    # This check catches another similarly named component before any keys are
    # changed.  It also documents the hierarchy this bake relies on.
    for control in controls:
        index = _control_index(control)
        parent = _parent(control)
        expected = "{}_spring{}_cns".format(component_name, index)
        if parent is None or _leaf_name(parent) != expected:
            raise SpringBakeError(
                "{} is not directly parented under {}. "
                "This is not the expected mGear spring-chain hierarchy."
                .format(control, expected)
            )
    return controls


def _incoming_plugs(plug):
    return cmds.listConnections(plug, source=True, destination=False, plugs=True) or []


def _node_type(plug):
    return cmds.nodeType(plug.split(".", 1)[0])


def _is_anim_curve(plug):
    return _node_type(plug).startswith("animCurve")


def _control_is_safe_to_key(control):
    """Reject a constrained/driven control instead of inserting a pairBlend."""
    for channel in _CHANNELS:
        plug = control + "." + channel
        if cmds.getAttr(plug, lock=True):
            raise SpringBakeError("{} is locked.".format(plug))
        unexpected = [source for source in _incoming_plugs(plug) if not _is_anim_curve(source)]
        if unexpected:
            raise SpringBakeError(
                "{} has a non-animation incoming connection ({}). "
                "Bake requires direct animator controls."
                .format(plug, ", ".join(unexpected))
            )


def _ui_host_from_component_root(component_root):
    """Return the standard Shifter UI host belonging to a component root."""
    leaf = _leaf_name(component_root)
    match = _COMPONENT_ROOT_RE.match(leaf)
    if not match:
        return None
    hosts = cmds.ls(
        "{}UI{}_ctl".format(match.group("prefix"), match.group("side")),
        long=True,
    ) or []
    return hosts[0] if hosts else None


def _default_ui_intensity_plug(component_root):
    """Discover the spring-intensity attribute on the component's UI host."""
    ui_host = _ui_host_from_component_root(component_root)
    if not ui_host:
        return None
    attributes = cmds.listAttr(ui_host, userDefined=True, keyable=True) or []
    candidates = [
        attribute
        for attribute in attributes
        if attribute.lower() == "spring_intensity"
        or attribute.lower().endswith("_spring_intensity")
    ]
    if len(candidates) == 1:
        return ui_host + "." + candidates[0]
    return None


def _resolve_ui_intensity_plug(component_root, simulation_ui_plug):
    """Validate the only simulation attribute this tool is allowed to edit."""
    plug = simulation_ui_plug or _default_ui_intensity_plug(component_root)
    if not plug:
        raise SpringBakeError(
            "Could not find one unique *_spring_intensity attribute on this "
            "component's UI host. Pass simulation_ui_plug explicitly."
        )
    if not cmds.objExists(plug):
        raise SpringBakeError("Simulation UI plug does not exist: {}".format(plug))
    if cmds.getAttr(plug, lock=True):
        raise SpringBakeError("Simulation UI plug is locked: {}".format(plug))
    return plug


def _new_bake_layer(component_root, controls, requested_name=None):
    """Create an override layer so the animator's original curves remain safe."""
    layer_name = requested_name or "mGearSpringBake_{}".format(
        _leaf_name(component_root).replace(":", "_").replace("_root", "")
    )
    if cmds.objExists(layer_name):
        raise SpringBakeError(
            "Bake layer already exists: {}. Mute or delete it before baking "
            "again; original animation has been preserved below it."
            .format(layer_name)
        )
    layer = cmds.animLayer(layer_name, override=True)
    cmds.select(controls, replace=True)
    cmds.animLayer(layer, edit=True, addSelectedObjects=True)
    return layer


def _world_matrix(node):
    return om.MMatrix(cmds.xform(node, query=True, worldSpace=True, matrix=True))


def _sample_live_result(controls, start, end, preroll):
    """Step the solver forward and record the exact displayed FK transforms."""
    first_eval_frame = start - max(0, int(preroll))
    cmds.currentTime(first_eval_frame - 1, edit=True)
    for frame in range(first_eval_frame, start):
        cmds.currentTime(frame, edit=True)

    samples = {}
    for frame in range(start, end + 1):
        cmds.currentTime(frame, edit=True)
        samples[frame] = {control: _world_matrix(control) for control in controls}
    return samples


def _key_world_matrix(control, desired_world, frame, bake_layer):
    """Write an evaluated world matrix as FK control channels at ``frame``."""
    parent = _parent(control)
    if parent:
        local = desired_world * _world_matrix(parent).inverse()
    else:
        local = desired_world

    transform = om.MTransformationMatrix(local)
    translation = transform.translation(om.MSpace.kTransform)
    rotation = transform.rotation(asQuaternion=False)
    scale = transform.scale(om.MSpace.kTransform)
    rotate_order = int(cmds.getAttr(control + ".rotateOrder"))
    rotation.reorderIt(_EULER_ORDERS[rotate_order])

    values = {
        "tx": translation.x,
        "ty": translation.y,
        "tz": translation.z,
        "rx": om.MAngle(rotation.x).asDegrees(),
        "ry": om.MAngle(rotation.y).asDegrees(),
        "rz": om.MAngle(rotation.z).asDegrees(),
        "sx": scale[0],
        "sy": scale[1],
        "sz": scale[2],
    }
    for channel in _CHANNELS:
        cmds.setKeyframe(
            control,
            attribute=channel,
            time=frame,
            value=values[channel],
            animLayer=bake_layer,
        )


def _bake_samples_to_controls(controls, samples, start, end, bake_layer):
    """Solve parent-to-child so each stored world matrix is preserved."""
    for frame in range(start, end + 1):
        cmds.currentTime(frame, edit=True)
        for control in controls:
            _key_world_matrix(control, samples[frame][control], frame, bake_layer)
            # Anim-layer outputs are dirtied by setKeyframe but Maya does not
            # always update a downstream child until time is explicitly
            # evaluated again. The next FK control depends on this parent.
            cmds.currentTime(frame, edit=True, update=True)

    # Filter only the curves made on this layer, never the animator's base curves.
    layer_curves = cmds.animLayer(bake_layer, query=True, animCurves=True) or []
    if layer_curves:
        cmds.filterCurve(layer_curves, filter="euler")


def _verify_bake(controls, samples, start, end, tolerance):
    """Return the largest element-wise world-matrix difference after baking."""
    largest_error = 0.0
    largest_location = None
    for frame in range(start, end + 1):
        cmds.currentTime(frame, edit=True)
        for control in controls:
            actual = _world_matrix(control)
            expected = samples[frame][control]
            error = max(abs(actual[i] - expected[i]) for i in range(16))
            if error > largest_error:
                largest_error = error
                largest_location = (frame, control)
    if largest_error > tolerance:
        raise SpringBakeError(
            "Verification failed: world-matrix error {:.8f} at frame {} on {}. "
            "Undo once; this rig likely uses non-zero rotate pivots or another "
            "custom transform connection that needs a rig-specific variant."
            .format(largest_error, largest_location[0], largest_location[1])
        )
    return largest_error


def bake_component(component_root=None, start=None, end=None, preroll=0,
                   simulation_ui_plug=None, bake_layer=None, verify=True,
                   tolerance=1.0e-4):
    """Bake one live mGear spring chain to its own FK controls.

    Args:
        component_root (str): ``*_root`` transform.  Omit to derive it from
            the first selected node.
        start (int): First frame to bake.  Defaults to the playback minimum.
        end (int): Last frame to bake.  Defaults to the playback maximum.
        preroll (int): Frames evaluated before ``start`` but not keyed.
        simulation_ui_plug (str): Animator-facing intensity attribute to turn
            off after sampling. By default it is discovered from the
            component's UI host as its unique ``*_spring_intensity`` attribute.
        bake_layer (str): Override animation-layer name. Omit for an
            automatically named layer.
        verify (bool): Compare baked and pre-bake world matrices afterward.
        tolerance (float): Maximum allowed element-wise matrix difference.

    Returns:
        dict: controls, range, bake layer, UI plug turned off, and verification
        error.

    The operation is one Maya undo step.  It edits animation curves on the FK
    controls in the requested range; work from a versioned animation scene.
    """
    if start is None:
        start = int(cmds.playbackOptions(query=True, minTime=True))
    if end is None:
        end = int(cmds.playbackOptions(query=True, maxTime=True))
    start, end = int(start), int(end)
    if end < start:
        raise SpringBakeError("End frame must be greater than or equal to start frame.")

    component_root = _find_component_root(component_root)
    controls = _component_controls(component_root)
    simulation_ui_plug = _resolve_ui_intensity_plug(
        component_root, simulation_ui_plug
    )
    for control in controls:
        _control_is_safe_to_key(control)

    original_time = cmds.currentTime(query=True)
    original_selection = cmds.ls(selection=True, long=True) or []
    original_intensity = cmds.getAttr(simulation_ui_plug)
    finished = False
    cmds.undoInfo(openChunk=True, chunkName="Bake mGear spring chain to FK controls")
    try:
        samples = _sample_live_result(controls, start, end, preroll)
        # Intentionally the sole spring-network mutation: the animator-facing
        # UI attribute requested for this tool, never a pairBlend or solver.
        cmds.setAttr(simulation_ui_plug, 0.0)
        bake_layer = _new_bake_layer(component_root, controls, bake_layer)
        _bake_samples_to_controls(controls, samples, start, end, bake_layer)
        max_error = _verify_bake(
            controls, samples, start, end, tolerance
        ) if verify else None
        finished = True
    finally:
        if not finished:
            # A failed bake must not leave the user's live simulation switched off.
            cmds.setAttr(simulation_ui_plug, original_intensity)
        cmds.currentTime(original_time, edit=True)
        if original_selection:
            cmds.select(original_selection, replace=True)
        else:
            cmds.select(clear=True)
        cmds.undoInfo(closeChunk=True)

    result = {
        "component_root": component_root,
        "controls": controls,
        "start": start,
        "end": end,
        "bake_layer": bake_layer,
        "simulation_ui_plug_set_to_zero": simulation_ui_plug,
        "max_matrix_error": max_error,
    }
    print(
        "Baked {} FK controls from {} to {} on layer {}. Max matrix error: {}. "
        "Set only {} to 0.".format(
            len(controls), start, end, bake_layer, max_error,
            simulation_ui_plug,
        )
    )
    return result


def _selected_component_roots():
    """Resolve unique spring-component roots from the current selection."""
    selection = cmds.ls(selection=True, long=True) or []
    if not selection:
        raise SpringBakeError(
            "Select one or more spring-chain controls, roots, or UI controls first."
        )

    roots = []
    ignored = []
    for node in selection:
        try:
            root = _find_component_root(node)
            # A UI host selection commonly contains world/root controls too.
            # Accept only actual chain-spring components before batching, so
            # those ordinary rig controls are harmlessly ignored.
            _component_controls(root)
            _resolve_ui_intensity_plug(root, None)
        except SpringBakeError:
            ignored.append(node)
            continue
        if root not in roots:
            roots.append(root)

    if not roots:
        raise SpringBakeError(
            "No chain spring component could be resolved from: {}"
            .format(", ".join(ignored))
        )
    if ignored:
        cmds.warning(
            "Ignored non-spring selection(s): {}".format(", ".join(ignored))
        )
    return roots


def bake_selected_components(start=None, end=None, preroll=0, **kwargs):
    """Bake every unique spring chain represented by the current selection.

    Select any mix of FK controls, spring nodes, component roots, and Shifter
    UI hosts. Multiple selected controls from the same chain are deduplicated.
    ``trunkUI_C0_ctl`` resolves to ``trunk_C0_root`` even though it is outside
    the chain's DAG hierarchy.
    """
    roots = _selected_component_roots()
    if len(roots) > 1 and kwargs.get("simulation_ui_plug"):
        raise SpringBakeError(
            "For multiple chains, omit simulation_ui_plug so each component's "
            "own UI intensity attribute is resolved automatically."
        )
    if len(roots) > 1 and kwargs.get("bake_layer"):
        raise SpringBakeError(
            "For multiple chains, omit bake_layer so each component receives "
            "a unique layer name."
        )

    return [
        bake_component(
            component_root=root,
            start=start,
            end=end,
            preroll=preroll,
            **kwargs
        )
        for root in roots
    ]


def bake_selected_component(start=None, end=None, preroll=0, **kwargs):
    """Backward-compatible entry point; one or many selected chains work."""
    results = bake_selected_components(start, end, preroll, **kwargs)
    return results[0] if len(results) == 1 else results


# ---------------------------------------------------------------------------
# Maya launcher entry point

_WINDOW_NAME = "rig_mGear_spring_chain_bakeWindow"
_START_FIELD = "rig_mGear_bake_springStart"
_END_FIELD = "rig_mGear_bake_springEnd"
_PREROLL_FIELD = "rig_mGear_bake_springPreroll"
_VERIFY_CHECKBOX = "rig_mGear_bake_springVerify"
_HELP_TEXT = (
    "Bakes the currently visible spring motion to FK controls on a new "
    "animation layer, then turns off the live spring.\n\n"
    "Pre-roll evaluates frames before Start so the spring can settle; those "
    "frames are not baked.\n\n"
    "Verify compares the baked controls with the captured live result and "
    "warns if they do not match.\n\n"
    "To return to the original setup, mute or delete only the new "
    "mGearSpringBake layer. Your original control animation remains on "
    "the base layer."
)


def _ui_bake_selected(*_):
    """Read the small Maya UI and bake the currently selected chain(s)."""
    start = cmds.intFieldGrp(_START_FIELD, query=True, value1=True)
    end = cmds.intFieldGrp(_END_FIELD, query=True, value1=True)
    preroll = cmds.intFieldGrp(_PREROLL_FIELD, query=True, value1=True)
    verify = cmds.checkBox(_VERIFY_CHECKBOX, query=True, value=True)

    try:
        results = bake_selected_components(
            start=start, end=end, preroll=preroll, verify=verify
        )
    except SpringBakeError as error:
        message = str(error)
        cmds.warning(message)
        cmds.confirmDialog(
            title="mGear Spring Chain Bake",
            message=message,
            button=["OK"],
            defaultButton="OK",
            icon="critical",
        )
        return None

    message = "Baked {} spring chain system(s).".format(len(results))
    print(message)
    cmds.inViewMessage(
        assistMessage="<hl>{}</hl>".format(message),
        position="midCenterTop",
        fade=True,
    )
    return results


def show():
    """Open the Maya UI expected by the rig script launcher.

    Select one or more mGear spring-chain controls or UI hosts before pressing
    *Bake selected chain(s)*. Works with standard and gravity spring chains.
    The original animator curves remain on the base layer; every baked chain
    gets its own mGearSpringBake layer.
    """
    if cmds.window(_WINDOW_NAME, exists=True):
        cmds.deleteUI(_WINDOW_NAME)

    start = int(cmds.playbackOptions(query=True, minTime=True))
    end = int(cmds.playbackOptions(query=True, maxTime=True))
    window = cmds.window(
        _WINDOW_NAME,
        title="mGear Spring Chain Bake",
        sizeable=False,
        minimizeButton=False,
        maximizeButton=False,
    )
    cmds.columnLayout(adjustableColumn=True, rowSpacing=8)
    cmds.rowLayout(numberOfColumns=2, adjustableColumn=1)
    cmds.text(
        label="Select one or more spring chain controls or UI hosts.",
        align="left",
    )
    cmds.button(label="?", width=26, height=24, annotation=_HELP_TEXT)
    cmds.setParent("..")
    cmds.separator(style="in")
    cmds.intFieldGrp(
        _START_FIELD, label="Start", numberOfFields=1, value1=start
    )
    cmds.intFieldGrp(
        _END_FIELD, label="End", numberOfFields=1, value1=end
    )
    cmds.intFieldGrp(
        _PREROLL_FIELD,
        label="Pre-roll",
        numberOfFields=1,
        value1=0,
        annotation="Frames evaluated before Start but not baked.",
    )
    cmds.checkBox(
        _VERIFY_CHECKBOX,
        label="Verify baked world transforms",
        value=True,
    )
    cmds.separator(style="in")
    cmds.button(
        label="Bake selected chain(s)",
        height=32,
        command=_ui_bake_selected,
    )
    cmds.button(label="Close", command=lambda *_: cmds.deleteUI(window))
    cmds.showWindow(window)
    return window


def main():
    """Open the UI when this file is executed directly."""
    return show()


if __name__ == "__main__":
    main()