PyQt UI in 3ds Max 2014 Extension

By - - 3ds Max
Duration
6 mins
Last modification: 16 Sep, 2017

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:

  1. Make your PyQt window is a proper child windows of 3ds Max
  2. Make sure that Python doesn't collect your objects
  3. 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:
python.executeFile "demoPyVersionTool.py"

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
    #success
   

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()

Published In
Tags
  • 3ds Max
  • API
  • Python
  • Scripting
  • Film & VFX
  • Games
  • Design Visualization
6 Comments
To post a comment please login or register
| 4 years ago
Hello Mr. Christopher Diggins ? am learning MCG and max for python but I am getting eror import MAxPlus...normally working python codes but ? try to import MaxPlus and Eror smile emoticon
Edited by dQyLU868 4 years ago
| 4 years ago
Chris - I wasn't sure how else to contact you so i'll ask here - is there a reason for the preference for winforms in the .NET side of Max (Scene explorer, etc.)? Are there known issues with WPF, or is it just to be consistent with older tools?
Edited by LIv8rMpa 4 years ago
| 4 years ago
Why is this example being left to an unsupported module like QtWinMigrate to enable the functionality to embed these windows into the UI. You have another post where this migration is handled in C++. Why is this not already included functionality in the CUIFrame? QT should be a first class citizen? What was the QT integration left unfinished?
Edited by diL5gR26 4 years ago
| 5 years ago
Hi Chris, I have been looking at Max 2015 and the example pyside ui file that creates a simple ui with a button for creating a cylinder. Can you tell me how the ui can be parented to the main max window as currently, the Ui loses focus when the main max screen is selected and you have to select the ui/tool from the taskbar. This may have been an oversight or omission for some reason, but if you can inform me on the process for parenting any generated ui, internally or externally, to the main max window as a child that stays on top and minimizes/maximaizes when the main window does so, it would be graetly appreciated. Thanks
Edited by 4kqXoCGW 5 years ago
| 6 years ago
Nice to see 3dsmax connected closer to Python & QT...
Edited by Z6SEULOd 6 years ago
| 6 years ago
Great stuff Chris! Have you tried getting this to work with PySide?
Edited by 1GL97xu1 6 years ago
*Save $66 per month on Autodesk's Suggested Retail Price (SRP) when purchasing 1 year term 3ds Max or Maya subscription.