Ben Traje
← Back to maya

Maya Rigging Pipeline: Export and Diff Scene Hierarchies with Python

09 Sep 26 (1d ago)

When working on complex character rigs across multiple iterations, tracking subtle hierarchy changes is a massive pain point. A joint gets reparented by accident, a constraint is stripped out, or a limb node gets renamed—and you only discover it hours later when your skinning breaks or the animation export pipeline throws errors.

A solid way to bulletproof your rigging pipeline is to serialize your DAG (Directed Acyclic Graph) hierarchy into JSON snapshots and run automated structural diffs between versions.

Here is a two-part Python toolkit for Autodesk Maya: an export script to capture your hierarchy (whole scene or specific limb chains), and a comparison script that catches added/deleted nodes, reparented joints, and type mismatches.

Step 1: Exporting the Hierarchy to JSON

The first script crawls your Maya scene (or a specific branch starting from a node like "root") and exports the hierarchy to JSON.

It specifically:

  • Filters out shape nodes so you only track transform/joint hierarchies.
  • Resolves full DAG paths (|group|spine_01|spine_02) to prevent ambiguity from duplicate short names.
  • Records immediate parents, child arrays, and Maya node types.
import json
import os
import maya.cmds as cmds

def export_maya_hierarchy(output_path, root_node=None):
    """
    Serializes Maya DAG transform/joint hierarchy to a clean JSON file.
    
    :param output_path: Full file path where the JSON will be saved.
    :param root_node: Optional string. If provided, exports only this node 
                      and its descendants. If None, exports the entire scene.
    """
    # 1. Validate output directory
    out_dir = os.path.dirname(output_path)
    if out_dir and not os.path.exists(out_dir):
        try:
            os.makedirs(out_dir)
        except OSError as e:
            cmds.error(f"Cannot create directory {out_dir}: {e}")
            return

    # 2. Gather target DAG nodes
    if root_node:
        if not cmds.objExists(root_node):
            cmds.error(f"Specified root node '{root_node}' does not exist.")
            return
        
        # Get unique long path for root
        parent_long_path = cmds.ls(root_node, long=True)[0]
        descendants = cmds.listRelatives(parent_long_path, allDescendents=True, fullPath=True) or []
        target_nodes = [parent_long_path] + descendants
        print(f"Exporting hierarchy branch under: {root_node}...")
    else:
        target_nodes = cmds.ls(dag=True, long=True) or []
        print("Exporting complete scene hierarchy...")

    flat_hierarchy = []

    for node in target_nodes:
        # Ignore shape nodes to keep rig hierarchy data clean
        if cmds.nodeType(node, isTypeName='shape'):
            continue

        # Fetch immediate parent (using full DAG path)
        parent_list = cmds.listRelatives(node, parent=True, fullPath=True)
        parent_name = parent_list[0] if parent_list else None

        # Fetch immediate children and exclude shape nodes
        children_list = cmds.listRelatives(node, children=True, fullPath=True) or []
        filtered_children = [
            child for child in children_list 
            if not cmds.nodeType(child, isTypeName='shape')
        ]

        # Structure node data
        node_data = {
            "name": node.split("|")[-1],  # Short display name
            "full_path": node,             # Unique DAG path
            "type": cmds.nodeType(node),
            "parent": parent_name,
            "children": filtered_children
        }
        flat_hierarchy.append(node_data)

    # 3. Write out JSON
    try:
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(flat_hierarchy, f, indent=4)
        print(f"[Success] Exported {len(flat_hierarchy)} nodes to: {output_path}")
    except IOError as err:
        cmds.error(f"Failed to write JSON output: {err}")


# --- Execution Example ---
# Export just the skeletal rig branch
export_maya_hierarchy(
    output_path=r"D:\rig_checkpoints\character_v01.json", 
    root_node="root"
)

Step 2: Running a Structural Diff Between Versions

Once you have snapshots of two rig versions (v01 and v02), run the comparison script below. This can run directly in the Maya Script Editor or in standard standalone Python (no maya.cmds required to parse the JSON files).

Key Features:

  • Toggle Short Names vs. Full Paths: Use use_simple_name=True if you only care about clean joint names (e.g. L_wrist_jnt), but drop to False if your rig has duplicated names across different groups.
  • Filter Constraints: Rigs frequently bake or dynamically create constraints. Enabling exclude_constraints=True strips out nodes like parentConstraint and orientConstraint so you can focus strictly on joint and controller structural flow.
import json
import os

def compare_maya_hierarchies(old_json_path, new_json_path, use_simple_name=False, exclude_constraints=True):
    """
    Compares two Maya hierarchy JSON exports and reports structural differences.
    """
    try:
        with open(old_json_path, 'r', encoding='utf-8') as f:
            old_data = json.load(f)
        with open(new_json_path, 'r', encoding='utf-8') as f:
            new_data = json.load(f)
    except IOError as err:
        print(f"File loading error: {err}")
        return

    constraint_types = {
        "parentConstraint", "pointConstraint", "orientConstraint", 
        "scaleConstraint", "aimConstraint", "poleVectorConstraint"
    }

    def sanitize_dataset(raw_list):
        mapped = {}
        for item in raw_list:
            if exclude_constraints and item["type"] in constraint_types:
                continue

            lookup_key = item["name"] if use_simple_name else item["full_path"]

            if use_simple_name and lookup_key in mapped:
                print(f"[Warning] Non-unique short name detected: '{lookup_key}'. "
                      f"Consider running with use_simple_name=False for precise matching.")

            mapped[lookup_key] = item
        return mapped

    old_dict = sanitize_dataset(old_data)
    new_dict = sanitize_dataset(new_data)

    old_keys = set(old_dict.keys())
    new_keys = set(new_dict.keys())

    added_nodes = new_keys - old_keys
    deleted_nodes = old_keys - new_keys
    shared_nodes = old_keys & new_keys

    type_changes = {}
    parent_changes = {}
    child_changes = {}

    for key in shared_nodes:
        old_item = old_dict[key]
        new_item = new_dict[key]

        # 1. Detect Node Type Alterations
        if old_item["type"] != new_item["type"]:
            type_changes[key] = {
                "old": old_item["type"],
                "new": new_item["type"]
            }

        # 2. Detect Reparenting
        # Strip long paths down to short names if simple matching is enabled
        old_p = old_item["parent"]
        new_p = new_item["parent"]
        if use_simple_name:
            old_p = old_p.split("|")[-1] if old_p else None
            new_p = new_p.split("|")[-1] if new_p else None

        if old_p != new_p:
            parent_changes[key] = {"old": old_p, "new": new_p}

        # 3. Detect Child Additions / Removals
        old_c = set(old_item["children"])
        new_c = set(new_item["children"])

        if use_simple_name:
            old_c = {c.split("|")[-1] for c in old_c}
            new_c = {c.split("|")[-1] for c in new_c}

        if old_c != new_c:
            gained = list(new_c - old_c)
            lost = list(old_c - new_c)
            if gained or lost:
                child_changes[key] = {"gained": gained, "lost": lost}

    # --- Diff Output ---
    print("\n" + "=" * 65)
    print(" MAYA HIERARCHY AUDIT REPORT ".center(65, "="))
    print(f" Mode: {'Short Name' if use_simple_name else 'Full DAG Path'} | "
          f"Ignore Constraints: {exclude_constraints}")
    print("=" * 65)

    print(f"\n[+] ADDED NODES ({len(added_nodes)}):")
    for k in sorted(added_nodes):
        print(f"  + {k} ({new_dict[k]['type']})")

    print(f"\n[-] DELETED NODES ({len(deleted_nodes)}):")
    for k in sorted(deleted_nodes):
        print(f"  - {k} ({old_dict[k]['type']})")

    print(f"\n[⚙] TYPE MISMATCHES ({len(type_changes)}):")
    for k, diff in sorted(type_changes.items()):
        print(f"  * {k}: {diff['old']} -> {diff['new']}")

    print(f"\n[⇄] REPARENTED NODES ({len(parent_changes)}):")
    for k, diff in sorted(parent_changes.items()):
        print(f"  * {k}:")
        print(f"      Was: {diff['old']}")
        print(f"      Now: {diff['new']}")

    print(f"\n[☍] MODIFIED CHILD BRANCHES ({len(child_changes)}):")
    for k, diff in sorted(child_changes.items()):
        print(f"  * {k}:")
        if diff["gained"]:
            print(f"      + Gained: {diff['gained']}")
        if diff["lost"]:
            print(f"      - Lost:   {diff['lost']}")

    print("\n" + "=" * 65 + "\n")


# --- Execution Example ---
compare_maya_hierarchies(
    old_json_path=r"D:\rig_checkpoints\character_v01.json",
    new_json_path=r"D:\rig_checkpoints\character_v02.json",
    use_simple_name=True,
    exclude_constraints=True
)

Why DAG Long Paths Matter in Rigging Pipelines

In production character rigs, non-unique names happen constantly—especially in mirrored setups or standard multi-joint appendages (like having multiple instances of nodes named offset_grp, ik_ctrl, or driver_jnt across left and right limbs).

When building automated validation tools:

  • Always use cmds.ls(..., long=True) and cmds.listRelatives(..., fullPath=True) to record explicit graph topology without collisions.
  • Only use use_simple_name=True when running quick sanity checks where naming conventions guarantee uniqueness across the skeleton.