Before I start this post, I would like to mention that most of the stuff covered in this post also applies to developing in PySide and MaxPlus. I’m currently using 3ds Max 2012 at work, which is why I chose to use PyQt and Blur Python, instead of MaxPlus and PySide. The 3 development concepts (Qt Designer, uic module, and packages) I’m going to cover in this post are general standards to developing in Python and Qt altogether, and should be applied when developing for other software such as Maya and Nuke as well.
One of the most exciting aspects of having Python and Qt running in 3ds Max is the ability to write guis using the Qt4 framework. While maxscript and its strange but convenient implementation of .net have proven to be an unstoppable combination. Python and the Qt4 framework bring an ease to the development process that will leave the most hardcore maxscript gurus wondering how they ever lived without it.
With that said, I have noticed that many of my fellow max scripters using Python and Qt for the first time don’t have a good enough entry point to some of the more common practices that make development with these tools super fast and incredibly fun. I hope the following code along with this write-up will help the readers get the most out of Qt and Python when developing for 3ds Max and possibly other software like Maya, Nuke, etc. In this write-up I will be covering 3 simple concepts that I use in my daily tool development. This write-up assumes that the reader has a strong knowledge of maxscript and a basic knowledge of the python syntax. No knowledge of Qt is required. The code samples do make small use of inheritance, an object oriented programing concept that might be foreign to TDs who’ve only work in maxscript where this method does not exist. The code is simple enough to implement without the need for a deep understanding of how inheritance works.
Let’s get started!
Qt Designer (Stop writing gui code)
One of the coolest aspects of Qt is being able to use the “what you see is what you get” Qt Designer application (ships with Qt). This application allows the user to quickly create complex guis that can range from full application windows, dialogs or even other widgets, to extend Qt Designer and Qt itself. Qt Designer achieves this without the need to write a single line of code. Some max TDs might find it reminiscent to the “maxscript visual editor” tool inside of 3ds Max, except for one important factor…
It doesn’t suck.
In all seriousness Qt Designer is a fantastic little app that allows you to visualize and create your own gui by dragging widgets from a repository right into your dialog. It also allows you to use Qt’s simple and intuitive layout widgets, which developers can use to make gui that resize beautifully without all the extra code usually involved in writing resizable max roll-outs and dialogs. The layout system in Qt is enough to make most veteran maxscript developers never want to use native UI functions in maxscript ever again.
So while yes, you could just write Qt gui’s in Python programmatically, which can be a pain to maintain as tools grow and change, the need to do so becomes obsolete for static UI’s. Freeing the developer to work on more important things, like logic.
UIC module (Using .ui files in python)
So now that you are all excited about using Qt Designer to start making some next level guis inside 3ds Max, you might be stuck figuring out how to get your Designer gui (a “.ui” file) into Python. A common practice is to use a uic.exe that ships with PyQt to translate a “*.ui” file (xml format) to a “*.py” file (python code) that can be imported or pasted into your python scripts.
I personally do not like this method for the following reasons:
- Anytime a gui change is needed the developer has to do an extra step of converting the “*.ui” file to a “*.py”
- This practice encourages developers to do patches or edits in the newly created “*.py” file. A practice that can be destructive when changes require going back and forth between Qt Designer and Python.
- Due to reasons 1 and 2 developers might limit the use Qt Designer to creating the first version of the “*.ui” file and make all future changes outside of Qt Designer directly in converted “*.py” files. This destroys the “Feng shui” of having a separation between the gui code and the code that makes up the logic of the gui.
So, what is the easier more elegant way of integrating the use of your “*.ui” files straight into Python, without the need for messy conversions, you ask?? The answer is simple…
The uic module.
from PyQt4 import uic
When used properly uic can provide a fast and easy way to convert “*.ui” files to Python classes you can start using at run-time (and in a very sexy object oriented way).
Here’s a snippet showing my preferred usage of the uic module in Python for creating fancy guis:
from PyQt4 import QtGui, QtCore, uic
base,form = uic.loadUiType(r'c:\uifiles\mainUI.ui'))
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
In this snippet our dialog will be its own class of name “mainApp”. To display the interface all we have to do is the usual:
gui = mainApp()
gui.show()
At this point our gui is done, leaving us free to just focus on the logic of our tool and connecting the widgets to the logic. Making a very clean separation between the gui and the main logic of our code. Isn’t uic fun??!!
Note:PySide uic module does not have a “loadUiType” method, but it’s easy enough to make your own here’s an example.
Python Packages (Keep your code organized and portable)
For many maxscripters that don’t have alot of experience using Python, Python packages will be somewhat of a new concept. But it’s a pretty simple one that will allow you to keep your tools very clean and organized. You won’t have to worry about them clashing with other people’s tools and libraries.
Note:For a very nerdy (by which I mean technical and in depth) description to Python packages you can checkout the following link.
To understand packages we need to understand how the Python file sourcing mechanism (“import”) works and how it relates to the maxscript’s equivalent (“filein”).
In maxscript to source code from a secondary location one can simply do a …
fileIn @"c:\myMascriptFucntions.ms"
In Python it’s not that simple. Python files have to live in very specific locations for them to be “fileined” via the “import” command. These locations are loaded via the pythonpath environment variable. If the folders that your auxiliary files are stored in are NOT in the list of paths declared by the pythonpath variable, Python will fail to find them when you attempt to import them. By making packages we avoid the potential nightmare of having to add the path of every folder containing auxiliary files to the pythonpath before importing them and instead we can register our root folder and any sub folders as packages. This lets Python know to automatically search this folder for any auxiliary modules our tools are attempting to load.
To start using packages the easy way, we can simply start developing from a folder that is already in the default pythonpath such as “C:\Python27\Lib\site-packages”. But, we can also make folders outside of the Python root directory and develop from there as long as we let Python know to look for packages or modules in these external folders.
In my example I’ve chosen the second options and will be using the following folder as my main development area…
“C:\dev\workspace\losDev”
To start the example tool we will create a folder called sampleQtMaxGui (then name of our tool). We will then place a “__init__.py” file inside this folder effectively making our tool package.
“C:\dev\workspace\losDev\sampleQtMaxGui\__init__.py”
This registers the folder “C:\dev\workspace\losDev\sampleQtMaxGui” as a package (assuming we have added “C:\dev\workspace\losDev” to the pythonpath environment variable). At this point, Python will be able to source any files inside of this newly created package. Furthermore, we can now import our new package into any Python session via the following line…
import sampleQtMaxGui
If we wanted to get very fancy and organize our tools in more detail, we can always make more packages inside of our initial package. For example, the following structure would make a package called “3dsMax” that would host all our Python tools that only run in 3ds Max,each tool would be hosted in it’s own package.
C:\dev\workspace\losDev\3dsMax\__init__.py
C:\dev\workspace\losDev\3dsMax\sampleQtMaxGui\__init__.py
C:\dev\workspace\losDev\3dsMax\mindBlowingTool\__init__.py
Importing the “sampleQtMaxGui” tool in to a Python session would then look like this…
import 3dsmax.sampleQtMaxGui
I could then shorten the name space into a smaller variable by using the “as” expression to import the module into a shorter name space.
import 3dsmax.sampleQtMaxGui as sampleQtMaxGui
The big thing to take away from this, is that in Python it is not possible to import from sub-folders unless they contain a “__init__.py” file. These “__init__.py” files don’t have to contain any code, they only need to exist. But once they do, you’ll be able to source them easily via the import method.
Putting it all together
Ok, so now that we’ve talked about Qt Designer, the uic module, and python packages, it is time that we put it all together and see how this all works in practice. What better way to do this than to make a simple example tool. (download the finished sample code here)
Before we start our tool, here are a few things that we need to know for this example:
- The folder containing all my tools will be “C:\dev\workspace\losDev”.
- We will be creating a single tool called sampleQtMaxGui.
- This tool will be deployed as a package.
So, let’s make folder called “sampleQtMaxGui” and place a “__init__.py” file inside of it (ex. C:\dev\workspace\losDev\sampleQtMaxGui\__init__.py). Next, let’s make a simple “*.ui” file in Qt Designer that will be made up of a listWidget (list box) and a pushButton (button).
Here are the steps…
- Find designer.exe in your computer and launch it.
- Select the “main window” as your initial template.
- Drag and drop a list-widget and a push-button for the widget repository into your dialog (don’t worry about placement).
- Right click on the dialog and set the layout to vertical (this should automatically position your tools)
- After adding the widgets save the gui as “mainUI.ui” inside our tool’s package under a folder called “views” (ex. C:\dev\workspace\losDev\sampleQtMaxGui\views\mainUI.ui)
Once we’ve saved the “*.ui” file inside of the package it is time to start doing the Python work. In order to keep this example simple, all of our Python scripting will happen inside the “__init__.py”. The first lines of code will be used to import all the necessary Qt modules, the Blur python maxscript commands, and the “os” module.
from PyQt4 import QtCore, QtGui, uic
from Py3dsMax import mxs
import os
Next, we will be loading our “.ui” file via the “loadUiType” method in the uic module, effectively converting the content of the file into a class we can use to inherit from.
base,form = uic.loadUiType(os.path.join(os.path.dirname(__file__),'views','mainUI.ui'))
Note the use of “os.path.dirname(__file__)“. “__file__” is a global variable that python registers, which contains the absolute path to the file we’re currently working on. By using the “dirname” method of the “os” module, we can parse this path for the name of our tool’s root directory. We then use the “join” method to concatenate this folder, our subfolder containing our “*.ui” files (“views”), and the name of our “*.ui” file. This will effectively reproduce the absolute path of our “mainUI.ui” file regardless of where the package lives.
Now, it’s time to setup our gui class and have it inherit the values generated by the “loadUiType” method…
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
The “__init__” function is a Python method built into the class mechanism that gets executed whenever our class is instantiated. This means that as soon as we run the following line this method will be ran as well…
x = mainApp()
The line “super(mainApp,self).__init__(parent)” makes sure that our parent class is also initiated. The “super(mainApp,self)” method is similar to the “delegate” pointer used when writing scripted plugins in maxscript and can be used to access method and properties stored in the parent class. After initializing our delegate or parent class, the “setupUi” method becomes automatically available to our custom class. By executing this method next, all the widgets we created in Qt Designer are then added to our class as well, leaving our class now capable to render an exact clone of the gui saved in the “.ui” file.
The “__init__” method of our custom class is also a perfect place to add other functions that would populate and connect our widgets. We exemplify this by adding the following methods to our class “populateList” and “connectWidgets”.
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
#populate a list box...
def populateList(self):
self.scnObjects = mxs.objects
for o in self.scnObjects:self.listWidget.addItem(o.name)
#-- connect event handlers
def connectWidgets(self):
pass
After adding the new methods to our class, we can execute them inside the “__init__” method.
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
#----
self.populateList()
self.connectWidgets()
#populate a list box...
def populateList(self):
self.scnObjects = mxs.objects
for o in self.scnObjects:self.listWidget.addItem(o.name)
#-- connect event handlers
def connectWidgets(self):
pass
Next, we add the event handler methods that are to be connected in our currently empty “connectWidgets” method. For this example we will create two new methods “selectObjects” that will be fired when the user changes the selection on the list box, and a “pressButton” method that we’ll be fired when the user press our push button…
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
#----
self.populateList()
self.connectWidgets()
#populate a list box...
def populateList(self):
self.scnObjects = mxs.objects
for o in self.scnObjects:self.listWidget.addItem(o.name)
#-- connect event handlers
def connectWidgets(self):
pass
#--list box events
def selectObjects(self):
sel = []
mxs.deselect(mxs.objects)
for i in self.listWidget.selectedItems():
obj = self.scnObjects[(self.listWidget.indexFromItem(i).row())]
sel.append(obj)
mxs.select(sel)
mxs.ForceCompleteRedraw()
#-- button event
def pressButton(self):
sel = self.listWidget.selectedItems()
QtGui.QMessageBox.about(self,'QtMessage',"%i objects selected" % len(mxs.selection))
The last thing we do to finish our class is connect the callbacks from our widgets to the methods we want them to execute. In order to do this we add the following lines to the “connectWidgets” method. Note: callbacks in Qt are referred to as “Signals” and are built directly onto the widgets themselves, keep this in mind when looking at documentation for widgets.
self.listWidget.itemSelectionChanged.connect(self.selectObjects)
self.pushButton.clicked.connect(self.pressButton)
In the snippet above “listWidget” is the object name of the list box we created in Qt Designer and “pushButton” refers to the object name of the button that will also created in Qt Designer.
Our final code should look something like this…
class mainApp(base,form):
def __init__(self,parent=None):
super(mainApp,self).__init__(parent)
self.setupUi(self)
#----
self.populateList()
self.connectWidgets()
#populate a list box...
def populateList(self):
self.scnObjects = mxs.objects
for o in self.scnObjects:self.listWidget.addItem(o.name)
#-- connect event handlers
def connectWidgets(self):
self.listWidget.itemSelectionChanged.connect(self.selectObjects)
self.pushButton.clicked.connect(self.pressButton)
#--list box events
def selectObjects(self):
sel = []
mxs.deselect(mxs.objects)
for i in self.listWidget.selectedItems():
obj = self.scnObjects[(self.listWidget.indexFromItem(i).row())]
sel.append(obj)
mxs.select(sel)
mxs.ForceCompleteRedraw()
#-- button event
def pressButton(self):
sel = self.listWidget.selectedItems()
QtGui.QMessageBox.about(self,'QtMessage',"%i objects selected" % len(mxs.selection))
We’re now completely done with our gui class, but there’s still one last thing we want to do. To make it simple to open our gui from any where, we will add one more method outside of our class called “open”. Here’s what that looks like…
def open():
app = mainApp()
app.show()
return app
This function will allow us to open the gui right after importing our tool with the following lines…
import sampleQtMaxGui
ui = sampleQtMaxGui.open()
Running your tool from maxscript
To have a maxscript execute your tool, we make the following snippet in maxscript…
st = "import sys
losDevPath = r'C:\dev\workspace\losDev'
sys.path.insert(0,losDevPath)
import sampleQtMaxGui
gui = sampleQtMaxGui.open()
"
python.exec st
It’s important to know that the following lines are only necessary if the path to development folder has not already been added to the pythonpath. These lines allow you to add the path to your development folder at run-time vs having to mess with your machines environment variables. But, keep in mind this is a ghetto way of doing things. If you have no idea how to add your development folder to the pythonpath and you’re not working in a folder that is visible by Python, these lines might get you by for now. I strongly suggest you use this method only for testing or experimenting with the sample code in this post.
import sys
losDevPath = r'C:\dev\workspace\losDev'
sys.path.insert(0,losDevPath)
If the path to your development folder is already in the pythonpath you can use this instead.
st = "import sampleQtMaxGui
gui = sampleQtMaxGui.open()
"
python.exec st
One thing to keep in mind is that once you import a Python script into 3ds Max (or any other Python session), the code gets compiled and later changes to the code will not be refreshed unless your restart max, or force reload the library. If you want to load the latest code every time you import your tool you can add the “reload” method.
st = "import sampleQtMaxGui
reload(sampleQtMaxGui)"
gui = sampleQtMaxGui.open()
python.exec st
Anyway that’s it for now, good luck coding!!