Chances are you are reading this because you want to write some UI for 3ds Max using Python. Because 3ds Max is fundamentally a Win32 application there are some challenges to overcome:
- Make your PyQt window is a proper child windows of 3ds Max
- Make sure that Python doesn't collect your objects
- Making sure that accelerators are disabled when your Widget has the focus
Making PyQt Windows a Child of 3ds Max
Digia provides a Qt module for C++ called QtWinMigrate that was designed specifically to support mixing Qt with native Win32 UI. This works very well in C++ but unfortunately is not available by default in the PyQt or PySide packages. Luckily Blur studios wrapped make this package available in their binary PyQt distribution. If you need to build it from sources yourself the SIP sources can also be found online.
Once installed (note: I didn't install the Blur plug-ins. I have no idea how that could affect Python in the 3ds Max 2014 extension) you have to tell the 3ds Max Python engine where the PyQt module is. This can be done by updating the PYTHONPATH folder to point to the folder containing your Blur PyQt installation. For example I installed the Blur Qt into my regular Python installation folder, I set the PYTHONPATH user environment variable to "C:\Python27\Lib\site-packages". If you already have the PYTHONPATH variable you can append it to the list of paths, separating it from the others using a semicolon. If 3ds Max is running you will have to restart it before this change can take effect.
A simple test to see if 3ds Max Python can find the PyQt is to write:
On my system the result is:
3ds Max 2014 path environment variable c:\\program files\\autodesk\\3ds max 2014\\
MaxPlus module False
PyQt QT version 4.8.3
PyQt module True
PyQt version snapshot-4.9.5-9eb6aac99275
PySide module False
Python prefix C:\Program Files\Autodesk\3ds Max 2014\python
Python version 2.7.3 (default, Apr 10 2012, 23:24:47) [MSC v.1500 64 bit (AMD64)]
os module True
sys module True
Once all of this is done you have to do a bit of extra work to get the 3ds Max HWND from the MaxPlus API because it is exposed as a SWIG object, and not a plain int. Here is a snippet of code that illustrates how to do this.
import MaxPlus, ctypes from PyQt4 import QtGui, QtCore, QtWinMigrate def getMaxHwnd(): mem = long(MaxPlus.Win32.GetMAXHWnd()) return ctypes.c_longlong.from_address(mem).value class MaxWidget(QtGui.QMainWindow): def __init__(self): self.parent = QtWinMigrate.QWinWidget(getMaxHwnd()) QtGui.QMainWindow.__init__(self, self.parent)
Make sure that Python doesn't collect your objects
There is not much documentation on how to assure that Python doesn't collect your PyQt objects, and to be honest I don't know exactly what the best practice should be. The general principle is that Python needs to see at least one active reference to an object so that it doesn't collect it. For this purpose I use a class member variable to track custom high-level PyQt widgets that I create which I remove when the widget is closed.
class _Widgets(object): _instances =  class MaxWidget(QtGui.QMainWindow): def __init__(self): self.parent = QtWinMigrate.QWinWidget(getMaxHwnd()) QtGui.QMainWindow.__init__(self, self.parent) _Widgets._instances.append(self) def closeEvent(self, event): if self in _Widgets._instances: _Widgets._instances.remove(self)
Making sure that accelerators are disabled when your Widget has the focus
Another challenge with UI code in 3ds Max is that the main window of 3ds Max will interpret most key-presses as shortcut keys. When you have an edit field in a Python UI this can pose some obvious problems. Normally in regular 3ds Max UI programming we can use the DisableAccelerators API call to temporarily disable shortcut keys. The problem with PyQt is knowing exactly when to make this call.
Intuitively we may think that overriding the Qwidget.focusInEvent() would work, but when using PyQt with Win32, the event is not triggered automatically when switching from a non-PyQt managed window to a PyQt window. The best approach that I have been able to come up with is to install an event filter of the QApplication instance that previews all mouse clicks. If the QApplication exposed to Python sees a mouse click, then we assume that a PyQt window has gained focus and we can disable accelerators. It’s a bit hacky, and I'm sure the more experienced Python programmers out there can suggest an alternative, but this is the best I could find for now, at least until we can make improvements to the core.
Putting this together with the other techniques here is a complete code example
# To execute from MAXScript (if in your
/scripts/python folder. # python.executeFile "auDemoPyQt.py" import MaxPlus import ctypes from PyQt4 import QtGui, QtCore # Requires PyQt4 binaries from Blur studios https://code.google.com/p/blur-dev/downloads/list from PyQt4 import QtWinMigrate # Get or create the application instance as needed # This has to happen before any UI code is triggered app = QtGui.QApplication.instance() if not app: app = QtGui.QApplication() def getMaxHwnd(): ''' Get the HWND of 3ds Max as an int32 ''' mem = long(MaxPlus.Win32.GetMAXHWnd()) return ctypes.c_longlong.from_address(mem).value class _Widgets(object): ''' Used to store all widget instances and protect them from the garbage collector ''' _instances =  class _FocusFilter(QtCore.QObject): ''' Used to filter events to properly manage focus in 3ds Max. This is a hack to deal with the fact that mixing Qt and Win32 causes focus events to not get triggered as expected. ''' def eventFilter(self, obj, event): # We track any mouse clicks in any Qt tracked area. if event.type() == QtCore.QEvent.MouseButtonPress: print "Mouse down", obj elif event.type() == 174: # QtCore.QEvent.NonClientAreaMouseButtonPress: print "Non-client button press", obj else: return False MaxPlus.CUI.DisableAccelerators() return False class MaxWidget(QtGui.QMainWindow): # Note: this does not work automatically when switching focus from the rest of 3ds Max # to the PyQt UI. This is why we have to install an event filter. def focusInEvent(self, event): print "Focus gained on MaxWidget" MaxPlus.CUI.DisableAccelerators() def __init__(self): print "Creating MaxWidget" self.parent = QtWinMigrate.QWinWidget(getMaxHwnd()) QtGui.QMainWindow.__init__(self, self.parent) _Widgets._instances.append(self) # Install an event filter. Keep the pointer in this object so it isn't collected, and can be removed. self.filter = _FocusFilter(self) app.installEventFilter(self.filter) def closeEvent(self, event): print "Closing MaxWidget" if self in _Widgets._instances: _Widgets._instances.remove(self) # Very important, otherwise the application event filter will stick around for app.removeEventFilter(self.filter) class MyWidget(QtGui.QWidget): def btnPressed(self): print "Button pressed" if self.parent(): self.parent().setFocus() def __init__(self): QtGui.QWidget.__init__(self) self.topLayout = QtGui.QGridLayout() self.nextLayout = QtGui.QGridLayout() self.textEdit = QtGui.QTextEdit() self.textEdit.setText("Hello world") self.btn = QtGui.QPushButton("Apply") self.btn.pressed.connect(self.btnPressed) self.nextLayout.addWidget(self.textEdit) self.nextLayout.addWidget(self.btn) self.topLayout.addLayout(self.nextLayout, 0, 0) self.setLayout(self.topLayout) def main(): main = MaxWidget() w = MyWidget() main.setCentralWidget(w) main.show() if __name__ == '__main__': main()