top of page

Maya | Python | Rigging Part 4 | Add Stretch to IK

  • Writer: Max Liu
    Max Liu
  • Dec 17, 2024
  • 10 min read

The add_ik_stretch function sets up a node network to dynamically scale an IK limb so it stretches when pulled beyond its original length, and contracts or remains fixed otherwise. Here’s the overall calculation process summarized:

  1. Name and Node Setup:Generate a base name from the given side and part. Create fundamental nodes:

    • distanceBetween node (limb_dist): Measures the current distance between two locator points.

    • condition node (limb_cnd): Determines if the limb should stretch by comparing current length to its original length.

    • multiplyDivide node (stretch_mdn): Computes a ratio of the current length versus the original length.

    • spaceLocators (start_loc, end_loc): Reference points marking the start and end positions of the limb.

  2. Original Length Calculation:Determine the limb’s original total length by measuring the distances between the first and second joint, and second and third joint of the IK chain. Sum these distances to get the total original limb length.

  3. Dynamic Length Measurement:Constrain the start and end locators to appropriate controls so they move with the character’s rig. Feed their world positions into the distanceBetween node to constantly measure the current limb length as the rig moves.

  4. Ratio Computation (Stretch Factor):Connect the measured distance to the multiplyDivide node and divide by the original total length. This ratio represents how much the limb should scale if pulled longer than its base length.

  5. Stretch Condition Check:The condition node compares the current distance to the original length:

    • If current distance > original length, the node uses the computed ratio to stretch the limb.

    • Otherwise, the limb does not stretch (remains at original length).

  6. User Controls and Fine-Tuning:Add attributes to the world control for:

    • A master “stretch” slider (0 to 1) that blends between no stretch and full stretch.

    • Separate upper and lower segment scaling controls so the artist can tweak how the stretch distributes across the limb.

    Use a blendTwoAttr node and plusMinusAverage nodes to incorporate these user-defined adjustments into the final scaling values.

  7. Apply Final Scaling:Feed the resulting scaling values into the joint scale attributes of the IK chain along the specified axis. This ensures that when the limb is pulled beyond its original length, it lengthens smoothly; when compressed or at rest, it remains at its default size.

  8. Return Useful Information:Finally, the function returns a dictionary containing references to the created locators and key nodes, allowing other parts of the rigging script to access or further modify these elements.

In essence, the function automates the setup to measure, compute, and apply a dynamic stretch ratio to an IK limb, controlled by user-defined parameters and conditions.




from ssl import DefaultVerifyPaths

import maya.cmds as cmds
import math

    # Joint guides and aliases for a 3-joint limb (e.g. shoulder, elbow, wrist)
def limb(side='l', part='arm',
    joint_list = ['l_shoulder_gde_JNT', 'l_elbow_gde_JNT', 'l_wrist_gde_JNT'],
    alias_list = ['shoulder', 'elbow', 'wrist'],
    pole_vector = 'l_shoulder_pv_gde_LOC', remove_guides = False, add_stretch = True, color_dict = False, primary_axis = 'X', up_axis = 'Y'):

    if len(joint_list) != 3:
        cmds.error('Must provide three guides to build three joint limb.')

    if len(alias_list) != 3:
        cmds.error('Must provide three aliases, one for each joint.')

    if not pole_vector:
        cmds.error('Must provide a pole vector guide.')

    primaryAxis = define_axis(primary_axis)

    base_name = side + '_' + part

    # Create different chain types: IK, FK, and Bind
    ik_chain = create_chain(side, joint_list, alias_list, 'IK')
    fk_chain = create_chain(side, joint_list, alias_list, 'FK')
    bind_chain = create_chain(side, joint_list, alias_list, 'bind')

    # optimizae conrtol size by using a fraction of the start to end length.
    r = distance_between(fk_chain[0], fk_chain[-1]) / float(5)

    ########################################
    # CREATE THE FK CONTROL
    ########################################

    # create FK controls and connect to fk joint chain
    fk_ctrls = []

    for i, alias in enumerate(alias_list):
        # create fk controls
        ctrl = cmds.circle(radius=r, normal=primaryAxis, degree=3, name='{}_{}_FK_CTRL'.format(side, alias))[0]
        
        # align control to joint
        # Note: It's generally better to align before parenting to avoid double transforms.
        align_lras(snap_align=True, sel=[ctrl, fk_chain[i]])
        
        if i != 0:
            # If not the first control, parent it under the previous control
            cmds.parent(ctrl, par)
        
        # define parent control for the next iteration
        par = ctrl

        if i == 0:
            # Since there's no offset group now, this can just refer to the first FK ctrl
            fk_top_grp = ctrl

        # connect control to joint
        cmds.pointConstraint(ctrl, fk_chain[i], mo=False)
        cmds.connectAttr(ctrl + ".rotate", fk_chain[i] + ".rotate")
        
        fk_ctrls.append(ctrl)

    ########################################
    # CREATE THE WORLD IK CONTROL
    ########################################
    ## Create a main/world IK control circle. This is often a higher-level control for the IK system.
    world_ctrl = cmds.circle(radius=r * 1.2, normal=primaryAxis, degree=1, sections=4, constructionHistory=False, name=base_name + '_IK_CTRL')[0]
    
    cmds.setAttr(world_ctrl + '.rotate' + primary_axis[-1], 45)
    a_to_b(is_trans=True, is_rot=False, sel=[world_ctrl, ik_chain[-1]], freeze=True)
    local_ctrl = cmds.circle(radius=r, normal=primaryAxis, degree=1, sections=4, constructionHistory=False, name=base_name + '_local_IK_CTRL')[0]
    cmds.setAttr(local_ctrl + '.rotate' + primary_axis[-1], 45)
    cmds.makeIdentity(local_ctrl, apply=True, rotate=True)
    local_off = align_lras(snap_align=True, sel=[local_ctrl, ik_chain[-1]])
    cmds.parent(local_off, world_ctrl)

    loc_points = [
    [0.0, 0.0, 0.0], [0.0, -1.0, 0.0],
    [0.0, 0.0, 0.0], [1.0,  0.0, 0.0],
    [0.0, 0.0, 0.0], [-1.0, 0.0, 0.0],
    [0.0, 0.0, 0.0], [0.0,  0.0, 1.0],
    [0.0, 0.0, 0.0], [0.0,  0.0,-1.0]]

    pv_ctrl = curve_control(loc_points, name=base_name + '_PV_CTRL')
    cmds.setAttr(pv_ctrl + '.scale', r * 0.25, r * 0.25, r * 0.25)
    a_to_b(is_trans=True, is_rot=False, sel=[pv_ctrl, pole_vector], freeze=True)
    base_ctrl = cmds.circle(
    radius=r * 1.2,
    normal=primaryAxis,
    degree=1,
    sections=4,
    constructionHistory=False,
    name='{}_{}_IK_CTRL'.format(side, alias_list[0])
    )[0]

    # Rotate the base control for consistency
    cmds.setAttr(base_ctrl + '.rotate' + primary_axis[-1], 45)

    # Align the base_ctrl translation to the start joint of the IK chain and freeze
    a_to_b(is_trans=True, is_rot=False, sel=[base_ctrl, ik_chain[0]], freeze=True)

    # Parent constrain the first joint of the IK chain to the base_ctrl to maintain its position
    cmds.parentConstraint(base_ctrl, ik_chain[0], maintainOffset=True)


    ########################################
    # CREATE THE IK HANDLE
    ########################################

    # Create the actual IK handle, the first item return from the list is the ikh(IK handle). 
    ikh = cmds.ikHandle(
        name=base_name + '_IKH',
        startJoint=ik_chain[0],
        endEffector=ik_chain[-1],
        sticky='sticky',
        solver='ikRPsolver',
        setupForRPsolver=True
    )[0]

    # Parent constrain the IK handle to the local_ctrl (so moving the local_ctrl moves the IK)
    cmds.parentConstraint(local_ctrl, ikh, maintainOffset=True)

    # Add a pole vector constraint so pv_ctrl controls the knee/elbow direction of the IK chain
    cmds.poleVectorConstraint(pv_ctrl, ikh)



    ########################################
    # CREATE THE IF FK Blend Control
    ########################################

    #Create puls shape.
        # Example points for a settings control curve (plus shape)
    plus_points = [
        [-0.333,  0.333,  0.0],
        [-0.333,  1.0,    0.0],
        [ 0.333,  1.0,    0.0],
        [ 0.333,  0.333,  0.0],
        [ 1.0,    0.333,  0.0],
        [ 1.0,   -0.333,  0.0],
        [ 0.333, -0.333,  0.0],
        [ 0.333, -1.0,    0.0],
        [-0.333, -1.0,    0.0],
        [-0.333, -0.333,  0.0],
        [-1.0,   -0.333,  0.0],
        [-1.0,    0.333,  0.0],
        [-0.333,  0.333,  0.0]  # Last point repeats the first to close the shape
    ]

    settings_ctrl = curve_control(point_list = plus_points, name = base_name + '_settings_CTRL')
    settings_off = align_lras(snap_align=True, sel = [settings_ctrl, bind_chain[-1]])
    cmds.setAttr(settings_ctrl + '.scale', r * 0.25, r * 0.25, r * 0.25)
    #offset the plus sign control to translateY direction.
    if up_axis[0] == '-':
        cmds.setAttr(settings_ctrl + '.translate' + up_axis[-1], r * -1.5)
    else: 
        cmds.setAttr(settings_ctrl + '.translate' + up_axis[-1], r * 1.5)
    #makeIdentity() Freeze/resets the object’s transforms so that the current position, rotation, and scale become its new "zeroed" state.
    #After this, the control’s channels will read as zeroed out (0 for translate/rotate, 1 for scale), providing a clean starting point for animation.
    cmds.makeIdentity(settings_ctrl, apply = True, translate = True, rotate = True, scale = True, normal = False)
    #A parent constraint is applied so that if the limb (bind joint) moves, the settings control stays in a consistent relationship to it. maintainOffset=True means the control stays where it is, just following the joint’s movements, rather than snapping directly onto the joint.
    cmds.parentConstraint(bind_chain[-1], settings_ctrl, maintainOffset = True)
    #This adds a custom attribute fkIk to the settings control, allowing the user to blend between FK (value of 1) and IK (value of 0) modes. Ranging from 0 to 1 makes for a simple slider the animator can adjust.
    cmds.addAttr(settings_ctrl, longName='fkIk', attributeType='double', minValue=0, maxValue=1, 
                defaultValue=1, keyable=True)   

    blend_chains(base_name, ik_chain, fk_chain, bind_chain)

    if add_stretch:
        ik_stretch = add_ik_stretch(side, part, ik_chain, base_ctrl, local_ctrl, world_ctrl, primary_axis)

# implement blend function utilizing blendcolors node.
def blend_chains(base_name, ik_chain, fk_chain, bind_chain):
    for ik, fk, bind in zip(ik_chain, fk_chain, bind_chain):
        for attr in ['translate', 'rotate', 'scale']:
            bcn = cmds.createNode('blendColors', name = bind.replace('bind_JNT', attr + '_BCN'))
            #connect ik.attr and bcm.color1
            cmds.connectAttr(ik + '.' + attr, bcn + '.color1')
            cmds.connectAttr(fk + '.' + attr, bcn + '.color2')
            cmds.connectAttr(base_name + '_settings_CTRL.fkIk', bcn + '.blender')
            cmds.connectAttr(bcn + '.output', bind + '.' + attr)
            

    
def create_chain(side, joint_list, alias_list, suffix):
    chain = []
    jnt = None
    for j, a in zip(joint_list, alias_list):
        # For the first joint, there is no parent
        if j == joint_list[0]:
            par = None
        else:
            par = jnt

        # Create a joint with a naming convention side_alias_suffix
        jnt = cmds.joint(par, n='{}_{}_{}.JNT'.format(side, a, suffix))
        
        # Matching the transform from the guide joint to the created joint, and freezing transforms
        match_and_freeze_transform(j, jnt)
        
        chain.append(jnt)

    return chain

def define_axis(axis):
    #t checks the last character of the axis string. If it's 'X', it sets vector_axis to (1,0,0). For 'Y', (0,1,0), and for 'Z', (0,0,1).
    if axis[-1] == 'X':
        vector_axis = (1, 0, 0)
    elif axis[-1] == 'Y':
        vector_axis = (0, 1, 0)
    elif axis[-1] == 'Z':
        vector_axis = (0, 0, 1)
    else:
        cmds.error('Must provide either X, Y, or Z for the axis.')

    #It then checks if there is a '-' character anywhere in the axis string. If so, it multiplies each component of the vector by -1. For example, if axis = '-X', we start with (1,0,0) and flip it to (-1,0,0)  
    if '-' in axis:
        vector_axis = tuple(va * -1 for va in vector_axis)
    return vector_axis

def match_and_freeze_transform(source, target):
    """
    Matches the world-space transform (translation, rotation, scale) of 'target'
    to that of 'source' and then freezes transforms on the 'target'.
    """

    # Query the source transform matrix
    source_matrix = cmds.xform(source, query=True, worldSpace=True, matrix=True)
    
    # Apply the source's matrix to the target
    cmds.xform(target, worldSpace=True, matrix=source_matrix)
    
    # Freeze transformations on the target joint:
    # This applies the current transformations (translation, rotation, scale) 
    # as the object's default and resets the transforms to zero-out in local space.
    cmds.makeIdentity(target, apply=True, translate=True, rotate=True, scale=True, normal=False)

def align_lras(snap_align=False, sel=[]):
    # `sel` should be [object_to_align, target_object]
    if len(sel) < 2:
        cmds.error("align_lras requires two objects in 'sel': [object_to_align, target].")

    obj, target = sel
    
    if snap_align:
        # Create a temporary constraint to snap obj to target, then delete it
        tmp_const = cmds.parentConstraint(target, obj, mo=False)
        cmds.delete(tmp_const)

        # Freeze transforms after alignment
        cmds.makeIdentity(obj, apply=True, t=1, r=1, s=1, n=0)

    return obj

def distance_between(node_a, node_b):
    # Query the world space positions of the rotate pivot of the nodes
    point_a = cmds.xform(node_a, query=True, worldSpace=True, rotatePivot=True)
    point_b = cmds.xform(node_b, query=True, worldSpace=True, rotatePivot=True)
    
    # Calculate the Euclidean distance between the two points
    dist = math.sqrt(sum([pow((b - a), 2) for b, a in zip(point_b, point_a)]))
    return dist

def curve_control(point_list, name, degree = 1):
    crv = cmds.curve(degree = degree, editPoint = point_list, name = name)
    shp = cmds.listRelatives(crv, shapes = True)[0]
    cmds.rename(shp, crv + 'Shape')
    return crv

def a_to_b(is_trans=True, is_rot=True, sel=[], freeze=False):
    if len(sel) < 2:
        cmds.error("a_to_b requires two objects in 'sel': [object_to_align, reference_object].")

    obj, ref = sel

    # Determine which channels to skip
    skipTranslate = []
    skipRotate = []

    if not is_trans:
        skipTranslate = ['x','y','z']
    if not is_rot:
        skipRotate = ['x','y','z']

    # Use a parent constraint to align obj to ref
    temp_constraint = cmds.parentConstraint(
        ref, obj, mo=False,
        skipTranslate=skipTranslate,
        skipRotate=skipRotate
    )
    cmds.delete(temp_constraint)

    # Freeze transforms if requested
    if freeze:
        cmds.makeIdentity(obj, apply=True, t=True, r=True, s=True, n=0)

    return obj

def add_ik_stretch(side, part, ik_chain, base_ctrl, local_ctrl, world_ctrl, primary_axis):
    # Constructing a base name for all created nodes using the side and part strings
    base_name = side + '_' + part

    # ------------------------------------------------------------------------
    # Create nodes required for measuring and controlling the stretch behavior
    # ------------------------------------------------------------------------

    # Create a distanceBetween node to measure the distance between two points (endpoints)
    # This node calculates the distance between two input points.
    limb_dist = cmds.createNode('distanceBetween', name=base_name + '_DST')
    
    # Create a condition node to compare distances and decide if stretching should occur
    # This node will check if the current distance is greater than the original total length.
    limb_cnd = cmds.createNode('condition', name=base_name + '_CND')
    
    # Create spaceLocators which serve as reference points to measure the limb length
    # The start locator and end locator represent the initial and final positions to measure from.
    start_loc = cmds.spaceLocator(name=base_name + '_start_LOC')[0]
    end_loc = cmds.spaceLocator(name=base_name + '_end_LOC')[0]
    
    # Create a multiplyDivide node to compute the scaling ratio (distance / original_length)
    # This will give us a multiplier to adjust the limb length.
    stretch_mdn = cmds.createNode('multiplyDivide', name=base_name + '_stretch_MDN')

    # ------------------------------------------------------------------------
    # Calculate the original limb length
    # ------------------------------------------------------------------------
    # Assuming you have a custom function `distance_between` that returns the distance
    # between two given joints or transforms in the IK chain.
    
    # Measure distance between the first and second joint of the IK chain
    length_a = distance_between(ik_chain[0], ik_chain[1])
    
    # Measure distance between the second and third joint of the IK chain
    length_b = distance_between(ik_chain[1], ik_chain[2])
    
    # The total original length of the limb (sum of the two segments)
    total_length = length_a + length_b

    # ------------------------------------------------------------------------
    # Set up constraints for measuring the limb's dynamic length
    # ------------------------------------------------------------------------
    # The start_loc is constrained to the base controller so it moves with it.
    cmds.pointConstraint(base_ctrl, start_loc, maintainOffset=False)
    # The end_loc is constrained to the local controller at the other end of the limb.
    cmds.pointConstraint(local_ctrl, end_loc, maintainOffset=False)

    # Connect the start and end locator worldMatrix attributes to the distanceBetween node
    # so the distance node always knows where these locators are in world space.
    cmds.connectAttr(start_loc + '.worldMatrix[0]', limb_dist + '.inMatrix1')
    cmds.connectAttr(end_loc + '.worldMatrix[0]', limb_dist + '.inMatrix2')

    # ------------------------------------------------------------------------
    # Calculate the length ratio (current_length / original_length)
    # ------------------------------------------------------------------------
    # Connect the measured distance from limb_dist to the multiplyDivide node input1X
    cmds.connectAttr(limb_dist + '.distance', stretch_mdn + '.input1X')
    
    # Set the original total length as input2X
    cmds.setAttr(stretch_mdn + '.input2X', total_length)
    
    # Set the operation of multiplyDivide node to "Divide" (2)
    # Now stretch_mdn.outputX = current_length / original_length
    cmds.setAttr(stretch_mdn + '.operation', 2)

    # ------------------------------------------------------------------------
    # Use the condition node to decide whether to stretch
    # ------------------------------------------------------------------------
    # Connect the current distance as the firstTerm of the condition
    cmds.connectAttr(limb_dist + '.distance', limb_cnd + '.firstTerm')
    
    # If the current distance is greater than the original, use the ratio as output
    cmds.connectAttr(stretch_mdn + '.outputX', limb_cnd + '.colorIfTrueR')
    
    # Set the secondTerm to the original total length to compare with current distance
    cmds.setAttr(limb_cnd + '.secondTerm', total_length)
    
    # Set the condition operation to "Greater Than" (3) 
    # If current distance > total_length => stretch occurs
    cmds.setAttr(limb_cnd + '.operation', 3)

    # ------------------------------------------------------------------------
    # Add attributes to the world_ctrl to turn stretch on/off and adjust upper/lower segments
    # ------------------------------------------------------------------------
    # Add a 'stretch' attribute to enable or disable stretching (0 to 1)
    cmds.addAttr(world_ctrl, attributeType='double', min=0, max=1, defaultValue=1, keyable=True, longName='stretch')
    
    # Dynamically name the up and lo attributes based on the 'part' name provided
    up_name = 'up' + part.title()  # e.g. if part = 'arm', up_name = 'upArm'
    lo_name = 'lo' + part.title()  # e.g. if part = 'arm', lo_name = 'loArm'
    
    # Add upper segment scaling attribute
    cmds.addAttr(world_ctrl, attributeType='double', defaultValue=1, keyable=True, longName=up_name)
    # Add lower segment scaling attribute
    cmds.addAttr(world_ctrl, attributeType='double', defaultValue=1, keyable=True, longName=lo_name)

    # Create a blendTwoAttr node to blend between no stretch and full stretch
    # The blendTwoAttr allows us to smoothly transition the scaling factor.
    stretch_bta = cmds.createNode('blendTwoAttr', name=base_name + '_stretch_BTA')

    cmds.setAttr(stretch_bta + '.input[0]', 1)
    cmds.connectAttr(limb_cnd + '.outColorR', stretch_bta + '.input[1]')
    
    # Connect the world_ctrl.stretch attribute to the blender of stretch_bta
    cmds.connectAttr(world_ctrl + '.stretch', stretch_bta + '.attributesBlender')


    # Create plusMinusAverage nodes for upper and lower segments to handle the scaling math
    up_pma = cmds.createNode('plusMinusAverage', name=up_name + '_PMA')
    lo_pma = cmds.createNode('plusMinusAverage', name=lo_name + '_PMA')

    # Connect the upper/lower attributes from the world_ctrl to the respective plusMinusAverage nodes
    cmds.connectAttr(world_ctrl + '.' + up_name, up_pma + '.input1D[0]')
    cmds.connectAttr(world_ctrl + '.' + lo_name, lo_pma + '.input1D[0]')

    # Connect the stretch blend output to these nodes so they consider the stretching factor
    cmds.connectAttr(stretch_bta + '.output', up_pma + '.input1D[1]')
    cmds.connectAttr(stretch_bta + '.output', lo_pma + '.input1D[1]')

    # Set negative inputs to these nodes to ensure correct scaling math (subtract 1 to get desired ratio)
    cmds.setAttr(up_pma + '.input1D[2]', -1)
    cmds.setAttr(lo_pma + '.input1D[2]', -1)

    # Finally, connect the output of the plusMinusAverage nodes to the actual joints in the IK chain
    # This will scale them along the specified primary axis, effectively stretching or compressing the limb.
    cmds.connectAttr(up_pma + '.output1D', ik_chain[0] + '.scale' + primary_axis[-1])
    cmds.connectAttr(lo_pma + '.output1D', ik_chain[1] + '.scale' + primary_axis[-1])

    # ------------------------------------------------------------------------
    # Return a dictionary of useful nodes and values
    # This can be used by other functions for further adjustments or cleanups
    # ------------------------------------------------------------------------
    return_dict = {
        'measure_locs': [start_loc, end_loc],
        'length_total': total_length,
        'mdn': stretch_mdn,
        'cnd': limb_cnd
    }

    return return_dict


limb()

Comments


bottom of page