Qt UI
3ds Max Python チートシート の章一覧
Python のツールの UI は、3ds Max に同梱の PySide2(Qt for Python) で作ります。MaxScript の rollout の代わりです。 大事なのは 2 つだけです。3ds Max のメインウィンドウを親にすること と、ウィンドウへの参照を持ち続けること です。
3ds Max 2025 以降は PySide6 に変わりました。本章のコードは 3ds Max 2022(PySide2)基準です。2025 以降では
from PySide2 import ...をfrom PySide6 import ...に読み替えます。一部の API は名前が変わっており、たとえばモーダル表示のexec_()はexec()になります。
3ds Max のメインウィンドウを取得する
本章では、以後 qtmax.GetQMaxMainWindow() で取得した QMainWindow を親にします。公式ヘルプにある QWidget.find(rt.windows.getMAXHWND()) でも親にできますが、こちらの QWidget 版はドッキングには使えません。
from PySide2 import QtWidgets
import qtmax
main = qtmax.GetQMaxMainWindow() # QMainWindow(ドッキングに使える)
main_w = QtWidgets.QWidget.find(rt.windows.getMAXHWND()) # QWidget 版(公式ヘルプの書き方)
- 親にしておくと、3ds Max の最小化・終了と一緒に閉じ、3ds Max のウィンドウの裏に隠れることもありません。
最小のツールウィンドウ
from PySide2 import QtCore, QtWidgets
import qtmax
class MyToolDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super(MyToolDialog, self).__init__(parent)
self.setWindowTitle("My Tool")
self.setAttribute(QtCore.Qt.WA_DeleteOnClose) # 閉じたら破棄する
self.spin = QtWidgets.QSpinBox()
self.spin.setRange(1, 100)
self.spin.setValue(5)
btn = QtWidgets.QPushButton("箱を並べる")
btn.clicked.connect(self.make_boxes)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(QtWidgets.QLabel("個数"))
layout.addWidget(self.spin)
layout.addWidget(btn)
def make_boxes(self):
with pymxs.undo(True, "箱を並べる"): # ボタン 1 回 = undo 1 回
for i in range(self.spin.value()):
rt.Box(pos=rt.Point3(i * 30, 0, 0))
rt.redrawViews() # 画面に反映する
dlg = MyToolDialog(parent=qtmax.GetQMaxMainWindow())
dlg.show() # モードレス(3ds Max を操作しながら使える)
show()はモードレス、exec_()はモーダル(閉じるまで 3ds Max を操作できない)です。- ボタンから
rtを呼ぶ処理はpymxs.undo(True, ...)で囲むと、ユーザが Ctrl + Z で一度に戻せます。 - 3ds Max はスクリプトの実行中に画面を更新しないので、処理のあとに
rt.redrawViews()を呼びます。
ウィンドウを 1 つだけ開く(開き直す)
スクリプトを実行し直すたびにモジュールのグローバル変数は作り直されるので、そこに前回のウィンドウへの参照を入れておいても、次に実行したときにはもう参照できません。確実に前のウィンドウを閉じるには、objectName を付けておき、メインウィンドウの子から findChild で探します。
from PySide2 import QtCore, QtWidgets
import qtmax
def show_my_tool():
main = qtmax.GetQMaxMainWindow()
prev = main.findChild(QtWidgets.QDialog, "MyToolDialog")
if prev is not None:
prev.close() # 前のウィンドウを閉じる
dlg = QtWidgets.QDialog(main)
dlg.setObjectName("MyToolDialog") # findChild で見つけるための名前
dlg.setWindowTitle("My Tool")
dlg.setAttribute(QtCore.Qt.WA_DeleteOnClose) # 閉じたら破棄する
dlg.show()
return dlg
_my_tool_window = show_my_tool()
_my_tool_window = show_my_tool() # 2 回呼んでも見えているウィンドウは 1 つ
- ⚠️ 親を付けていないウィンドウは、参照が無くなると消えることがあります。親を付けていても、あとで閉じたり開き直したりするために、ウィンドウへの参照はどこかに持っておきます。
WA_DeleteOnCloseを付けていないと、close()はウィンドウを隠すだけで破棄しません。
ドッキングできるウィンドウ
from PySide2 import QtCore, QtWidgets
import qtmax
class MyDock(QtWidgets.QDockWidget):
def __init__(self, parent=None):
super(MyDock, self).__init__(parent)
self.setWindowTitle("My Dock")
self.setObjectName("MyToolDock") # Qt の状態保存に使う名前(付けないと警告が出る)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
body = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(body)
btn = QtWidgets.QPushButton("選択を表示")
btn.clicked.connect(lambda: print([o.name for o in rt.selection]))
layout.addWidget(btn)
self.setWidget(body)
main = qtmax.GetQMaxMainWindow()
dock = MyDock(parent=main)
main.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock) # 右端にドッキング
dock.setFloating(True) # 浮かせて表示する場合(最初からドッキングさせるならこの行を外す)
dock.show()
- ドッキングさせたいときは、親を
qtmax.GetQMaxMainWindow()(QMainWindow)にします。QWidget.find(...)で取った親ではドッキングできません。
メッセージ・確認・入力
rt.messageBox("完了しました", title="My Tool") # MXS: messageBox "完了しました"
ok = rt.queryBox("続けますか?", title="My Tool") # はい → True
from PySide2 import QtWidgets
import qtmax
parent = qtmax.GetQMaxMainWindow()
ret = QtWidgets.QMessageBox.question(parent, "My Tool", "削除しますか?")
yes = ret == QtWidgets.QMessageBox.Yes
text, ok = QtWidgets.QInputDialog.getText(parent, "My Tool", "名前:") # 文字の入力
- 簡単な確認なら
rt.messageBox/rt.queryBoxが手軽です。Qt のツールの中なら、見た目をそろえるためにQMessageBoxを使います。 - いずれもモーダルなので、ユーザが閉じるまで処理が止まります。
進捗バーを出す(キャンセル可能)
nodes = list(rt.objects)
rt.progressStart("処理中...") # MXS: progressStart "処理中..."
try:
for i, o in enumerate(nodes):
o.wirecolor = rt.Color(255, 128, 0)
if not rt.progressUpdate(100.0 * (i + 1) / len(nodes)): # キャンセルされたら False
break
finally:
rt.progressEnd() # 例外が起きても必ず閉じる
- 3ds Max 下部のステータスバーに進捗バーが出ます。
progressUpdateは 0〜100 を受け取り、ユーザが [キャンセル] を押すとFalseを返します。 - ⚠️
progressEnd()を呼ばずに終わると進捗バーが残ります。try/finallyで必ず閉じます。
Qt のツールの中で出すなら QProgressDialog も使えます。
from PySide2 import QtCore, QtWidgets
import qtmax
nodes = list(rt.objects)
prog = QtWidgets.QProgressDialog("処理中...", "キャンセル", 0, len(nodes), qtmax.GetQMaxMainWindow())
prog.setWindowModality(QtCore.Qt.WindowModal)
prog.setMinimumDuration(0) # 待たずにすぐ表示する
for i, o in enumerate(nodes):
if prog.wasCanceled():
break
o.wirecolor = rt.Color(0, 128, 255)
prog.setValue(i + 1)
QtWidgets.QApplication.processEvents() # 画面を更新してキャンセルを受け付ける
prog.close()
Qt Designer の .ui を読み込む
import os
from PySide2 import QtCore, QtUiTools, QtWidgets
import qtmax
ui_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "my_tool.ui")
f = QtCore.QFile(ui_path)
f.open(QtCore.QFile.ReadOnly)
dlg = QtUiTools.QUiLoader().load(f, qtmax.GetQMaxMainWindow())
f.close()
def make_teapot():
rt.Teapot()
rt.redrawViews()
dlg.findChild(QtWidgets.QPushButton, "pushButton").clicked.connect(make_teapot)
dlg.show()
__file__はスクリプトファイルとして実行したときだけ使えます(リスナーに貼った場合は使えません)。
ツールバーやメニューから起動する(マクロスクリプト)
body = 'python.ExecuteFile @"C:\\tools\\my_tool\\main.py"' # 中身は MaxScript の文字列
macro_id = rt.macros.new("My Tools", "MyTool_Open", "My Tool を開く", "My Tool", body)
# MXS: macroScript MyTool_Open category:"My Tools" ( python.ExecuteFile @"..." )
- 作ったマクロスクリプトは、[カスタマイズ] → [ユーザインタフェースをカスタマイズ] のカテゴリ「My Tools」に現れ、ツールバー・メニュー・ショートカットに割り当てられます。
macros.newを実行するたびに、そのマクロスクリプトのコピーがユーザのマクロスクリプトフォルダ(#userMacros)に .mcr ファイルとして保存されます。- マクロスクリプトの中身は MaxScript なので、Python は
python.ExecuteFileで呼びます。Python の関数を直接呼びたいときは、rt.myToolOpen = 関数のように MaxScript のグローバルに入れておき、中身を'myToolOpen()'にします(→「MaxScript との連携」章)。この方法は、3ds Max の起動時にその関数を定義するモジュールを読み込んでおく必要があります(→「モジュール管理・デバッグ」章)。
落とし穴まとめ
- 親は 3ds Max のメインウィンドウ。ドッキングするなら
qtmax.GetQMaxMainWindow()。 - 親を付けていないウィンドウへの参照をどこにも持たないと、ガベージコレクションで消えることがある。
- 同じスクリプトを実行し直すとウィンドウが増える。
objectNameとfindChildで前のものを見つけて閉じてから開く。 rtの操作のあとはrt.redrawViews()。ボタン単位でpymxs.undo(True)で囲む。progressEnd()はfinallyで必ず呼ぶ。
関連
- ボタンから MaxScript を呼ぶ: 「MaxScript との連携」章
- シーンの変化に UI を追従させる: 「コールバック・イベント」章
コメント