11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Viewport/Networkの単純作業を削る:Houdini自作“時短ツール”8つ(Shelfで即導入)

11
Last updated at Posted at 2025-12-24

はじめに

この記事はHoudini Advent Calendar 2025 25日目の記事です。

時短を目指した自作ツールの紹介記事になります。

詳しい理論に関する説明は省いているため、
学習というよりはかなり実務寄りな内容になっています。

単純作業は、できるだけ考えずに済ませたい

Houdini で作業していると、

  • カメラを切り替える
  • 背景色を変える
  • ディスプレイオプションを開く
  • ネットワークにメモを書く

といった 「考えなくていい操作」 に、意外と時間を取られていると感じることがありました。

この記事では、そうした発想から作った

自作の作業時短ツール をいくつか紹介します。

今回紹介するツール一覧

今回は、普段の作業で実際に使っている時短ツールの中から以下の 8 つを紹介します。

検証環境:Houdini 19.5.403

ツール名 内容
Select Cam from Button ボタンUIでカメラ切り替え
Change BG Color 背景色を Light / Dark で切替
Quick Display Option よく使う表示設定だけをUI化
Sticky Note Pad 高機能 StickyNote 作成UI
Travel Playbar 任意のフレームへワンクリックで移動
Screenshot to BG Image ビューポートをキャプチャし、Network View へ配置
Paste as Obj Merges コピーしたノードを Object Merge ノードとしてペースト
Paste as Fetches コピーしたノードを ROP Fetch ノードとしてペースト

使い方(導入方法)

すべてのツールは Shelf Tool として登録する前提です。

コードはそのまま コピペで動作 します。

とりあえず使うための登録手順

  1. Shelf 上で右クリック → New Tool
  2. Script タブへ記事内のコードをそのまま貼り付け
  3. Optionsタブでアイコンや名前を設定
  4. Hotkeysタブでショートカットを設定
    (競合してない割り当てを探るのは面倒なので、参考までに私個人のHotkey設定を記載してます。)

1. Select Cam from Button

カメラを探さない。押すだけ。

sse_SelectCamfromButton.gif

何をするツールか

シーン内のカメラを一覧表示し、

ワンクリックでビューポートのカメラを切り替えます。

どんなときに有効か

例えば、mayaからAlembicでカメラを取り込んだとき、階層が深くてカメラ名が追いづらいケースなどで便利です。

どうやって実現しているか

カメラ切り替えツールの肝は「可変数のカメラを毎回スキャンして、同数のボタンを動的生成する」点です。

ボタン生成は for cam in cams: のループで行い、lambda cam=cam でクリック時に参照するカメラを固定しています。

from PySide2 import QtWidgets

def create_camera_selection_dialog(cams):
    dialog = QtWidgets.QDialog()
    dialog.setWindowTitle("Select Camera")
    layout = QtWidgets.QVBoxLayout(dialog)

    for cam in cams:
        btn = QtWidgets.QPushButton(cam.name())
        layout.addWidget(btn)

        # 重要:lambda の「cam=cam」で、その時点の cam をボタンに閉じ込める
        btn.clicked.connect(lambda checked=False, cam=cam: (set_viewport(cam), dialog.close()))

    dialog.exec_()

補足

自作HDAのカメラがある場合は、
find_cameras関数に下記を追加することで拾うことが出来ます。

例:”sse_scenescaledcamera”というHDAの場合

    sse_scenescaledcamera_nodes = [node for node in hou.node("/obj").children() if node.type().name().startswith('sse_scenescaledcamera')]
    cams += sse_scenescaledcamera_nodes

挿入例

def find_cameras():
    """
    シーン内のカメラを見つける関数
    """
    cams = list(hou.nodeType(hou.objNodeTypeCategory(), 'cam').instances())

    # 例:独自HDAカメラも拾いたい場合はここで追加
    sse_scenescaledcamera_nodes = [node for node in hou.node("/obj").children() if node.type().name().startswith('sse_scenescaledcamera')]
    cams += sse_scenescaledcamera_nodes
    
    return cams

Hotkey登録の一例:Alt + Ctrl + T (Global)

おすすめアイコン:TOOLS_view_mode_camera

フルコード(クリックで展開)
import hou
from PySide2 import QtWidgets

def set_viewport(cam):
    """
    現在のビューポートにカメラを設定する関数
    """
    scene_view = hou.ui.paneTabOfType(hou.paneTabType.SceneViewer)
    viewport = scene_view.curViewport()
    viewport.setCamera(cam)

def find_cameras():
    """
    シーン内のカメラを見つける関数
    """
    cams = list(hou.nodeType(hou.objNodeTypeCategory(), 'cam').instances())
    return cams

def create_camera_selection_dialog(cams):
    """
    カメラ選択ダイアログを作成する関数
    """
    app = QtWidgets.QApplication.instance()
    if not app:
        app = QtWidgets.QApplication([])

    if cams:
        dialog = QtWidgets.QDialog()
        layout = QtWidgets.QVBoxLayout()
        dialog.setLayout(layout)
        dialog.setWindowTitle("Select Camera")

        for cam in cams:
            button = QtWidgets.QPushButton(cam.name())
            button.clicked.connect(lambda checked=False, cam=cam: (set_viewport(cam), dialog.close()))
            layout.addWidget(button)

        dialog.exec_()
    else:
        hou.ui.displayMessage("No Camera in Scene", buttons=("OK",))

def main():
    """
    メイン関数
    """
    cams = find_cameras()
    create_camera_selection_dialog(cams)

main()

2. Change BG Color

見づらいと思った瞬間に、切り替える

sse_ChangeBGColor.gif

何をするツールか

ビューポートの背景色を

Light / Dark の2パターンでトグル切り替えします。

(個人的に、実際に使うのはこの2色だけなので)

どうやって実現しているか

  • 切り替え状態は hou.session に保持
  • 背景色の変更は hscript ベースのviewdisplay -B を使用
    →「特定のビューポート」ではなく、すべてのビューポートに同じ設定が適用される

Hotkey登録の一例:Alt + Ctrl + B (Global)

おすすめアイコン:VIEW_display_background

フルコード(クリックで展開)
import sys
import toolutils
import hou

def toggle_background():
    try:
        bg = hou.session.bg
    except AttributeError:
        hou.session.bg = 'dark'
        bg = 'dark'

    if bg == 'dark':
        hou.session.bg = 'light'
    else:
        hou.session.bg = 'dark'

    hou.hscript("viewdisplay -B %s *" % hou.session.bg)
    hou.ui.setStatusMessage("Toggled background to %s" % hou.session.bg.upper())

toggle_background()

3. Quick Display Option

Display Option は多すぎる

sse_QuickDisplayOption.gif

何をするツールか

Display Option の中から、

実務でよく使う項目だけを抜き出してUI化しました。

  • Particle 表示タイプ/サイズ
  • Normal Scale
  • Vector Scale
  • Texture Max Resolution(2D / 3D)

どうやって実現しているか①: Texture サイズの扱い

  • 2D Texture は本来 (width, height)
  • 3D Texture は (width, height, depth)

ですが、実用上は 同じ値を入れるケースがほとんどなので、

  • 入力は 1つの数値
  • 内部で 2D / 3D に応じてチャンネル数を分けて適用

という割り切りをしています。

どうやって実現しているか②: パーティクル表示

  • パーティクルの表示タイプ・サイズは

    python内でhscript(viewdisplay)を使用して制御


Hotkey登録の一例:Alt + Ctrl + G (Global)

おすすめアイコン:BUTTONS_displayoptions

フルコード(クリックで展開)
from PySide2 import QtWidgets, QtCore, QtGui
import hou

class MyWidget(QtWidgets.QWidget):
    def __init__(self):
        super(MyWidget, self).__init__()
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
        self.setWindowTitle("Viewport Display Option")
        
        main_layout = QtWidgets.QVBoxLayout()

        # ビューポートの設定を取得
        settings = self.get_viewport_settings()

        # 初期値の取得(ビューポートが取得できない場合はデフォルト値)
        if settings:
            initial_normal = settings.normalScale()
            initial_vector = settings.vectorScale()
            initial_tex2d = settings.textureMaxRes2D()[0]  # 2D は (width, height) のタプルなので width を使用
            initial_tex3d = settings.textureMaxRes3D()[0]  # 3D は (w, h, d) のタプルなので w を使用
        else:
            initial_normal = 0.2
            initial_vector = 1.0
            initial_tex2d = 2048
            initial_tex3d = 256

        # --- Particle Settings (GroupBox) ---
        particle_group = QtWidgets.QGroupBox("Particle Settings")
        particle_layout = QtWidgets.QVBoxLayout()

        self.ptypes = ['points', 'lines', 'discs', 'pixels']
        self.radio_buttons = {ptype: QtWidgets.QRadioButton(ptype) for ptype in self.ptypes}
        self.radio_buttons['points'].setChecked(True)

        for rb in self.radio_buttons.values():
            particle_layout.addWidget(rb)

        self.size_input = QtWidgets.QSpinBox()
        self.size_input.setMinimum(1)
        self.size_input.setMaximum(100)
        self.size_input.setValue(2)

        self.apply_particle_button = QtWidgets.QPushButton("Apply Particle Settings")
        self.apply_particle_button.clicked.connect(self.apply_particle_settings)

        particle_layout.addWidget(QtWidgets.QLabel("Particle Size"))
        particle_layout.addWidget(self.size_input)
        particle_layout.addWidget(self.apply_particle_button)

        particle_group.setLayout(particle_layout)
        main_layout.addWidget(particle_group)

        # --- Normal Scale (GroupBox) ---
        normal_group = QtWidgets.QGroupBox("Normal Scale")
        normal_layout = QtWidgets.QVBoxLayout()

        self.normal_scale_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.normal_scale_slider.setMinimum(1)
        self.normal_scale_slider.setMaximum(100)
        self.normal_scale_slider.setValue(int(initial_normal * 10))

        self.normal_scale_input = QtWidgets.QLineEdit(f"{initial_normal:.1f}")
        self.normal_scale_input.setValidator(QtGui.QDoubleValidator(0.1, 100.0, 3))

        self.apply_normal_button = QtWidgets.QPushButton("Apply Normal Scale")
        self.apply_normal_button.clicked.connect(self.apply_normal_scale)

        normal_layout.addWidget(self.normal_scale_slider)
        normal_layout.addWidget(self.normal_scale_input)
        normal_layout.addWidget(self.apply_normal_button)

        normal_group.setLayout(normal_layout)
        main_layout.addWidget(normal_group)

        # --- Vector Scale (GroupBox) ---
        vector_group = QtWidgets.QGroupBox("Vector Scale")
        vector_layout = QtWidgets.QVBoxLayout()

        self.vector_scale_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.vector_scale_slider.setMinimum(1)
        self.vector_scale_slider.setMaximum(100)
        self.vector_scale_slider.setValue(int(initial_vector * 10))

        self.vector_scale_input = QtWidgets.QLineEdit(f"{initial_vector:.1f}")
        self.vector_scale_input.setValidator(QtGui.QDoubleValidator(0.1, 100.0, 3))

        self.apply_vector_button = QtWidgets.QPushButton("Apply Vector Scale")
        self.apply_vector_button.clicked.connect(self.apply_vector_scale)

        vector_layout.addWidget(self.vector_scale_slider)
        vector_layout.addWidget(self.vector_scale_input)
        vector_layout.addWidget(self.apply_vector_button)

        vector_group.setLayout(vector_layout)
        main_layout.addWidget(vector_group)

        # --- 3D Texture (GroupBox) ---
        texture_3d_group = QtWidgets.QGroupBox("3D Texture Max Resolution")
        texture_3d_layout = QtWidgets.QVBoxLayout()

        self.texture_3d_input = QtWidgets.QLineEdit(str(initial_tex3d))
        self.texture_3d_input.setValidator(QtGui.QIntValidator(64, 4096))

        self.apply_texture_3d_button = QtWidgets.QPushButton("Apply 3D Texture Max Res")
        self.apply_texture_3d_button.clicked.connect(self.apply_texture_3d)

        texture_3d_layout.addWidget(self.texture_3d_input)
        texture_3d_layout.addWidget(self.apply_texture_3d_button)

        texture_3d_group.setLayout(texture_3d_layout)
        main_layout.addWidget(texture_3d_group)

        # --- 2D Texture (GroupBox) ---
        texture_2d_group = QtWidgets.QGroupBox("2D Texture Max Resolution")
        texture_2d_layout = QtWidgets.QVBoxLayout()

        self.texture_2d_input = QtWidgets.QLineEdit(str(initial_tex2d))
        self.texture_2d_input.setValidator(QtGui.QIntValidator(64, 4096))

        self.apply_texture_2d_button = QtWidgets.QPushButton("Apply 2D Texture Max Res")
        self.apply_texture_2d_button.clicked.connect(self.apply_texture_2d)

        texture_2d_layout.addWidget(self.texture_2d_input)
        texture_2d_layout.addWidget(self.apply_texture_2d_button)

        texture_2d_group.setLayout(texture_2d_layout)
        main_layout.addWidget(texture_2d_group)

        self.setLayout(main_layout)

        # スライダーと数値入力の同期
        self.normal_scale_slider.valueChanged.connect(self.update_normal_scale_input)
        self.normal_scale_input.textChanged.connect(self.update_normal_scale_slider)

        self.vector_scale_slider.valueChanged.connect(self.update_vector_scale_input)
        self.vector_scale_input.textChanged.connect(self.update_vector_scale_slider)

    def get_viewport_settings(self):
        """ビューポートの設定オブジェクトを取得"""
        scene_viewer = hou.ui.paneTabOfType(hou.paneTabType.SceneViewer)
        if not scene_viewer:
            QtWidgets.QMessageBox.warning(self, "エラー", "SceneViewer が見つかりません。")
            return None
        return scene_viewer.curViewport().settings()

    def update_normal_scale_input(self, value):
        """スライダー変更時に QLineEdit を更新"""
        self.normal_scale_input.setText(f"{value / 10.0:.1f}")

    def update_normal_scale_slider(self):
        """QLineEdit 変更時にスライダーを更新"""
        try:
            value = float(self.normal_scale_input.text())
            self.normal_scale_slider.setValue(int(value * 10))
        except ValueError:
            pass  # 数値変換エラー時は無視

    def update_vector_scale_input(self, value):
        """スライダー変更時に QLineEdit を更新"""
        self.vector_scale_input.setText(f"{value / 10.0:.1f}")

    def update_vector_scale_slider(self):
        """QLineEdit 変更時にスライダーを更新"""
        try:
            value = float(self.vector_scale_input.text())
            self.vector_scale_slider.setValue(int(value * 10))
        except ValueError:
            pass  # 数値変換エラー時は無視

    def apply_particle_settings(self):
        """パーティクルの表示タイプとサイズを適用"""
        ptype = [ptype for ptype, rb in self.radio_buttons.items() if rb.isChecked()][0]
        hou.hscript(f"viewdisplay -P {ptype} *")

        psize = self.size_input.value()
        hou.hscript(f"viewdisplay -p {psize} *")

        #print(f"Particle Type: {ptype}, Size: {psize} を適用しました。")

    def apply_normal_scale(self):
        """Scale Normal を適用"""
        settings = self.get_viewport_settings()
        if settings:
            normal_scale = float(self.normal_scale_input.text())
            settings.setNormalScale(normal_scale)
            #print(f"Normal Scale を {normal_scale} に設定しました。")

    def apply_vector_scale(self):
        """Scale Vectors を適用"""
        settings = self.get_viewport_settings()
        if settings:
            vector_scale = float(self.vector_scale_input.text())
            settings.setVectorScale(vector_scale)
            #print(f"Vector Scale を {vector_scale} に設定しました。")

    def apply_texture_3d(self):
        """3D Texture Max Resolution を適用"""
        settings = self.get_viewport_settings()
        if settings:
            tex_3d_res = int(self.texture_3d_input.text())  # 1つの入力を取得
            settings.setTextureMaxRes3D((tex_3d_res, tex_3d_res, tex_3d_res))  # (width, height, depth) に適用
            #print(f"3D Texture Max Res を ({tex_3d_res}, {tex_3d_res}, {tex_3d_res}) に設定しました。")
    
    def apply_texture_2d(self):
        """2D Texture Max Resolution を適用"""
        settings = self.get_viewport_settings()
        if settings:
            tex_2d_res = int(self.texture_2d_input.text())  # 1つの入力を取得
            settings.setTextureMaxRes2D((tex_2d_res, tex_2d_res))  # (width, height) に適用
            #print(f"2D Texture Max Res を ({tex_2d_res}, {tex_2d_res}) に設定しました。")

widget = MyWidget()
widget.show()

4. Sticky Note Pad

StickyNote の“地味な不満”を潰す

sse_StickyNotePad.gif

何をするツールか

StickyNote 作成専用の UI です。

  • 文字サイズ/文字色/背景色を指定
  • テキスト量に応じて 表示枠を自動調整
  • 選択ノードの近くに配置

デメリット(正直に)

  • 作成位置の基準としてノード選択が必須

デフォルトの StickyNote のように
「何も選ばずに作成」したかったのですが、

今回は“選択ノード基準”で置く実装にしています。
(カーソル位置だけで安定させる方法が見つからず)

どうやって実現しているか

表示枠サイズの計算には、
いくつかのマジックナンバーを使っています。
フォントサイズや表示倍率によって最適な値が変わるため、

実用上それっぽく見える値を採用しています。

より安定したサイズ算出方法や、
ノード選択なしで配置する良い方法をご存じの方がいれば、
教えていただけると助かります。

# StickyNoteのサイズ計算
lines = text.split('\n')
max_line_length = max(len(line) for line in lines)
sticky_note_width = max_line_length * font_size * 0.55  # 表示枠に収める係数(幅)
ticky_note_height = len(lines) * font_size * 1.5       # 表示枠に収める係数(高さ)

Hotkey登録の一例:Ctrl + Shift + P (Network Pane)

※ デフォルトのStickyNoteはShift + P

おすすめアイコン:BUTTONS_network_sticky

フルコード(クリックで展開)
from PySide2 import QtWidgets, QtGui, QtCore
import hou

class StickyNoteCreator(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(StickyNoteCreator, self).__init__(parent)
        self.setWindowTitle("Sticky Note Pad")

        # ウィンドウを常に最前面に表示するフラグを設定
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)

        self.text_edit = QtWidgets.QTextEdit()
        self.hide_background_check = QtWidgets.QCheckBox("Hide Background")

        # フォントサイズスピンボックスを作成
        self.font_size_spinbox = QtWidgets.QDoubleSpinBox()
        self.font_size_spinbox.setRange(0.5, 100.0)  # 修正: 0.1 から 100.0 に変更
        self.font_size_spinbox.setValue(2.0) #1.0=Large, 3.0=ExtraLarge
        self.font_size_spinbox.setDecimals(1)
        self.font_size_spinbox.setFixedWidth(70)

        # フォントサイズラベルとスピンボックスのレイアウト
        self.font_size_layout = QtWidgets.QHBoxLayout()
        self.font_size_label = QtWidgets.QLabel("Font Size :")
        self.font_size_layout.addWidget(self.font_size_label)
        self.font_size_layout.addStretch()  # 右詰めにするためのスペーサー
        self.font_size_layout.addWidget(self.font_size_spinbox)

        # 背景色ラベルとボタンのレイアウト
        self.background_color_layout = QtWidgets.QHBoxLayout()
        self.background_color_label = QtWidgets.QLabel("Background Color :")
        self.background_color_button = QtWidgets.QPushButton()
        self.background_color_button.setFixedWidth(70)
        self.background_color_button.clicked.connect(self.select_background_color)
        self.background_color_layout.addWidget(self.background_color_label)
        self.background_color_layout.addStretch()  # 右詰めにするためのスペーサー
        self.background_color_layout.addWidget(self.background_color_button)

        # 文字色ラベルとボタンのレイアウト
        self.text_color_layout = QtWidgets.QHBoxLayout()
        self.text_color_label = QtWidgets.QLabel("Text Color :")
        self.text_color_button = QtWidgets.QPushButton()
        self.text_color_button.setFixedWidth(70)
        self.text_color_button.clicked.connect(self.select_text_color)
        self.text_color_layout.addWidget(self.text_color_label)
        self.text_color_layout.addStretch()  # 右詰めにするためのスペーサー
        self.text_color_layout.addWidget(self.text_color_button)

        # 全体のレイアウト設定
        self.layout = QtWidgets.QVBoxLayout()
        self.layout.addWidget(self.text_edit)
        self.layout.addWidget(self.hide_background_check)
        self.layout.addLayout(self.font_size_layout)  # フォントサイズレイアウトを追加
        self.layout.addLayout(self.background_color_layout)
        self.layout.addLayout(self.text_color_layout)

        # OK/キャンセルボタン
        self.button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
        self.button_box.accepted.connect(self.accept)
        self.button_box.rejected.connect(self.reject)
        self.layout.addWidget(self.button_box)

        self.setLayout(self.layout)

        # 初期背景色と文字色の設定
        self.background_color = QtGui.QColor(82, 82, 82)
        self.text_color = QtGui.QColor(214, 214, 214)
        self.update_button_color(self.background_color_button, self.background_color)
        self.update_button_color(self.text_color_button, self.text_color)

    def update_button_color(self, button, color):
        pixmap = QtGui.QPixmap(30, 30)
        pixmap.fill(color)
        button.setIcon(QtGui.QIcon(pixmap))

    def select_background_color(self):
        color = QtWidgets.QColorDialog.getColor(self.background_color, self)
        if color.isValid():
            self.background_color = color
            self.update_button_color(self.background_color_button, color)

    def select_text_color(self):
        color = QtWidgets.QColorDialog.getColor(self.text_color, self)
        if color.isValid():
            self.text_color = color
            self.update_button_color(self.text_color_button, color)

    def accept(self):
        text = self.text_edit.toPlainText()
        hide_background = not self.hide_background_check.isChecked()
        font_size = self.font_size_spinbox.value()

        # 親ノードの取得
        selected_nodes = hou.selectedNodes()
        if not selected_nodes:
            print("Please select some nodes.")
            return
        parent = selected_nodes[0].parent()

        # 中心位置の計算
        list_x_of_selected_nodes = [node.position().x() for node in selected_nodes]
        list_y_of_selected_nodes = [node.position().y() for node in selected_nodes]
        center_x = sum(list_x_of_selected_nodes) / len(selected_nodes)
        center_y = sum(list_y_of_selected_nodes) / len(selected_nodes)

        # StickyNoteのサイズ計算
        lines = text.split('\n')
        max_line_length = max(len(line) for line in lines)
        sticky_note_width = max_line_length * font_size * 0.55  # 表示枠に収める係数(幅)
        sticky_note_height = len(lines) * font_size * 1.5       # 表示枠に収める係数(高さ)

        # StickyNoteの作成
        sticky_note = parent.createStickyNote()
        sticky_note.setPosition(hou.Vector2(center_x, center_y))
        sticky_note.setText(text)
        sticky_note.setTextSize(font_size)
        sticky_note.setSize(hou.Vector2(sticky_note_width, sticky_note_height))
        sticky_note.setTextColor(hou.Color(self.text_color.redF(), self.text_color.greenF(), self.text_color.blueF()))
        sticky_note.setColor(hou.Color(self.background_color.redF(), self.background_color.greenF(), self.background_color.blueF()))
        sticky_note.setDrawBackground(hide_background)

        # StickyNoteの位置調整
        left_most_x = min(list_x_of_selected_nodes)
        up_most_y = max(list_y_of_selected_nodes)
        sticky_note_position_x = left_most_x
        sticky_note_position_y = up_most_y + sticky_note_height * 0.1
        sticky_note_position = hou.Vector2(sticky_note_position_x, sticky_note_position_y)
        sticky_note.setPosition(sticky_note_position)

"""
DESCRIPTION : StickyNote作成インターフェイスを表示
"""
app = QtWidgets.QApplication.instance()
if not app:
    app = QtWidgets.QApplication([])
dialog = StickyNoteCreator()
dialog.show()

5. Travel Playbar

フレーム比較をパカパカやる用

sse_TravelPlaybar.gif

何をするツールか

任意のフレーム番号を入力し、
ボタンを押すだけでそのフレームへ移動するシンプルなツールです。

どんなときに有効か

異なるフレーム間を パカパカ切り替えながら比較したいときに便利です。

補足

入力欄を 3 つ用意していますが、どれも機能としては同じです。


Hotkey登録の一例:Alt + Shift + B (Global)
おすすめアイコン:PLAYBAR_editable_keys

フルコード(クリックで展開)
import hou
from PySide2 import QtWidgets, QtCore, QtGui

class MyWindow(QtWidgets.QWidget):
    def __init__(self):
        super(MyWindow, self).__init__()
        self.setWindowTitle("Travel Playbar")
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)

        self.button_a = QtWidgets.QPushButton('go', self)
        self.button_b = QtWidgets.QPushButton('go', self)
        self.button_c = QtWidgets.QPushButton('go', self)

        start_frame = int(hou.playbar.playbackRange()[0])
        end_frame = int(hou.playbar.playbackRange()[1])

        self.input_a = QtWidgets.QLineEdit(str(start_frame), self)
        self.input_b = QtWidgets.QLineEdit(self)
        self.input_c = QtWidgets.QLineEdit(str(end_frame), self)

        self.layout = QtWidgets.QVBoxLayout(self)

        self.input_layout = QtWidgets.QHBoxLayout()
        self.input_layout.addWidget(self.input_a)
        self.input_layout.addWidget(self.input_b)
        self.input_layout.addWidget(self.input_c)

        self.button_layout = QtWidgets.QHBoxLayout()
        self.button_layout.addWidget(self.button_a)
        self.button_layout.addWidget(self.button_b)
        self.button_layout.addWidget(self.button_c)

        self.layout.addLayout(self.input_layout)
        self.layout.addLayout(self.button_layout)

        self.button_a.clicked.connect(self.on_button_a_clicked)
        self.button_b.clicked.connect(self.on_button_b_clicked)
        self.button_c.clicked.connect(self.on_button_c_clicked)

    def on_button_a_clicked(self):
        target_frame = int(self.input_a.text())
        hou.setFrame(target_frame)

    def on_button_b_clicked(self):
        target_frame = int(self.input_b.text())
        hou.setFrame(target_frame)

    def on_button_c_clicked(self):
        target_frame = int(self.input_c.text())
        hou.setFrame(target_frame)

ui = MyWindow()
ui.show()


6. Screenshot to BG Image

ビューポートをコピペする

sse_ScreenshotToBGImage.gif

何をするツールか

現在のビューポートをキャプチャし、
その画像を Network View の Background Image として配置します。

どんなときに有効か

  • レイアウトの意図を残したいとき

  • ノード構成と見た目を対応づけたいとき

  • 後から見返すためのスナップショット

といった用途を想定しています。

仕様

  • Network Image と Null ノードを対応づけて管理

  • ノード削除・フラグ変更に応じて背景画像も追従

  • 画像は $HIP/screenshots 以下に保存

  • Null ノードのコメントに日時とフレーム番号を付与

「背景画像を貼る」だけで終わらせず、
後から整理・削除しやすい構成にしています。

元ネタ

このツールは、以下のページにあるコードをベースにしています。

Houdini Python – bernie’s
https://berniebernie.fr/wiki/Houdini_Python#screenshot_to_background_image

元のコードでは外部ツールによるキャプチャを使用していますが、
Houdini 内で完結するように調整しています。


おすすめアイコン:BUTTONS_capture

フルコード(クリックで展開)
import hou
import os
import subprocess
import datetime
import nodegraphutils as utils
from time import gmtime, strftime

widthRatio = 4

def takeScreenShot(systempath):
    # ビューポートのカメラパスを取得
    cur_desktop = hou.ui.curDesktop()
    desktop = cur_desktop.name()
    viewer = hou.paneTabType.SceneViewer
    panetab = cur_desktop.paneTabOfType(viewer).name()
    persp = cur_desktop.paneTabOfType(viewer).curViewport().name()
    camera_path = desktop + '.' + panetab + '.' + 'world.' + persp

    # スクリーンショットのファイル名を作成
    filename = systempath

    # スクリーンショットを撮影
    if filename is not None:
        frame = hou.frame()
        hou.hscript("viewwrite -f %d %d %s '%s'" % (frame, frame, camera_path, filename))

    return filename
   
def removeBackgroundImage(**kwargs):
    ''' erases bg image from tuples of backgroundImages() if it can find it, updates bg '''
    deletingNode = [x[1] for x in  kwargs.items()][0]
    image = deletingNode.parm('houdinipath').eval()
    editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
    backgroundImagesDic = editor.backgroundImages()
    backgroundImagesDic = tuple(x for x in backgroundImagesDic if x.path() != image)
    editor.setBackgroundImages(backgroundImagesDic)
    utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)

def changeBackgroundImageBrightness(event_type,**kwargs):
    ''' changes brightness/visibility if template or bypass flags are checked -- its poorly written/thought but i was tired'''
    nullNode = [x[1] for x in  kwargs.items()][0]
    image = nullNode.parm('houdinipath').eval()
    brightness = 1.0
    if nullNode.isBypassed():
        brightness = 0.0
    elif nullNode.isTemplateFlagSet():
        brightness = 0.5
    editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
    backgroundImagesDic = editor.backgroundImages()
    i = 0
    for item in backgroundImagesDic:
        if item.path() == image:
            backgroundImagesDic[i].setBrightness(brightness)
            break
        i = i + 1
    editor.setBackgroundImages(backgroundImagesDic)
    utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)
    
#generate unique(ish) path for screenshot
timestamp = strftime('%Y%m%d_%H%M%S', gmtime())
hipname = str(hou.getenv('HIPNAME'))
hippath = str(hou.getenv('HIP')) + '/screenshots'
screenshotName = hipname + '.' + timestamp + '.jpg'
systempath = hippath + '\\' + screenshotName
houdinipath = '$HIP/screenshots/'+screenshotName
try: 
    os.makedirs(os.path.dirname(systempath))
except OSError:
    if not os.path.isdir(os.path.dirname(systempath)):
        raise

#take screenshot with capture region
takeScreenShot(systempath)


#set up background image plane
editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
image = hou.NetworkImage()
image.setPath(houdinipath)
sel = hou.selectedNodes()
nullNode = ''


if sel:
    lastSel = sel[-1]
    nullNode = lastSel.parent().createNode('null','screenshot')
    if lastSel.outputConnections():
        nullNode.setInput(0,lastSel)

else:
    nullNode = editor.pwd().createNode('null','screenshot')
    nullNode.moveToGoodPosition()
    lastSel = nullNode

# for New Null
# 現在の日時を取得
time = datetime.datetime.now()
# 日時を文字列に変換
time_str = time.strftime("%Y.%m.%d__%H:%M:%S")
current_frame = hou.frame()
 
#configure image plane placement
nullNode.setUserData('nodeshape','camera')
nullNode.setPosition(lastSel.position())
nullNode.setColor(hou.Color(.3,.3,.3))
nullNode.setComment(time_str + "__" + str(current_frame) + "F")  # コメントに日時を設定
nullNode.setGenericFlag(hou.nodeFlag.DisplayComment,True)  # コメントをネットワークビューで表示
nullNode.move([lastSel.size()[0]*2,-lastSel.size()[1]*2])

rez = hou.imageResolution(systempath)
ratio = 1.0*rez[1]/rez[0]
rect = hou.BoundingRect(0,-lastSel.size()[1]*1.1-0.5,widthRatio,-widthRatio*ratio-lastSel.size()[1]*1.1-0.5)
image.setRelativeToPath(nullNode.path())
image.setRect(rect)

hou_parm_template_group = hou.ParmTemplateGroup()
hou_parm_template = hou.LabelParmTemplate("houdinipath", "Label", column_labels=(['\\'+houdinipath]))
hou_parm_template.hideLabel(True)
hou_parm_template_group.append(hou_parm_template)
nullNode.setParmTemplateGroup(hou_parm_template_group)


nullNode.addEventCallback((hou.nodeEventType.BeingDeleted,), removeBackgroundImage)

#attach a function to change visibility or opacity if corresponding node flags are changed
nullNode.addEventCallback((hou.nodeEventType.FlagChanged,), changeBackgroundImageBrightness)

#add image to network background
backgroundImagesDic = editor.backgroundImages()
backgroundImagesDic = backgroundImagesDic + (image,)
editor.setBackgroundImages(backgroundImagesDic)
utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)



7. Paste as Obj Merges

何も考えずノードをコピペしたい (Object Merge編)

sse_PasteAsObjmerge.gif

何をするツールか

クリップボードにコピーしたノードパスを元に、
Object Merge ノードとしてまとめて生成します。

元ネタ

このツールも" 6. Screenshot to BG Image " と同様に、下記から流用させてもらっています。

Houdini Python – bernie’s
https://berniebernie.fr/wiki/Houdini_Python#screenshot_to_background_image

補足

  • 貼り付け位置はマウスポインタの位置が基準になるため、Hotkey 実行前提です。

Hotkey登録の一例:Alt + Ctrl + V (Network Pane)

おすすめアイコン:SOP_object_merge

フルコード(クリックで展開)
import hou

def main():
    network = hou.ui.curDesktop().paneTabUnderCursor()
    networkpath = network.pwd().path()
    pos = network.cursorPosition()

    clipboard = hou.ui.getTextFromClipboard()

    n = 0

    if clipboard:
        list = clipboard.split()
        for item in list:
            if hou.node(item) != None:
                merge = hou.node(networkpath).createNode('object_merge','merge_'+item.split('/')[-1])
                merge.parm('objpath1').set(str(item))
                merge.setPosition(pos)
                merge.move([n*2,0])
                if n == 0:
                    merge.setSelected(True,True)
                else:
                    merge.setSelected(True,False)
                n = n + 1
                
main()



8. Paste as Fetches

何も考えずノードをコピペしたい (ROP Fetch編)

sse_PasteAsFetches.gif

何をするツールか

クリップボードにあるノードパスを元に、
Fetch ノードを自動生成します。

どうやって実現しているか

Fetch ノードの source パラメータは、
ノードタイプによって参照すべきパスが異なるため、
コピー元のノードタイプに応じて自動で切り替えるようにしています。

具体的には:

  • filecache / vellumio / rbdio
    → 実行用ノードが一段下にあるため、/render を付与

  • ROP Alembic Output など
    → そのままノードパスを指定

という形で分岐しています。

補足1

現時点で /render の自動付与に対応しているのは以下のノードのみです。

  • filecache

  • vellumio

  • rbdio

補足2

自作HDA含む、他のノードも対応させたい場合、elifで下記の条件を追加してください。

"sse_AET_exporter"という自作HDAの例

            elif is_node_type(node, "sse_AET_exporter"):
                source_param = node.path() + "/for_Dive/export_alembic"

挿入例

        for item in items:
            node = hou.node(item)
            if node is None: 
            # Pythonの推奨スタイル(PEP8)では is not None を使用します。
            # 流用元では if node != None:が使用されていた。
                continue
            # 選択したノードのタイプに基づいてsourceパラメータを設定
            if is_node_type(node, "filecache"):
                source_param = node.path() + "/render"
            elif is_node_type(node, "vellumio") or is_node_type(node, "kinefx::characterio") or is_node_type(node, "rbdio"):
                source_param = node.path() + "/filecache/render"

            # 例:独自HDAも拾いたい場合はここで追加
            elif is_node_type(node, "sse_AET_exporter"):
                source_param = node.path() + "/for_Dive/export_alembic"
                
            else:
                source_param = node.path()

補足3

  • 貼り付け位置はマウスポインタの位置が基準になるため、Hotkey 実行前提です。

Hotkey登録の一例:Alt + Ctrl + F (Network Pane)

おすすめアイコン:COP2_fetch

フルコード(クリックで展開)
import hou
import re

def pasteAsFetches():
    network = hou.ui.curDesktop().paneTabUnderCursor()
    if not hasattr(network, "pwd"):
        raise RuntimeError("Mouse must be over a network editor.")
        
    networkpath = network.pwd().path()
    pos = network.cursorPosition()
    clipboard = hou.ui.getTextFromClipboard()
    n = 0

    def is_node_type(node, base_type):
        node_type_name = node.type().name()
        pattern = re.compile(f"^{base_type}(::\d+\.\d+)?$")
        return bool(pattern.match(node_type_name))

    if clipboard:
        items = clipboard.split() # list はPythonの組み込み型名と衝突するため、誤動作や補完の妨げになります。わかりやすく items に変更しました。

        for item in items:
            node = hou.node(item)
            if node is None: # Pythonの推奨スタイル(PEP8)では is not None を使用します。(old: if node != None:)
                continue
            # 選択したノードのタイプに基づいてsourceパラメータを設定
            if is_node_type(node, "filecache"):
                source_param = node.path() + "/render"
            elif is_node_type(node, "vellumio") or is_node_type(node, "kinefx::characterio") or is_node_type(node, "rbdio"):
                source_param = node.path() + "/filecache/render"
            else:
                source_param = node.path()
            # /out階層にfetchノードを作成
            fetch = hou.node(networkpath).createNode("fetch", "fetch_" + item.split('/')[-1])
            # sourceパラメータを設定
            fetch.parm("source").set(source_param)
            # ノードの位置を設定(重ならないようにする)
            fetch.setPosition(pos)
            fetch.move([n * 2, 0])
            fetch.setSelected(True, clear_all_selected=(n == 0))
            n += 1
            
pasteAsFetches()


おわりに

今回紹介したツールは、
どれも目新しいことをしているわけではありません。

普段作業している中で、
「毎回やっているけど、地味に面倒だな」と感じた操作を、
そのままツールにしてみたり、流用したりしただけのものです。

自分の作業を少しでも止めないために作ったものですが、
似たような作業をしている方の手元でも、
何か一つでも役に立てば嬉しいです。


ここまで読んでいただき、ありがとうございました。

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?