Houdini Python PanelでカスタムUIを作る


この記事はHoudiniアドベントカレンダー2018 9日目の記事です。

# はじめに

Python Panelを作成することによってpythonでのHoudiniに対する操作をGUIから呼び出せるようになります。とは言え簡単なコマンドを実行するだけならシェルフツールで事足りるので必要になる機会は少ないです。実行するのに設定するパラメータが多すぎるとか、参加しているプロジェクトのルールに従ってデータを入力する補助ツールが必要だとかシェルフツールで出来ること以上のものが必要になった時にやっと選択肢に入ってくるかなってくらいの印象です。

Python PanelはAutorigs,Character Picker等のHoudini標準ツールの他、[VEX Editor](https://github.com/paulwinex/pw_Houdini_VEX_Editor/tree/master/pw_VEX_Editor) やBlenderのゲームエンジンとして開発中の[Armory](https://armory3d.org/index.html)をHoudiniから起動する [armory_houdini](https://github.com/armory3d/armory_houdini) 等Githubで公開されているものもあります。

# Hello World
さっそく作成していきます。まずはペインタブからPython Panelを選びます。
Python Panelのチュートリアルが表示されます。
![01_python_panel_first.png](https://qiita-image-store.s3.amazonaws.com/0/223831/8186087d-80d8-d90d-0da2-191506dba29c.png)
このチュートリアル通りに進めるとUIが作成できます。
歯車アイコン ⇒ New InterfaceでPython Panel Editorが開きます。
そのままでも大丈夫ですが"Save To","Name","Label"を下図のように変更しました。
![02_python_panel_create.png](https://qiita-image-store.s3.amazonaws.com/0/223831/7db7dbbc-4d7d-3a99-7c8e-d31ab7028cfc.png)
Applyを押すと"Save To"に設定されたパスにpypanelファイルが出力されます。
ここからHoudiniでMy Custom UIを表示できるように設定していきます。

Python Panel EditorのToolbar Menuタブを選択します。
![02_2_python_panel_toolbar.png](https://qiita-image-store.s3.amazonaws.com/0/223831/8790e0c1-7388-5dc8-c224-ac22b3d2295f.png)

左側のAvailable InterfacesからMy Custom UIを選んで右矢印を押して右側のToolbar Menu Entriesに登録されたのを確認してApplyを押します。これでPython PanelからMy Custom UIを表示できるようになりました。
![03_toolbar_menu.png](https://qiita-image-store.s3.amazonaws.com/0/223831/4d83c776-be3a-1f86-77ff-84d14753c661.png)

同様にPython Panel EditorのPane Tab MenuタブからもMy Custom UIを登録してApplyを押します。これでペインタブから直接My Custom UIを表示できるようになりました。
![04_panetab_menu.png](https://qiita-image-store.s3.amazonaws.com/0/223831/f42c3bac-5da3-8fa2-afe3-2a6c29df707a.png)

Toolbar、Pane TabどちらもHello World!しているかと思います。

# カレンダーを表示する
ここからpythonコードを書き換えて動作を確認していきます。
生成されたコードの#から始まるコメントを読むとhutil.Qtを使わずにPySide2かPyQt5を使えとあるのでPySide2に変更します。また'Hello World!'部分も書き換えてみます。コメントは確認したら削除してしまって問題ありません。

```python:my_custom_ui.pypanel(元コード)
########################################################################
# Replace the sample code below with your own to create a
# PyQt5 or PySide2 interface.  Your code must define an
# onCreateInterface() function that returns the root widget of
# your interface.
#
# The 'hutil.Qt' is for internal-use only.
# It is a wrapper module that enables the sample code below to work with
# either a Qt4 or Qt5 environment for backwards-compatibility.
#
# When developing your own Python Panel, import directly from PySide2
# or PyQt5 instead of from 'hutil.Qt'.
########################################################################

#
# SAMPLE CODE
#
from hutil.Qt import QtWidgets

def onCreateInterface():
    widget = QtWidgets.QLabel('Hello World!')
    return widget
```

```python:my_custom_ui.pypanel(書き換え後)
from PySide2 import QtWidgets

def onCreateInterface():
    widget = QtWidgets.QLabel('僕の考えた最強のHoudini UI')
    return widget
```

出来ました。
![05_code_edit.png](https://qiita-image-store.s3.amazonaws.com/0/223831/551c746c-b650-5379-2c4e-9b9284572465.png)


ここで先のチュートリアルにあったカレンダーのコードをコピペして確認してみます。

```python:my_custom_ui.pypanel(カレンダー版)
from PySide2 import QtWidgets

def onCreateInterface():
    # Create a calendar widget and a label.
    calendar = QtWidgets.QCalendarWidget()
    title = QtWidgets.QLabel("My Calendar")

    # Create a widget with a vertical box layout.
    # Add the label and calendar to the layout.
    root_widget = QtWidgets.QWidget()
    layout = QtWidgets.QVBoxLayout()
    layout.addWidget(title)
    layout.addWidget(calendar)
    root_widget.setLayout(layout)

    # Return the top-level widget.
    return root_widget
```
これでHoudini上にカレンダーを導入することが出来ました。
![06_calendar.png](https://qiita-image-store.s3.amazonaws.com/0/223831/96d3649d-c0e2-4328-40b4-4716a723b72f.png)


# 実行するコードを外部ファイル化する
pypanelのままコードを書き進めることも出来ますがコードの行数が増えてくると辛くなってきます。また色々なツールから呼び出される共通処理はHDAのように同じものを参照しにいく作りが望ましいのでファイル分割は必要になってきます。

pythonで外部ファイルをインポートするためにはパスを通す必要があります。
Houdiniは$HOME以下にscripts/pythonというディレクトリがあるとそこにパスを通してくれるのでディレクトリを作成しpythonコードを追加することとします。

```Houdini17.0(Windows)の場合
C:\Users\<ユーザー名>\Documents\houdini17.0\scripts\python
```

このディレクトリに my_custom_ui.pyとしてファイルを追加します。中身は元のpypanelの時とほぼ同じです。
日本語を使う際に文字のエンコーディング関連の問題があるのでファイルの先頭に一行追加しています。

```python:my_custom_ui.py
#coding: utf-8
from PySide2 import QtWidgets

def onCreateInterface():
    # Create a calendar widget and a label.
    calendar = QtWidgets.QCalendarWidget()
    title = QtWidgets.QLabel("My Calendar")

    # Create a widget with a vertical box layout.
    # Add the label and calendar to the layout.
    root_widget = QtWidgets.QWidget()
    layout = QtWidgets.QVBoxLayout()
    layout.addWidget(title)
    layout.addWidget(calendar)
    root_widget.setLayout(layout)

    # Return the top-level widget.
    return root_widget
```
そしてpypanel側ではmy_custom_ui.pyをimportするように書き換えます。

```python:my_custom_ui.pypanel(ファイル分割後)
import my_custom_ui

def onCreateInterface():
    reload(my_custom_ui)
    return my_custom_ui.onCreateInterface()
```

これで挙動は同じままにファイルを分割することが出来ました。これ以降は好きなテキストエディタでコードを編集するのも容易になります。
コードを編集後はPython PanelのReloadボタン![07_reload.png](https://qiita-image-store.s3.amazonaws.com/0/223831/078c521f-701e-ec9b-bc03-f06e07c32aec.png)を押すことで画面に反映されます。ペインタブの方だと一旦閉じて開きなおす必要があるので編集作業時はPython Panelを使う方が良さそうです。

# ネットワークエディタからノードをドラッグ&ドロップ
ネットワークエディタからノードをドラッグ&ドロップするとノードのパスがテキストで取得できます。
hou.nodeでノードのパスからノードを取得できます。
ここではQtWidgets.QWidgetを継承したMyCustomWidgetクラスを作成してドロップイベントの処理が出来るようにしています。カレンダーは有効な使い方が思いつかなかったので削除しました。

```python:my_custom_ui.py
#coding: utf-8
from PySide2 import QtWidgets
import hou

class MyCustomWidget(QtWidgets.QWidget):
    """ 自作ウィジェット """
    def __init__(self,parent=None):
        super(MyCustomWidget, self).__init__(parent)

        self.label = QtWidgets.QLabel("Node Info")
        self.label.setStyleSheet("font: 18pt;") #文字サイズを調整

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.label)
        self.setLayout(layout)

        # ドラッグ&ドロップを有効にする
        self.setAcceptDrops(True)

    def dropEvent(self, event):
        """ ドロップイベント処理 """
        if event.mimeData().hasText():
            # ドロップされたノードのパスを取得
            node_path = event.mimeData().text()
            # ノードのパスからノードを取得
            node = hou.node(node_path)
            if node:
                # ラベルの文字を更新
                self.label.setText('{} ({})'.format(node.name(), node.path()))
            else: #複数選択の場合など通常のパスでない場合
                self.label.setText(node_path)
            event.accept()
        
def onCreateInterface():
    return MyCustomWidget()
```

![08_drag_drop.png](https://qiita-image-store.s3.amazonaws.com/0/223831/cac5a6ea-4e29-2651-4458-4fbc90ec3190.png)


Houdiniのテキストボックス(Python Panelじゃなくても)にネットワークエディタからのドラッグ&ドロップでパスを入力できることを最近になってようやく気付きました…。


# おまけ:ネットワークエディタのイベントをフックする
[Extending the network editor](https://www.sidefx.com/docs/houdini/hom/network.html)によるとネットワークエディタの挙動をカスタマイズ出来るらしいのでフックしたイベントをPython Panel側に横流ししてどんなイベントが来ているのか確認してみました。

まずイベントを横流しするための[シグナル](https://wiki.qt.io/Qt_for_Python_Signals_and_Slots)をscripts/python以下に作ります。

```python:my_event_signal.py
# coding: utf-8
from PySide2.QtCore import QObject, Signal   

class MyEventSignal(QObject):
    network_event = Signal(object)

my_event_signal_global = MyEventSignal()
```

次に$HOME以下にpython2.7libsというディレクトリを作成しnodegraphhooks.pyのファイルを追加します。nodegraphhooks.pyは必ずこのファイル名である必要があります。

```Houdini17.0(Windows)の場合
C:\Users\<ユーザー名>\Documents\houdini17.0\python2.7libs
```

```python:nodegraphhooks.py
#coding: utf-8
from my_event_signal import my_event_signal_global

def createEventHandler(uievent, pending_actions):
    """ ネットワークエディタからのイベントをフックする """
    my_event_signal_global.network_event.emit(uievent)
    return None, False
```

最後にmy_custom_ui.pyを変更します。

```my_custom_ui.py
#coding: utf-8
from canvaseventtypes import *
from PySide2 import QtWidgets
from PySide2.QtGui import QStandardItem, QStandardItemModel
from my_event_signal import my_event_signal_global

class EventListWidget(QtWidgets.QWidget):
    """ ネットワークエディタのイベントをリスト表示するウィジェット """

    def __init__(self,parent=None):
        """ 初期化 """
        super(EventListWidget, self).__init__(parent)

        self.listview = QtWidgets.QListView()
        self.model = QStandardItemModel()
        self.listview.setModel(self.model)

        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(self.listview)
        self.setLayout(layout)

        my_event_signal_global.network_event.connect(self.onReceiveEvent)

    def __del__(self):
        my_event_signal_global.network_event.disconnect(self.onReceiveEvent)

    def onReceiveEvent(self, uievent):
        """ ネットワークエディタのイベントを受け取ってリストに追加する """
        if isinstance(uievent, MouseEvent):
            # あまりに大量にくるのでmousemoveだけは除外する
            if uievent.eventtype == 'mousemove':
                return
        self.model.appendRow( QStandardItem(str(uievent)) )
        self.listview.scrollToBottom()

def onCreateInterface():
    return EventListWidget()
```

結果このようになりました。
![ex_nodegraphhooks.png](https://qiita-image-store.s3.amazonaws.com/0/223831/6488da03-71a8-fad5-31d4-61a23ae4bace.png)


流れてくるイベントを見る限りノードを追加したり消したりしてもそれらしいイベントは一切流れてきません。用途的にデフォルトのネットワークエディタが何かする前に別の何かを実行するためのものなのでネットワークエディタ上の変更を検知してあれこれするような使い方は難しそうです。ネットワークエディタを拡張するにもHoudiniのpythonコード($HFS/houdini/python2.7libs以下)の理解が必要でかなりハードル高そうです…。