はじめに
この記事はHoudini Advent Calender 2024の6日目の記事です
Houdini 20.5から新しく追加されたHOMの関数でMultiparmの扱いが便利になったので
今回はそれらを使って、いい感じ! なMultiparm Blockを作っていきたいと思います!
使用バージョン : Houdini 20.5.332
関数
hou.Parm.moveMultiParmInstances(moves)
hou.Parm.moveDownMultiParmInstance(inst_index)
hou.Parm.moveUpMultiParmInstance(inst_index)
20.0.270から追加された関数でMultiparmの移動ができるようになっています
単純な移動であればこちらの関数を使うだけでBlockの移動ができますね
hou.Parm.multiParmInstancesAsData()
hou.Parm.setMultiParmInstancesFromData(data)
そして、Recipesの追加に伴って、20.5からDataを扱う関数が増えました!
今回はMultiparmに関するものだけを挙げていますが、他にもあるのでParmもご確認下さい
hou.Parm.multiParmInstancesAsData()の関数を実行すると、
各インスタンスの情報を含んだリストが返ってきます。
リストの各要素は辞書で構成されていて、
基本的にはキーにパラメータの名前、値にパラメータの値が含まれます
# return: [{<parm_name>_#: <parm_value>,...},
#          {<parm_name>_#: <parm_value>,...}...]
# e.g)
#  単一の値
[{'parm1_#': '0', 'parm2_#': '2'},
 {'parm1_#': '5', 'parm2_#': '3'}]
#  複数の値
[{'parm1_#': ['0','1','2'], 'parm2_#': ['1','2','3']},
 {'parm1_#': ['3','4','5'], 'parm2_#': ['2','3','1']}]
# エクスプレッション
[{'parm1_#': {'expression': 'ch("ref_parm")'}},
 {'parm1_#': {'expression': 'hou.pwd().evalParm("ref_parm")', 'language': 'Python'}}]
#  キーフレーム
[{'parm1_#':
  {'keyframes': [ {'frame': 1, 'value': 1.0, 'slope': 0.0,
                   'accel': 0.0, 'inaccel': 0.3, 'expression': 'bezier()'},
                  {'frame': 2, 'value': 0.5, 'slope': 0.0,
                   'accel': 0.0, 'inaccel': 0.3, 'expression': 'bezier()'}
                ]
  }
}]
MultiparmBlock 移動編
まずはパラメータのテンプレートを作成していきましょう
Edit Parameter Interaceからこのようなパラメータを作成しました

Folder TypeはMultiparm Block (list)
Button IconにそれぞれBUTTONS_up、BUTTONS_downを設定しています

もちろんこのままでは何も動かないので、
矢印のボタンを押した時に移動するようにコールバックを書いていきます

直接だと書きにくいので、自分は別途エディタなどで書いてから貼り付けています
HDAや別途パラメータで関数を用意して、それを評価する方法もありますが今回はシンプルに
parent = kwargs["parm"].parentMultiParm();
idx    = int(kwargs["script_multiparm_index"]) - parent.multiParmStartOffset();
n      = parent.eval();
parent.moveDownMultiParmInstance(idx) if (n > 1) and (idx > 0) else None
parent = kwargs["parm"].parentMultiParm();
idx    = int(kwargs["script_multiparm_index"]) - parent.multiParmStartOffset();
n      = parent.eval();
parent.moveUpMultiParmInstance(idx) if (n > 1) and (idx < n-1) else None
Callback Scriptではスクリプトを一行にまとめないといけないため、
行の末尾に ; をつけて文の区切りとして使用しています
では、スクリプトで何をしているか見ていきましょう!
# はじめに自身の属しているMultiparmを取得します
parent = kwargs["parm"].parentMultiParm();
# インデックスを取得
# ただ、そのままだとパラメータのインデックスがオフセットされている場合があるので、
# 0から開始されるように調整
idx = int(kwargs["script_multiparm_index"]) - parent.multiParmStartOffset();
# インスタンスの総数を取得
n = parent.eval();
# 三項演算子を使って、条件に一致する場合のみ関数を実行
# up_#:   インスタンスの数が2つ以上かつ、インデックスの値が最下位ではない場合のみ上に移動
parent.moveDownMultiParmInstance(idx) if (n > 1) and (idx > 0)   else None
# down_#: インスタンスの数が2つ以上かつ、インデックスの値が最上位ではない場合のみ下に移動
parent.moveUpMultiParmInstance(idx)   if (n > 1) and (idx < n-1) else None
三項演算子
x if condition else y
この書き方は三項演算子と呼ばれ、if-else 文を1行で書くことができます
- condition は評価される条件式
- x は、condition が 真(True)であれば選ばれる値
- y は、condition が 偽(False)であれば選ばれる値
up_#、down_#それぞれのCallback Scriptにスクリプトを設定し、Acceptを押したら
移動ボタンの付いたMultiparmの完成です!
MultiparmBlock 入替え編
先程のMultiparmだと、繰り上げ、繰り下げしかできないので
もう少し拡張して、任意の場所と入替えられるようにして行きましょう
QMenuでメニューを作ろう
早速、準備していくのですが、
どのMultiparmと入替えるのかを選択できるようにしたいので、
Action Button
先程はButtonパラメータのCallback Scriptでスクリプトを書いていきましたが
一行では何かと難しいので、今回はAction Buttonを使って実装していきたいと思います。
Edit Parameter Interfaceを見てみると、パラメーターのタブの中にAction Buttonというタブがあります。
ここにPythonスクリプトを書き込むとパラメーターの横にアイコンが表示されるので、それをクリックすると書き込んだスクリプトを実行する事が出来ます。
 
シンプルなメニュー
Floatなど適当なパラメータを用意して、Action Buttonに以下を書き込みます
from hutil.Qt.QtGui     import QCursor
from hutil.Qt.QtWidgets import QMenu, QAction
# NOTE: QActionはQt6からQtGuiに属するので、今後修正が必要になるかも
def test():
    hou.ui.displayMessage("Hello!")
menu    = QMenu("Hoge")                   # メニューの作成
submenu = QMenu("Fuga",   parent=menu)    # サブメニューの作成
action  = QAction("Fugo", parent=submenu) # アクションの作成
action.triggered.connect(test) # アクションをクリックした時の関数を登録
submenu.addAction(action) # アクションの追加
menu.addMenu(submenu)     # サブメニューの追加
# UIの見え方をHoudini仕様にするために、スタイルシートを設定
menu.setStyleSheet(hou.qt.styleSheet())
# マウスカーソルの位置を取得
pos = QCursor().pos()
# 実行
menu.exec_(pos)
これを実行すると

簡単なメニューが作れました
それではこれらを踏まえてMultiparmを拡張していきましょう!
実装

移動編でのパラメーターにDataパラメータを追加しました。
こちらのパラメータタイプは本来の使い方ではないと思うのですが
Action Buttonだけを使いたかったので、こちらを使用しています
※これにより処理が増えてしまいましたっ...
パラメータタイプ
from hutil.Qt.QtGui     import QCursor
from hutil.Qt.QtWidgets import QMenu, QAction
# NOTE: QActionはQt6からQtGuiに属するので、今後修正が必要になるかも
# 引数を含んだ関数を与えたいのでpartialをimport
from functools import partial
# geometryが空の状態だとhou.Parm.setMultiParmInstancesFromDataを実行した時に
# エラーを引き起こすので、それを回避するための関数
def checkNoneGeometry(data):
    for d in data:
        for k in d.keys():
            if isinstance(d[k], dict) and ("geometry" in d[k]) and (d[k]["geometry"] is None):
                d[k].pop('geometry')
    return data
# Multiparmを入れ替えるための関数
def swapBlock(multiparm, idx, target_idx):
    # Multiparmのデータを取得
    data = multiparm.multiParmInstancesAsData()
    # geometryにNoneが含まれていないか確認
    checkNoneGeometry(data)
    # 順番の入替え
    data[idx],data[target_idx] = data[target_idx],data[idx]
    # Multiparmのセット
    multiparm.setMultiParmInstancesFromData(data)
# パラメータの情報を取得
parm      = kwargs["parmtuple"][0]
multiparm = parm.parentMultiParm()
offset    = multiparm.multiParmStartOffset()
idx       = int(kwargs["script_multiparm_index"]) - offset
n         = multiparm.eval()
# メインメニューの作成
menu = QMenu()
# サブメニューの作成
submenu = QMenu("Swap",                                 # Label
                icon   = hou.qt.Icon("SOP_attribswap"), # Icon
                parent = menu)                          # Parent
for i in range(n):
    # クリックしたインデックスの場合はスキップ
    if i == idx:
        continue
    # アクションの作成
    action = QAction(hou.qt.Icon("SOP_enumerate"),                # Icon
                     "%s_%d"%(multiparm.description(), i+offset), # Label
                     parent=submenu)                              # Parent
    # アクションをクリックした時の関数を登録
    # NOTE: lambdaを使う事もできますが、イテレーションの中で設定すると
    #       iなどの値が先にキャプションされてしまい意図しない結果となるので
    #       functoolsのpartialを使用
    action.triggered.connect(partial(swapBlock,multiparm,idx,i))
    # サブメニューにアクションを追加
    submenu.addAction(action)
# サブメニューをメニューに追加
menu.addMenu(submenu)
# UIの見え方をHoudini仕様にするために、スタイルシートを設定
menu.setStyleSheet(hou.qt.styleSheet())
# マウスカーソルの位置を取得
pos = QCursor().pos()
# 実行
menu.exec_(pos)
checkNoneGeometry()
Data、Geometry Dataタイプのパラメータは、ジオメトリを格納できるのですが、
その値が空の場合、hou.Parm.setMultiParmInstancesFromDataでエラーを引き起こすため、それを回避するために処理をいれています
今回アイコンも追加しています、詳しくはこちらを hou.qt.Icon
これを適用すると入替えメニューの付いたMultiparmの完成です!
MultiparmBlock コピー・複製編
さぁどんどん、いきましょう
次はコピー、複製できるように拡張していきます
入替え編のコードを少し調整します
from hutil.Qt.QtGui     import QCursor
from hutil.Qt.QtWidgets import QMenu, QAction
# NOTE: QActionはQt6からQtGuiに属するので、今後修正が必要になるかも
# 引数を含んだ関数を与えたいのでpartialをimport
from functools import partial
# geometryが空の状態だとhou.Parm.setMultiParmInstancesFromDataを実行した時に
# エラーを引き起こすので、それを回避するための関数
def checkNoneGeometry(data):
    for d in data:
        for k in d.keys():
            if isinstance(d[k], dict) and ("geometry" in d[k]) and (d[k]["geometry"] is None):
                d[k].pop('geometry')
    return data
# Multiparmを操作するための関数
def processBlock(multiparm, idx, target_idx, method):
    # Multiparmのデータを取得
    data = multiparm.multiParmInstancesAsData()
    # geometryにNoneが含まれていないか確認
    checkNoneGeometry(data)
    # 順番の入替え
    if method == "swap":
        data[idx],data[target_idx] = data[target_idx],data[idx]
    # コピー
    elif method == "copy":
        data[idx] = data[target_idx]
    # 複製
    elif method == "dup":
        data.insert(target_idx, data[idx])
    # Multiparmのセット
    multiparm.setMultiParmInstancesFromData(data)
# パラメータの情報を取得
parm      = kwargs["parmtuple"][0]
multiparm = parm.parentMultiParm()
offset    = multiparm.multiParmStartOffset()
idx       = int(kwargs["script_multiparm_index"]) - offset
n         = multiparm.eval()
# メインメニューの作成
menu = QMenu()
# サブメニューのリストを作成
submenu_list = []
submenu_list.append(QMenu("Swap",              icon=hou.qt.createIcon("SOP_attribswap"), parent=menu, objectName="swap"))
submenu_list.append(QMenu("Copy\tfrom",        icon=hou.qt.createIcon("BUTTONS_copy"),   parent=menu, objectName="copy"))
submenu_list.append(QMenu("Duplicate\tbefore", icon=hou.qt.createIcon("SOP_duplicate"),  parent=menu, objectName="dup"))
for submenu in submenu_list:
    method = submenu.objectName()
    for i in range(n):
        # 複製以外で、クリックしたインデックスの場合はスキップ
        if (i == idx) and (method != "dup"):
            continue
        # アクションの作成
        action = QAction(hou.qt.createIcon("SOP_enumerate"),          # Icon
                         "%s_%d"%(multiparm.description(), i+offset), # Label
                         parent=submenu)                              # Parent
        # アクションをクリックした時の関数を登録
        # NOTE: lambdaを使う事もできますが、イテレーションの中で設定すると
        #       iなどの値が先にキャプションされてしまい意図しない結果となるので
        #       functoolsのpartialを使用
        action.triggered.connect(partial(processBlock,multiparm,idx,i,method))
        # サブメニューにアクションを追加
        submenu.addAction(action)
    # サブメニューをメニューに追加
    menu.addMenu(submenu)
# UIの見え方をHoudini仕様にするために、スタイルシートを設定
menu.setStyleSheet(hou.qt.styleSheet())
# マウスカーソルの位置を取得
pos = QCursor().pos()
# 実行
menu.exec_(pos)
サブメニューの追加と、それらを処理する関数に条件をつけています
少し調整を加えるだけで実装できました!
コピー・複製機能の付いたMultiparmの完成です!雑
MultiparmBlock リファレンス編(番外)

こちらなんですが、リファレンスになるとリストの編集だけではなく、
データの中身を少し書き換える必要があるのと、ネストされたMultiparmやRampなどの処理が複雑になるため、詳しい解説は省略します
コードは貼り付けておくので、ご興味あればご覧ください!
Script
from hutil.Qt.QtGui     import QCursor
from hutil.Qt.QtWidgets import QMenu, QAction
# NOTE: QActionはQt6からQtGuiに属するので、今後修正が必要になるかも
import re
# 引数を含んだ関数を与えたいのでpartialをimport
from functools import partial
# 辞書を子階層までコピーしたいのでdeepcopyをimport
from copy      import deepcopy
# geometryが空の状態だとhou.Parm.setMultiParmInstancesFromDataを実行した時に
# エラーを引き起こすので、それを回避するための関数
def checkNoneGeometry(data):
    for d in data:
        for k in d.keys():
            if isinstance(d[k], dict) and ("geometry" in d[k]) and (d[k]["geometry"] is None):
                d[k].pop('geometry')
    return data
    
# Rampのリンクが残っている場合があるのでそれらを解除
def unlinkMultiparm(node):
    path  = node.path()
    links = re.findall("'(.*?)'",hou.hscript("opmultiparm %s"%(path))[0])
    [hou.hscript("opmultiparm '%s' '%s' ''"%(path,link)) for link in links]
# 再帰的にdataをリファレンスへと書き換え
def toReferenceData(node, data, target_idx, offset, indices=[], depth=0):
    for i,parm_set in enumerate(data):
        if depth == 0 and target_idx != i:
            continue
        current_indices = indices[:] + [i+offset,]
        
        for parm_name,parm_value in parm_set.items():
            resolved_parm_name = parm_name.replace("#","{}").format(*current_indices)
            expr     = 'ch("%s")'%(resolved_parm_name)
            expr_str = 'chs("%s")'%(resolved_parm_name)
            
            # Get Template
            template_group = node.parmTemplateGroup()
            template = template_group.find(parm_name)
            if template:
                type_    = template.type()
                datatype = template.dataType()
                
                # Ramp
                if datatype == hou.parmData.Ramp:
                    ramp_offset = int(template.tags().get('multistartoffset',1))
                    parm_set[parm_name] = {'value' : {'expression': expr}, 'points' : parm_set[parm_name]}
                    for point_idx,point in enumerate(parm_set[parm_name]['points']):
                        for point_name, point_value in point.items():
                            if isinstance(point_value, list):
                                point[point_name] = [{'expression': 'ch("%s_%d%s")'%(resolved_parm_name,point_idx+ramp_offset,channel)}
                                                      for channel in ["cr","cg","cb"]]
                            else:
                                point[point_name] =  {'expression': 'ch("%s_%d%s")'%(resolved_parm_name,point_idx+ramp_offset,point_name)}
                                
                # MultiparmBlock       
                elif type_ in (hou.parmTemplateType.Folder, hou.parmTemplateType.FolderSet):
                    if template.folderType() in (
                        hou.folderType.MultiparmBlock,
                        hou.folderType.ScrollingMultiparmBlock,
                        hou.folderType.TabbedMultiparmBlock
                    ):
                        offset = int(template.tags().get('multistartoffset',1))
                        if isinstance(parm_set[parm_name], list):
                            parm_set[parm_name] = {
                                'multiparm_links': {},
                                'multiparms'     : parm_set[parm_name][:],
                                'value'          : {'expression': expr}
                            }
                            parm_value = parm_set[parm_name]['multiparms']
                        elif isinstance(parm_set[parm_name], dict):
                            parm_value = parm_set[parm_name].get('multiparms')
                            parm_set[parm_name]['value'] = {'expression': expr}
                        else:
                            parm_set[parm_name] = {'expression': expr}
                            
                # String / Float
                else:
                    expr = expr_str if datatype == hou.parmData.String else expr
                    parm_set[parm_name] = {'expression': expr}
                    
                # List
                if isinstance(parm_value, list):
                    toReferenceData(node, parm_value, target_idx, offset, current_indices, depth+1)
    if depth == 0:
        return data[target_idx]
# Multiparmを入れ替えるための関数
def processBlock(multiparm, idx, target_idx, method):
    # Multiparmのデータを取得
    data = multiparm.multiParmInstancesAsData()
    # geometryにNoneが含まれていないか確認
    checkNoneGeometry(data)
    # 順番の入替え
    if method == "swap":
        data[idx],data[target_idx] = data[target_idx],data[idx]
    # コピー
    elif method == "copy":
        data[idx] = data[target_idx]
    # 複製
    elif method == "dup":
        data.insert(target_idx, data[idx])
    # リファレンス
    elif method == "ref":
        # リンクを解除
        unlinkMultiparm(multiparm.node())
        # 一度パラメータをリセット
        multiparm.removeMultiParmInstance(idx)
        multiparm.insertMultiParmInstance(idx)
        # リファレンスに書き換え
        data[idx] = toReferenceData(multiparm.node(),
                                    deepcopy(data),
                                    target_idx,
                                    multiparm.multiParmStartOffset())
    # Multiparmのセット
    multiparm.setMultiParmInstancesFromData(data)
# パラメータの情報を取得
parm      = kwargs["parmtuple"][0]
multiparm = parm.parentMultiParm()
offset    = multiparm.multiParmStartOffset()
idx       = int(kwargs["script_multiparm_index"]) - offset
n         = multiparm.eval()
# メインメニューの作成
menu = QMenu()
# サブメニューのリストを作成
submenu_list = []
submenu_list.append(QMenu("Swap",              icon=hou.qt.createIcon("SOP_attribswap"), parent=menu, objectName="swap"))
submenu_list.append(QMenu("Copy\tfrom",        icon=hou.qt.createIcon("BUTTONS_copy"),   parent=menu, objectName="copy"))
submenu_list.append(QMenu("Duplicate\tbefore", icon=hou.qt.createIcon("SOP_duplicate"),  parent=menu, objectName="dup"))
submenu_list.append(QMenu("Reference\tfrom",   icon=hou.qt.createIcon("LOP_reference"),  parent=menu, objectName="ref"))
for submenu in submenu_list:
    method = submenu.objectName()
    for i in range(n):
        # 複製以外で、クリックしたインデックスの場合はスキップ
        if (i == idx) and method != "dup":
            continue
        # アクションの作成
        action = QAction(hou.qt.createIcon("SOP_enumerate"),          # Icon
                         "%s_%d"%(multiparm.description(), i+offset), # Label
                         parent=submenu)                              # Parent
        # アクションをクリックした時の関数を登録
        # NOTE: lambdaを使う事もできますが、イテレーションの中で設定すると
        #       iなどの値が先にキャプションされてしまい意図しない結果となるので
        #       functoolsのpartialを使用
        action.triggered.connect(partial(processBlock,multiparm,idx,i,method))
        # サブメニューにアクションを追加
        submenu.addAction(action)
    # サブメニューをメニューに追加
    menu.addMenu(submenu)
# UIの見え方をHoudini仕様にするために、スタイルシートを設定
menu.setStyleSheet(hou.qt.styleSheet())
# マウスカーソルの位置を取得
pos = QCursor().pos()
# 実行
menu.exec_(pos)
おわりに
新しく追加された関数で、Multiparmを簡単に扱うことができました
今までは、複製するのにも結構大変だったので個人的には嬉しいところです
また、QMenuを使ったカスタムのメニューもさらに書き加えていけば、
独自のWidgetを表示したりと出来ることが増えるので是非色々と試してみて下さい!
ありがとうございました!





