skip to content
Compute.Ideas() Exploring the intersection between Technology and Human Mind.

Tech Art Adventures - Rubik Cube in Maya using MASH

How I glued together a Rubik’s Cube in PyMel

Introduction

I love puzzles. They serve as effective mental exercise, enhancing focus and problem-solving skills. By requiring sustained attention and cognitive effort, they help improve mental agility. They’re a straightforward way to engage your mind, making them more than just a simple pastime.

As an admirer of cubers, I decided to create my own digital Rubik’s Cube in Maya using Pymel. The objective is to have a tool that can generate n sized Rubik and provide handles to interact and solve it.

Rubik’s Cube in Maya (PyMel) - Multi series generator with interactive capability.

Creating the Rubik’s Cube

First, let’s start by creating a Python class called Builder that will hold all the necessary methods and properties to construct our Rubik’s Cube in Maya. The Builder class will contain a constructor and various methods for building, coloring, and controlling the Rubik’s Cube.

Building the Cube

The build method will be responsible for generating the initial grid of pieces for the Rubik’s Cube. It will create a MASH network in Maya, which allows for the procedural generation of objects like our Rubik’s Cube. The method sets the arrangement, grid size, and margin distance between instances, and then bakes the instances to geometry.

def build(self, _size):
        """
        Drives the order of Rubik's Cube
        construction.
        """
        self.size = _size
        radiusDiv = 1.25
        self.controlRadius = self.size / radiusDiv
        
        ## Create MASH network to generate initial grid of pieces
        instancer = 'instancer'
        cube = cmds.polyCube(w = 1, h = 1, name = f'{self.name}') 
        mashNetwork = mapi.Network()
        mashNetwork.createNetwork(name = self.name, geometry = instancer)
        
        # Use primitive cube as distribution source
        cmds.connectAttr(
            str(cube[0]) + '.outMesh',
            mashNetwork.distribute + '.inputMesh')
        
        # Arrange grid based on Rubik's Cube size
        cmds.setAttr(mashNetwork.distribute + '.arrangement', 6)
        cmds.setAttr(mashNetwork.distribute + '.gridx', _size)
        cmds.setAttr(mashNetwork.distribute + '.gridy', _size)
        cmds.setAttr(mashNetwork.distribute + '.gridz', _size)
        
        # Set margin distance between instances and center overall distribution
        amplitude = _size - 1
        cmds.setAttr(mashNetwork.distribute + '.gridAmplitudeX', amplitude)
        cmds.setAttr(mashNetwork.distribute + '.gridAmplitudeY', amplitude)
        cmds.setAttr(mashNetwork.distribute + '.gridAmplitudeZ', amplitude)
        cmds.setAttr(mashNetwork.distribute + '.centerLinearDistribution', 1)
        
        # Bake MASH instances to Geometry
        mash = cmds.ls(type = instancer)
        cmds.select(mash)
        cmds.BakeInstancerToGeometry()
        mbake.MASHbakeInstancer(False)
        cmds.CloseFrontWindow()
        
        # Cleanup 
        cmds.delete(mash)
        cmds.delete(cube)

        # Create a list of node references      
        index = 1
        query = f'{self.name}*'

        nodes = cmds.ls(query, dag=True, transforms=True)    
        for node in nodes:
            if (not self.rootParent and self.isParent(node)): 
                            newName = f'{self.name}_parent'
                            cmds.rename(node, newName)
                            self.rootParent = newName
                            continue
            
            newName = f'{self.name}_{index}'         
            cmds.rename(node, newName) 
            self.nodes.append(newName)
            index += 1    
        
        # Visuals
        # Add edge smoothness on every piece
        self.bevelAll()
        
        # Color accoridng to face
        self.color(Faces.FRONT, [0,1,0])
        self.color(Faces.BACK, [0,0,1])
        self.color(Faces.UP, [1,1,1])
        self.color(Faces.DOWN, [1,1,0])
        self.color(Faces.LEFT, [1,0.5,0.1])
        self.color(Faces.RIGHT, [1,0,0])    

        # Center World Rotate Pivot to allow coordinated rotation
        for node in self.nodes:
            cmds.xform(node, worldSpace=True, rotatePivot=[0,0,0])      

        #Check if reference display layer already exists
        layerName = 'rubik_reference_layer'
        dispLayers = cmds.ls(type = 'displayLayer')
        layerExists = False
        if dispLayers:
            for layer in dispLayers:
                if layer == layerName:
                    layerExists = True
                    break
 
        if not layerExists:
            layer = cmds.createDisplayLayer(name = layerName)
            cmds.setAttr("{}.displayType".format(layer), 2) 
            
        # Add nodes the reference layer to prevent user interaction
        cmds.editDisplayLayerMembers(layerName, self.nodes, noRecurse=True)


        # Add Controls 
        maxLenght = 0.5 + (self.size - 2) * 0.5    
        step = -maxLenght
        for i in range(0, self.size):
            self.createControls([step,0,0], 0)
            self.createControls([0,step,0], 1)
            self.createControls([0,0,step], 2)
            step += 1

        cmds.select(clear=True)
        
        # Begin tracking input selection
        self.initSelectionJob()

Beveling the Cube Pieces

To add a smooth edge to each piece, we will use the bevelAll method. This method applies a simple bevel on all the pieces in the Rubik’s Cube, with customizable segment and offset values.

def bevelAll(self): 
        """
        Apply simple bevel on all pieces.
        [Segment] and [offset] values are exposed
        for tweaking
        """
        for node in self.nodes:
            cmds.select(node)
            cmds.polyBevel3(
                            segments=self.bevelSeg,
                            offset=self.bevelOffset,
                            chamfer=True,
                            depth=1,
                            smoothingAngle=0)

Coloring the Cube

The Rubik’s Cube is known for its colorful faces, and our virtual cube is no exception. The color method applies vertex-based paint to each face, with a specified color for each of the six faces (Front, Back, Up, Down, Left, and Right).

def color(self, _face, color): 
        """
        Vertex based paint.
        [notUndoable] flag improves performance for 
        large numbers of object. This will make
        the command not undoable regardless of 
        whether undo has been enabled or not.
        """

        faces = self.selectFacesByOrientation(_face)   
        vertices = self.selectVerticesFromFace(faces)
        cmds.select(clear=True)

        for vertex in vertices:
            cmds.polyColorPerVertex(
                                    vertex,
                                    rgb=(
                                        color[0], 
                                        color[1], 
                                        color[2]), 
                                    notUndoable=True, 
                                    colorDisplayOption=True,
                                    clamped=True)
        
        for node in self.nodes:                
            cmds.setAttr(f'{node}.displayColors', 1)
        cmds.refresh()

Interacting with the Cube

Our Rubik’s Cube should not only be visually appealing but also interactive. To achieve this, we’ll create control handles that allow the user to select and rotate the Rubik’s Cube pieces.

Creating Control Handles

The createControls method generates circle controls and aligns them with the corresponding faces of the Rubik’s Cube. It also tags and renames the control handles, appending them to a list of controls for future reference.

def createControls(self, position, axis):
        """
        Create circle controls and align
        with the coresponding faces
        """
        vector = [0,0,0]
        vector[axis] = 1
        control = cmds.circle( 
            object=True,
            normal=vector,
            center=position, 
            radius=self.controlRadius)
        cmds.parent(control[0], self.rootParent)
        cmds.xform(control[0], worldSpace=True, centerPivots=True)
        rotation = [0,0,0]
        tag = f'Control_{len(self.controls)}'
        cmds.rename(control[0],tag)
        self.controls.append([tag,position,rotation,axis])

Selecting and Rotating Control Handles

The selectControlHandle and validateControlHandle methods enable users to select and rotate the control handles in the scene, while ensuring that the rotation matches a 90-degree snap in both directions.

def initSelectionJob(self):
        """
        Initialises tracking job for when a control handle is selected
        in the scene. 
        ! As of now, user is required to deselect gizmos on empty space
        before selecting next one, otherwise the selection is not recognized
        by Maya. 
        """
        if cmds.scriptJob(exists=self.selectionJob):
            cmds.scriptJob(kill=self.selectionJob, force=True)

        job = cmds.scriptJob(
            killWithScene=True, 
            ct=("SomethingSelected",
            self.selectControlHandle),
            protected=True)   
        self.selectionJob = job

def selectControlHandle(self):
        # Prevent interaction when pieces aren't set.
        if not self.interactable: return
           
        # Confirm that only single control handle is selected,
        # otherwise clear selection
        selectedControl = cmds.ls('Control*', selection=True, flatten=True)
        
        if len(selectedControl) <= 0: return     
        if len(selectedControl) > 1:
            cmds.select(all=True, clear=True)
            return
        if selectedControl[0] == self.activeControl[0]: 
            return

        self.activeControl[0] = selectedControl[0]
        matchedNodes = []   
        
        # Find selected control and collect matching pieces    
        for control in self.controls:
            if control[0] == self.activeControl[0]: 
                self.activeControl[1] = control[3]
                matchedNodes = self.matchNodes(control[1], control[3])   
                break
        
        # Unparent old selection
        if self.previousControl:
            oldSelection = cmds.listRelatives(
                self.previousControl,
                children = True,
                type='transform')

            for match in oldSelection:
                cmds.parent(match, self.rootParent)
            
        # Parent new selection
        for match in matchedNodes:
            cmds.parent(match, selectedControl[0], s=True)               
            cmds.reorder(match, front = True ) 

        # Highlight control and show context handle
        cmds.select(selectedControl[0], noExpand=True)   
        cmds.setToolTo('RotateSuperContext')
        self.previousControl = self.activeControl[0]
        
        # Begin validating Rubik's Cube position 
        self.initControlValidation()

def validateControlHandle(self):
        """
        Checks if rotation matches 90 degree snap
        in both directions. If not, hides all 
        control handles except current. 
        Limits global interaction.
        """
        rotAttribute = f'{self.activeControl[0]}.r{self.axis[self.activeControl[1]]}'
        angle = cmds.getAttr(rotAttribute)
        
        if angle == 0 or abs(angle) % 90 == 0:
            self.interactable = True
            print('Selected Control is Valid and Set')
        else:
            self.interactable = False     
            print ('Selected Control is Invalid ' + str(angle))
        
        if not self.interactable and self.visibleControls:
            self.visibleControls = False
            for control in self.controls:
                if control[0] == self.activeControl[0]: continue
                if cmds.getAttr(f'{control[0]}.visibility') == 1:
                    cmds.hide(control[0])
                    
        elif self.interactable:
            self.visibleControls = True
            for control in self.controls:
                cmds.showHidden(control[0])

Saving and Loading Cube State

Finally, to enhance the usability of our Rubik’s Cube, we’ll provide the functionality to save and load its state. The getState method saves the relevant variables, including the size, controls, and transforms, into a JSON file.

def getState(self):
        """
        Saves relevant variables
        into a .json file 
        """
        transforms = {          
            'node': [],
            'pos': [],
            'ori': []            
        }
        
        for node in self.nodes:
            position = cmds.xform(
                node, query = True,
                translation = True,
                worldSpace = True)    
            
            orientation = cmds.xform(
                node, query = True, 
                translation = True, 
                worldSpace = True) 
            transforms['node'].append(node)
            transforms['pos'].append(position)
            transforms['ori'].append(orientation)
       
        state = {
            'size': self.size,
            'controls': self.controls,
            'transforms': transforms
        }
        return state

Conclusion

In this blog post, we’ve explored how to implement a Rubik’s Cube run in Maya using pymel. With this implementation, users can create, control, and interact with a virtual Rubik’s Cube in a 3D environment. This project demonstrates the versatility and power of scripting

Feel free to download the full project from my repository.