【MotionBuilder】Python SDK 入門 第8回 『UI・ツール開発 - PySide』

に公開

この記事は、Python SDK 入門 の第8回目の記事です。

https://zenn.dev/nadegata_memo/articles/77a0fbce3b3387

今回は、UI・ツール開発の前の準備として、Qt(PySide) を利用してUIの要素を取り扱う基礎的な方法について説明します。


1. はじめに

1.1. Qt / PySide について

Qt とは、クロスプラットフォームのGUI作成フレームワークです。
複数のモジュール群から成る大規模なプロジェクトであり[1]、ユーザーは用途に応じて各モジュールを導入して開発を行います。

alt text
Qt Documentation - All Modules

工業製品や描画ソフト、DCC ツールなど様々な場所で導入されていており、MotionBuilder や Maya のUIにも Qt が利用されています。

PySide (Qt for Python) は C++ の Qt のライブラリを Python から利用できるようにしたものです。Maya や Houdini のUI作成で馴染みのある方もいらっしゃるかもしれません。

PySide の導入は簡単で、下記コマンドで必要なパッケージが全てインストールされます。

pip install PySide6

このコマンドで PySide とともにインストールされる Shiboken パッケージは、Python とのバインディングの内部情報を提供するものです[2]。Qt を利用したアプリケーションでは、例えば以下のような処理で SDK から取得した UI 要素を PySide で利用します。

from PySide6 import QtWidgets
from shiboken6 import wrapInstance

# Creates a Python wrapper for a C++ object instantiated at a given memory address
# PySide6.QtWidgets.QWidget型へ
widget = wrapInstance(widget_CppPtr_FromSDK, QtWidgets.QWidget)


1.2. MotionBuilder と Qt

MotionBuilder のインストール時、Qt のライブラリも一緒にダウンロードされています。
alt text
MotionBuilder 起動時にリンクされる Qt ライブラリ

PySide のパッケージも同様にインストールされていて、MotionBuilder 内部の python.exe のモジュール探索パス下にあるため、Python Editor から利用できます。

import PySide2
print(PySide2)
>>>
<module 'PySide2' from 'C:\\Program Files\\Autodesk\\MotionBuilder 2024\\bin\\x64\\python\\site-packages\\PySide2\\__init__.py'>

MotionBuilder のバージョンによって、使用されている Qt のバージョンが下記の通り異なるので注意が必要です[3]

  • MotionBuilder 2025 : Qt 6.5.3 / PySide6
  • MotionBiulder 2024 : Qt 5.15.2 / PySide2

2024以前と2025のどちらのバージョンにも対応するには、import文を以下のように書きます。

try:
    # MotionBuilder 2025 向け
    from PySide6.QtWidgets import QWidget
    from shiboken6 import wrapInstance, getCppPointer
except:
    # MotionBuilder 2024 以前向け
    from PySide2.QtWidgets import QWidget
    from shiboken2 import wrapInstance, getCppPointer


2. Qt で UI を分析する

2.1. MainWindow と全体のUI

MotionBuilder の SDK には FBGetMainWindow() という、MainWindow へのポインターを取得する関数があります。

from pyfbsdk import FBGetMainWindow
try:
    from PySide6.QtWidgets import QMainWindow
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow
    from shiboken2 import wrapInstance

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow) # get MainWindow
print(win_ptr)
print(win)

>>>
1772773305808
<PySide2.QtWidgets.QMainWindow(0x19cc185edd0) at 0x0000019CD76E4E40>


QMainWindowは、アプリケーションのメインのUI(下記構造)を提供するクラスです。

alt text
PySide6.QtWidgets.QMainWindow - Qt for Python

一度簡単なアプリケーションを作り、MainWindow を確認してみましょう。

test.py
import sys
try:
    from PySide6 import QtCore
    from PySide6.QtGui import Qt
    from PySide6.QtWidgets import (QApplication, QMainWindow, QMenuBar,
                                   QStatusBar, QToolBar, QDockWidget,
                                   QWidget, QHBoxLayout, QVBoxLayout, QLabel)
except:
    from PySide2 import QtCore
    from PySide2.QtGui import Qt 
    from PySide2.QtWidgets import (QApplication, QMainWindow, QMenuBar,
                                   QStatusBar, QToolBar, QDockWidget,
                                   QWidget, QHBoxLayout, QVBoxLayout, QLabel)

class CustomMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Application Title")

'''
(中略)
'''

app = QApplication(sys.argv)
win = CustomMainWindow()
win.showMaximized()

if(int(QtCore.qVersion()[0]) < 6):
    app.exec_()
else:
    app.exec()
ソース全文
import sys
try:
    from PySide6 import QtCore
    from PySide6.QtGui import Qt
    from PySide6.QtWidgets import (QApplication, QMainWindow, QMenuBar,
                                   QStatusBar, QToolBar, QDockWidget,
                                   QWidget, QHBoxLayout, QVBoxLayout, QLabel)
except:
    from PySide2 import QtCore
    from PySide2.QtGui import Qt 
    from PySide2.QtWidgets import (QApplication, QMainWindow, QMenuBar,
                                   QStatusBar, QToolBar, QDockWidget,
                                   QWidget, QHBoxLayout, QVBoxLayout, QLabel)

class CustomMainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Application Title")
        
        # Allow dockwidgets to dock in horizontal order
        self.setDockNestingEnabled(True)
        
        # add menu bar
        mbar = QMenuBar()
        mbar.addMenu("Menubar menu")
        self.setMenuBar(mbar)
        
        # Add status bar
        sbar = QStatusBar()
        sbar.showMessage("Statusbar message")
        sbar.setHidden(False)
        self.setStatusBar(sbar)
        
        # Add Tool bar
        top_toolbar = QToolBar()
        top_toolbar.setObjectName("top toolbar")
        top_toolbar.addAction("Toolbar menu1")
        side_toolbar = QToolBar()
        side_toolbar.setObjectName("side toolbar")
        side_toolbar.addAction("Toolbar\nmenu2")
        self.addToolBar(Qt.ToolBarArea.TopToolBarArea, top_toolbar)
        self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, side_toolbar)
        
        # Add Dock Widget1
        dwig1 = QDockWidget()
        dwig1.setWindowTitle("dockwidget1")
        dwig1.setObjectName("dockwidget1")
        container1 = QWidget()
        dwig1.setWidget(container1)
        layout1 = QVBoxLayout()
        label1 = QLabel("Widget in dockwidget1")
        layout1.addWidget(label1)
        container1.setLayout(layout1)
        dwig1.setAllowedAreas(Qt.AllDockWidgetAreas)
        self.addDockWidget(Qt.LeftDockWidgetArea, dwig1)
        
        # Add Dock Widget2
        dwig2 = QDockWidget()
        dwig2.setWindowTitle("dockwidget2")
        dwig2.setObjectName("dockwidget2")
        container2 = QWidget()
        dwig2.setWidget(container2)
        layout2 = QVBoxLayout()
        label2 = QLabel("Widget in dockwidget2")
        layout2.addWidget(label2)
        container2.setLayout(layout2)
        dwig2.setAllowedAreas(Qt.AllDockWidgetAreas)
        self.addDockWidget(Qt.RightDockWidgetArea, dwig2)
    
    def showEvent(self, event):
        super().showEvent(event)
        self.PrintWindowStructure()
    
    def PrintWindowStructure(self):
        for child in self.children():
            print(child)


app = QApplication(sys.argv)
win = CustomMainWindow()
win.showMaximized()

if int(QtCore.qVersion()[0]) < 6:
    app.exec_()
else:
    app.exec()


python test.py でアプリケーションが起動します。

alt text
作成されたアプリケーション

Toolbar menu の部分は見覚えのある方もいるかもしれません。Maya の Shelf や 3dsMax のリボン等に使われている Widget[4]です。

alt text
Maya における Toolbar の例

アプリケーションのUIが全てこの構成だとは限らず、実際 MotionBuilder では "Toolbars" や "Central Widget"、"Status Bar" にあたる要素はありません。また、MotionBuilder における Menu Bar については、上記スクリプトで作成されるような単純なものではなく、ユーザーが独自に作成した Widget で構成される Custom Menu Bar というものが使用されています。詳しくは 2.3.節 で解説します。


2.2. DockWidget

QDockWidgetクラスが提供する DockWidget は、Float/Dock および Close という特徴をもつ[5]Widget で、最初は MainWindow に Dock(接続)された状態で存在しています。

alt text
Float ボタンと Close ボタン
Qtにおいて、QObjectクラスを継承するすべてのオブジェクトは一種の特殊な親子関係[6]を持ちうるのですが、この特殊な関係があることで、親にあたるオブジェクトが削除された時に子オブジェクトも削除されます。ここで、MotionBuilder の MainWindow の構成(ペアレントされた Widget)を調べてみましょう。

from pyfbsdk import FBGetMainWindow
try:
    from PySide6.QtWidgets import QMainWindow
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow
    from shiboken2 import wrapInstance

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)

# ペアレントされた Widget を出力
for child in win.children():
    print(child)

>>> 
<PySide2.QtWidgets.QLayout(0x22a86218fc0, name = "_layout") at 0x0000022A9669E680>
<PySide2.QtWidgets.QWidget(0x22a8052d9f0) at 0x0000022A966CE6C0>
<PySide2.QtWidgets.QDockWidget(0x22a97eadc20, name="ToolWindow_2") at 0x0000022A966CE780>
<PySide2.QtWidgets.QDockWidget(0x22a97621a60, name="ToolWindow_3") at 0x0000022A966CEB80>
<PySide2.QtWidgets.QDockWidget(0x22a97620140, name="ToolWindow_4") at 0x0000022A966CE7C0>
<PySide2.QtWidgets.QDockWidget(0x22afd58f510, name="ToolWindow_5") at 0x0000022A966CEA40>
<PySide2.QtWidgets.QDockWidget(0x22a97636370, name="ToolWindow_6") at 0x0000022A966CE800>
<PySide2.QtWidgets.QDockWidget(0x22a97636130, name="ToolWindow_7") at 0x0000022A966CE980>
<PySide2.QtWidgets.QDockWidget(0x22a976386b0, name="UniqueName_00:59:05:17 (90)") at 0x0000022A966CE880>
<PySide2.QtWidgets.QDockWidget(0x22a955c2740, name="ToolWindow_1") at 0x0000022A966CEAC0>

DockWidget には、上記出力で示された objectName(accessibleName) の他に、表示名(windowTitle)があります。

# DockWidget のタイトルを表示
for child in win.children():
    if type(child) == QDockWidget:
        print(child.windowTitle())

>>>
Python Editor
Asset Browser
Properties
Transport Controls  -  Keying Group: TR
Navigator
Python Tool Manager
Profiling Center
Viewer


出力されたタイトルには見覚えがあるはずです。
MotionBuilder の「Window」のタイトルですね。

alt text
出力されたのはここの表示

スクリプト実行時に表示されていた Window のタイトル一覧が出力されました。これを踏まえると、MotionBuilder において Window と呼ばれていたものは、Qt における DockWidget に対応することになります。

補足:出力された DockWidget 以外の child について
<PySide2.QtWidgets.QLayout(0x22a86218fc0, name = "_layout") at 0x0000022A9669E680>
<PySide2.QtWidgets.QWidget(0x22a8052d9f0) at 0x0000022A966CE6C0>


QLayout は Widget を整列させるはたらきをするもので、この QLayout型のオブジェクトは各 DockWidget(= Window) を MainWindow 内で整列させているものです。すなわち、画面左上「Layout」で選択するレイアウトに対応します。

もう一つの QWidget型のオブジェクトは前節で触れた Custom Menu Bar にあたるものです。


2.3. Menu Bar

MotionBuilder の Menu Bar は通常のものとは異なる Custom Menu Bar です。

alt text
Menu Bar にあたる箇所

まずは Custom Menu Bar 全体にあたる Widget を取得してみましょう。

from pyfbsdk import FBGetMainWindow
try:
    from PySide6.QtWidgets import QMainWindow
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow
    from shiboken2 import wrapInstance

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)

# Menu Bar 部にあたる Widget を取得
menuwidget = win.menuWidget()
print(menuwidget)

>>>
<PySide2.QtWidgets.QWidget(0x20ab93b32e0) at 0x0000020ACF3AC940>

続いて、Custom Menu Bar 内の各 Widget も取得してみましょう。

# Menu Bar 部の Widget を構成する要素を出力
for child in menuwidget.children():
    print(child)

>>>
<PySide2.QtWidgets.QHBoxLayout(0x20ab93b3d90) at 0x0000020ACF392F00>
<PySide2.QtWidgets.QMenuBar(0x20ab93b34e0) at 0x0000020ACD8CB040>
<PySide2.QtWidgets.QWidget(0x20ad00a5500) at 0x0000020ACEEB3C80>

QHBoxLayout型のオブジェクトは、QLayout.QBoxLayout型を継承していて、特に水平に(Horizontal)Widgetを整列させる場合に使用されるものです(垂直 - Vertical - の場合はQVBoxLayout型を使います)。このレイアウトによって、残りの QMenubar型と QWidget型の Widget が整列して存在しています。

出力されたQWidget型のオブジェクトは、画面右上のアカウント情報に関する部分です。

alt text

この部分の Widget は複雑なので解説は割愛します。

最後に QMenuBar型のオブジェクトについてです。画面左上の「File」「Edit」等のメニューを表示している部分になります。
QMainWindowクラスには、Menu Bar 部の Widget を取得するメンバ関数として menuBar()menuWidget() がありますが、以下のような実装の違いがあり[7]、注意が必要です。

関数 実装内容
menuBar() Menu Bar 部にある Widget が QMenuBar型(およびその派生型)であればその Widget を返し、そうでなければ作り変える
menuWidget() 型によらず、Menu Bar 部にある Widgetを(あれば)返す


特に menuBar() はおすすめ出来ません。
2024以前のバージョンではクラッシュし、2025では Menu Bar が消えるためです。

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)

# 非推奨 !!!
menuwidget = win.menuBar()

以上を踏まえると、Menu Bar およびその中のメニューを取得する方法は以下の通りになります。

from pyfbsdk import FBGetMainWindow
try:
    from PySide6.QtWidgets import QMainWindow, QMenuBar
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow, QMenuBar
    from shiboken2 import wrapInstance

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)

# Custom Menu Bar 内のメニュー部を取得
menuwidget = win.menuWidget()
menubar = None
for child in menuwidget.children():
    if type(child) == QMenuBar:
        menubar = child

# メニュー名の表示
for menu in menubar.actions():
    print(menu.text())

>>>
File
Edit
Animation
Settings
Layout
Open Reality
Python Tools
Window
Help
Tools
Scripts


では、Menu Bar のメニュー部に新たなメニューを追加することを考えます。
QMenuBarクラスにおけるメニューの追加方法は、addMenu(title), addMenu(icon,title), addMenu(menu) の3つです。

from pyfbsdk import FBGetMainWindow, FBMessageBox
try:
    from PySide6.QtWidgets import QMainWindow, QMenuBar, QMenu
    from PySide6.QtGui import QIcon
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow, QMenuBar, QMenu
    from PySide2.QtGui import QIcon
    from shiboken2 import wrapInstance

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)
menuwidget = win.menuWidget()
menubar = None
for child in menuwidget.children():
    if type(child) == QMenuBar:
        menubar = child

# 3通りの方法で Menu Bar にメニューを追加
newmenu1 = menubar.addMenu("menu1")
action1  = newmenu1.addAction("content1")
action1.triggered.connect(lambda checked=False,
                            message = "menu1 - content1 is triggered."
                            : FBMessageBox("Message", message, "OK"))

newmenu2 = menubar.addMenu(QIcon("path/to/icon.png"),"menu2")
action1  = newmenu2.addAction("content2")
action1.triggered.connect(lambda checked=False,
                            message = "menu2 - content2 is triggered."
                            : FBMessageBox("Message", message, "OK"))

newmenu3 = QMenu("menu3", None)
menubar.addMenu(newmenu3)
action3 = newmenu3.addAction("content3")
action3.triggered.connect(lambda checked=False,
                            message = "menu3 - content3 is triggered."
                            : FBMessageBox("Message", message, "OK"))

Menu Bar に新たにメニューが作られ、メニュー内のコンテンツをクリックすると、メッセージボックスが表示されます。

alt text
作成されたメニューと、表示されたメッセージボックス


補足:SDK を利用する方法

MotionBuilder の SDK には、上部の Menu Bar のメニューを扱う専用のクラス FBMenuManagerがあります。

詳しくは FBMenuManager Class Reference をご覧ください。


3. 応用例

3.1. プラグインのように用いる

第3回 で触れたように、MotionBuilder は起動時に特定のパス下[8]のモジュールを全て実行します。そのパス下にUIを作成する処理をするスクリプトを置いておけば、起動時に実行されることで起動直後には新たなUIが表示されていることになります。

自分の場合、作成したスクリプトを特定のディレクトリに保存しておき、Menu Bar へのメニュー追加とファイルの読み込みを行うスクリプトを起動時に実行させるようにすることで、Menu Bar からオリジナルのスクリプトを実行できるようにしています。

alt text
作成スクリプト実行用のメニュー

以下リポジトリに作成方法およびソースをまとめているので、よければ覗いてみてください。
https://github.com/Ndgt/Mobu_PluginBase_Python


3.2. ドラッグアンドドロップに対応させる

Drag and Drop の機能を備えた ToolBar を作成します。スクリプトファイルをドラッグアンドドロップするとファイル名が登録され、クリックするとスクリプトが実行されるようにします。

from pyfbsdk import FBGetMainWindow, FBApplication
from pathlib import Path
try:
    from PySide6.QtWidgets import QMainWindow, QMenuBar, QToolBar, QAction
    from PySide6.QtGui import Qt, QDragEnterEvent, QDropEvent
    from shiboken6 import wrapInstance
except:
    from PySide2.QtWidgets import QMainWindow, QMenuBar, QToolBar, QAction
    from PySide2.QtGui import Qt, QDragEnterEvent, QDropEvent
    from shiboken2 import wrapInstance

class CustomToolBar(QToolBar):
    def __init__(self):
        super().__init__()
        self.setAcceptDrops(True) # ドラッグアンドドロップ有効化

    def dragEnterEvent(self, event: QDragEnterEvent):
        if event.mimeData().hasUrls() or event.mimeData().hasText():
            event.acceptProposedAction()
        else:
            event.ignore()
    
    def dropEvent(self, event: QDropEvent):
        mime_data = event.mimeData()     
        if mime_data.hasUrls():
            file_paths = [url.toLocalFile() for url in mime_data.urls()]
            for path in file_paths:
                script_path = Path(path)
                script_name = script_path.name
                action = self.addAction(script_name)
                action.triggered.connect(lambda checkd = False : FBApplication().ExecuteScript(str(script_path)))
        event.acceptProposedAction()

    def RemoveActions(self):
        for action in self.actions():
            if(action.text() != "Remove\nActions"):
                self.removeAction(action)

win_ptr = FBGetMainWindow()
win = wrapInstance(win_ptr, QMainWindow)
menuwidget = win.menuWidget()
menubar = None
for child in menuwidget.children():
    if type(child) == QMenuBar:
        menubar = child

# ToolBar を新規作成
side_toolbar =  CustomToolBar()
win.addToolBar(Qt.ToolBarArea.LeftToolBarArea, side_toolbar) # 左側に作成
action = QAction("Remove\nActions")
side_toolbar.addAction(action)
action.triggered.connect(lambda checked = False : side_toolbar.RemoveActions())

以下のように ToolBar が作成されます。

alt text
作成された ToolBar と登録されたスクリプト

「Remove Actions」ボタンを押すと、ドラッグアンドドロップして登録された各 Action を消去できます。また、上記スクリプトでは複数のスクリプトをまとめてドロップして登録することも可能です。

この、スクリプトをドラッグアンドドロップさせてUIに登録するアイデアは、Digital Frontier 様の記事を参考にさせていただきました。


次回

今回は『UI・ツール開発』のテーマの2回目として、Qt(PySide) を利用してUIの要素を取り扱う基礎的な方法を示しました。次回は最終回として、PySide を利用した実践的なツール開発の手法を解説します。


それでは、今回はここまで。最後までお読みくださりありがとうございました。

脚注
  1. Qt のソースコードを取得してビルドをする方法について ↩︎

  2. Qt Documentation - Shiboken ↩︎

  3. 各バージョンで MotionBuilder SDK Requirements を参照 ↩︎

  4. Qtにおける、UIの基本構成単位(参考↩︎

  5. Float や Close ボタンの無い(機能を持たない)DockWidget もあります ↩︎

  6. "Parenting system" と呼ばれます。詳細は Qt for Beginners を参照 ↩︎

  7. QMainWindow::menuWidget() は内部で QLayout::menuBar() を呼び出すため、QMainWindow::menuBar() と異なり QMenuBar型へのキャストを行いません(qmainwindow.cpp を参照) ↩︎

  8. MotionBuidler Help - Running Python Scripts を参照 ↩︎

Discussion