Search code examples
pythonmayapyside2

Perform modifications in the scene and update custom window from a QThread in Maya


Context

I'm creating a PySide2 tool running in Maya. The tool is executing a lot of long tasks, some modifying the scene (cleaning tasks), some creating files (exporting tasks).

Because this is a long task, I'd like to display feedback (progress bar) while it's running.

Problems

  • Unfortunately, so far, the whole UI does not seem to be updated during the executing.
  • Also, because I had odd behaviors (Maya freezing forever) in the real code, I'm guessing this is not a safe use of threads.

Example code

Here is a simplified bit of code showing where I am so far. Is this the right way to use QThread? I'm from a CG Artist background, not a professional programmer, so I'm probably misusing or misunderstanding the concepts I'm trying to use (threads, PySide...)

import time

from PySide2.QtGui import *
from PySide2.QtCore import *
from PySide2.QtWidgets import *

import maya.cmds as cmds


class Application(object):
    def __init__(self):
        self.view = View(self)

    def do_something(self, callback):
        start = int(cmds.playbackOptions(q=True, min=True))
        end = int(cmds.playbackOptions(q=True, max=True))

        # First operation
        for frame in xrange(start, end + 1):
            cmds.currentTime(frame, edit=True)
            # Export ...
        callback(33)
        time.sleep(1)

        # Second operation
        for frame in xrange(start, end + 1):
            cmds.currentTime(frame, edit=True)
            # Export ...
        callback(66)
        time.sleep(1)

        # Third operation
        for frame in xrange(start, end + 1):
            cmds.currentTime(frame, edit=True)
            # Export ...
        callback(100)
        time.sleep(1)


class View(QWidget):
    def __init__(self, controller):
        super(View, self).__init__()
        self.controller = controller
        self.thread = None
        
        self.setLayout(QVBoxLayout())
        
        self.progress = QLabel()
        self.layout().addWidget(self.progress)

        self.button = QPushButton('Do something')
        self.layout().addWidget(self.button)
        
        self.button.clicked.connect(self.do_something)
        
        self.show()
        
    def do_something(self):
        self.thread = DoSomethingThread(self.controller)
        self.thread.updated.connect(lambda progress: self.progress.setText(str(progress) + '%'))
        self.thread.run()
    
    
class DoSomethingThread(QThread):
    completed = Signal()
    updated = Signal(int)

    def __init__(self, controller, parent=None):
        super(DoSomethingThread, self).__init__(parent)
        self.controller = controller

    def run(self):
        self.controller.do_something(self.update_progress)
        self.completed.emit()
        
    def update_progress(self, progress):
        self.updated.emit(int(progress))
        
app = Application()

Solution

  • Threads are difficult to use correctly in Maya Python (you can see this from the number of questions listed here)

    Generally there are two hard rules to observe:

    1. all work that touches the Maya scene (say selecting or moving an object) has to happen in the main thread
    2. all work that touches Maya GUI also has to happen in the main thread.

    "main thread" here is the thread you get when you run a script from the listener, not on you're creating for yourself

    This obviously makes a lot of things hard to do. Generally a solution will involve the a controlling operation running on the main thread while other work that does not touch Maya GUI or scene objects is happening elsewhere. A thread-safe container (like a python Queue can be used to move completed work out of a worker thread into a place where the main thread can get to it safely, or you can use QT signals to safely trigger work in the main thread.... all of which is a bit tricky if you're not far along in your programming career.

    The good news is -- if all the work you want to do in Maya is in the scene you aren't losing much by not having threads. Unless the work is basically non-Maya work -- like grabbing data of the web using an HTTP request, or writing a non-Maya file to disk, or something else that does not deal with Maya-specific data -- adding threads won't get you any additional performance. It looks like your example is advancing the time line, doing work, and then trying to update a PySide GUI. For that you don't really need threads at all (you also don't need a separate QApplication -- Maya is already a QApplication)

    Here's a really dumb example.

    from PySide2.QtCore import *
    from PySide2.QtGui import *
    from PySide2.QtWidgets import *
    import maya.cmds as cmds
    
    class DumbWindow(QWidget):
    
        def __init__(self):
            super(DumbWindow, self).__init__()
            
            #get the maya app
            maya_app = QCoreApplication.instance()
            
            # find the main window for a parent
            for widget in maya_app.topLevelWidgets():
                if 'TmainWindow' in widget.metaObject().className():
                    self.setParent(widget)
                    break
                    
            self.setWindowTitle("Hello World")
            self.setWindowFlags(Qt.Window)
            
            self.layout = QVBoxLayout()
            self.setLayout(self.layout)
            
            start_button = QPushButton('Start', self)
            stop_button = QPushButton('Stop', self)
            self.layout.addWidget(start_button)
            self.layout.addWidget(stop_button)
            
            self.should_cancel = False
            self.operation = None
            self.job = None
    
            # hook up the buttons
            start_button.clicked.connect(self.start)
            stop_button.clicked.connect(self.stop)
    
    
        def start(self):
            '''kicks off the work in 'this_is_the_work''' 
            self.operation = self.this_is_the_work()
            self.should_cancel = False
            self.job = cmds.scriptJob(ie=self.this_makes_it_tick)
                
            
        def stop(self):
            ''' cancel before the next step'''
            self.should_cancel = True
    
        def this_is_the_work(self):
            print "--- started ---"        
            for frame in range(100):
                cmds.currentTime(frame, edit=True)
                yield "advanced", frame
            
            print "--- DONE ----"
    
        def bail(self):
            self.operation = None
            def kill_my_job():
                cmds.scriptJob(k=self.job)
                print "job killed"
            
            cmds.scriptJob(ie = kill_my_job, runOnce=True)
    
        def this_makes_it_tick(self):
            '''
            this is called whenever Maya is idle and thie
            '''
    
            # not started yet
            if not self.operation:
                return
    
            # user asked to cancel
            if self.should_cancel:
                print "cancelling"
                self.bail()
                return            
    
            try:
                # do one step.  Here's where you can update the 
                # gui if you need to 
                result =   next(self.operation)
                print result
                # example GUI update
                self.setWindowTitle("frame %i" % result[-1])
            except StopIteration:
                # no more stpes, we're done
                print "completed"
                self.bail()
            except Exception as e:
                print "oops", e
                self.bail()
     
             
    
    test = DumbWindow()
    test.show()
    

    Hitting start creates a maya scriptJob that will try to run whatever operation is in the function called this_is_the_work(). It will run to the next yield statement and then check to make sure the user hasn't asked to cancel the job. Between yields Maya will be busy (just as it would if you entered some lines in the listener) but if you're interacting with Maya when a yield comes up, the script will wait for you instead. This allows for safe user interaction without a separate thread, though of course it's not as smooth as a completely separate thread either.

    You'll notice that this kicks off a second scriptJob in the bail() method -- that's because a scriptJob can't kill itself, so we create another one which will run during the next idle event and kill the one we don't want.

    This trick is basically how most of the Maya's MEL-based UI works under the hood -- if you run cmds.scriptJob(lj=True) in the listener you'll usually see a lot of scriptJobs that represent UI elements keeping track of things.