このattribute、どこで定義したんだっけ…をpythonで探す

毎年恒例、アドカレ執筆を兼ねてHoudiniを触ろうの回。

:::note info
例によってSOP x Pythonで遊ぶ趣味ユーザによるapprentice枠での投稿です。
何ならPythonもご無沙汰なのでご指摘、代替メソッド案など大歓迎。
:::

↑ インフォメーションノートを使ってみたかっただけのやつ。

ということで [Houdini Apprentice Advent Calendar 2022](https://qiita.com/advent-calendar/2022/happrentice) の22日目。
去年はニッチで膨大なものを書いてしまったので今年はシンプルに、ほぼPythonコードを説明するだけの記事です。シンプルな割に個人的にはけっこう使いそうだなってのが出来上がりました。



# 導入
### モチベーション

気付けば年末。アドカレネタ探しに、趣味で作成したhipファイルを見返してみて思った。

**…どこで何してるかわからん、、何も覚えてない。**

なにやらベンリなattributeを作成して分岐処理をしているようだけど、このattributeの定義何だっけ…てかどこで定義したんだ…？ノード名見てもわからんし1個ずつ見るのもめんどくせええええ、、、ってなったので解決することにした。完全に自分用。

正直記事完成した今でも思っているのだけど、自分のぐぐり力が低いだけでこの機能Houdiniにすでにありそう。



### 出来上がるもの

##### Python SOP版(初期案)
https://twitter.com/shin_t_o_/status/1588171721185824769

こちらに思ったより反応頂いたのが今回記事化するきっかけに。


##### HDA版
https://twitter.com/shin_t_o_/status/1603410616684642306


検索したいattributeを持っている任意のノードにPython SOPないしHDAを繋いで

- [x] 検索区間
  - プルダウン形式： 上流のみ / 同一階層のネットワーク全体
- [x] 検索対象のattribute
  - プルダウン形式

を指定すると、下記みたいな感じでコンソール出力／DisplayFlagをONにしてくれる。Python SOPではその辺りの設定はベタ打ち。


### 実行環境
##### 作成環境
- 端末: WindowsデスクトップPC
- OS: Windows 10 Home 22H2
- Houdini: 19.5.363 Indie
- python: 3.9.10 (上記Houdiniバージョンにインストールされているもの)
- 外部モジュール: importなし

##### 動作確認環境
- 端末: M1 Macbook Air
- OS: macOS Monterey 12.4
- Houdini: 19.5.303 Apprentice
- Python: 3.7.13
- 外部モジュール: importなし

### 完成ファイル
[アドカレ用リポジトリ](https://github.com/shin-t-o/houdini-files/tree/main/advent-calendar/2022)


# Python SOP版

### ロジック

1. 検索範囲にあるノードを列挙
2. その中から検索対象であるtarget attribute( =`target_attr` )を含むノードを抽出
3. 更にその中での「最上流」を求める

これだけ。



### コード

取り敢えずこれをPython SOPに貼って「設定部分」を書き換えれば動く、のやつ（python3.8以降なら）。
正直読みにくいが、式中で型が混在してややこしいのでコメント+型ヒントマシマシ。

特に保持したい状態もなく、処理を繋げたいだけだったのでclassは使っていない。

```python
# -------------------------------------------------
# 設定部分
# -------------------------------------------------
# 検索範囲を指定
# (1) Python SOPノードを繋いだ先の「上流」のみを調査する場合はこっち
# scope: str = "upstream_only"
# (2) Python SOPノードを置いた「階層」全体を調査する場合はこっち
scope: str = "current_dir"
# 検索対象attributeを指定
target_attr: str = "pAttr"


# -------------------------------------------------
# 実行部分
# -------------------------------------------------
def exec_search(scope: str, target_attribute: str):
    # 親フォルダを取得
    parent_dir: str = hou.pwd().path().replace("/" + hou.pwd().name(), "")
    # 同一階層のSOPノードリストを取得
    current_sop: list[hou.SopNode] = hou.node(parent_dir).children()
    # "TemplateフラグをOFFにする" 処理を実行
    for sop in current_sop:
        sop.setTemplateFlag(False)

    if scope == "upstream_only":
        # 繋いだノードの「子孫（＝上流ノード）」を候補として取得
        base_sop: list[hou.SopNode] = hou.pwd().inputAncestors()
    elif scope == "current_dir":
        # 同一階層のノード全てを候補として取得
        base_sop: list[hou.SopNode] = current_sop

    # 指定の条件でfilter処理を実行
    result_sop: list[hou.SopNode] = filter_nodes_by_attribute(source_sop=base_sop, attr=target_attribute)

    # print(" ============================= ")
    if len(result_sop) == 0:
        print("No node found.")
    else:
        # (1) 候補を出力したい場合はこっち
        # print("result_sop: ", get_node_name_from_sop_list(sop_list=result_sop))
        # (2) 候補となるノードを選択状態 + Template Flag ONにしたい場合はこっち
        for sop in result_sop:
            sop.setSelected(True)
            sop.setTemplateFlag(True)


exec_search(scope=scope, target_attribute=target_attr)

```


<details><summary>途中で使われている関数はこちら（長いので別に切り出し）</summary>

```python
def has_attrib(geo: hou.Geometry, attr: str) -> bool:
    """
    ジオメトリの指定attribute保持有無を返却
    """
    return not (
        (geo.findPointAttrib(attr) is None)
        & (geo.findVertexAttrib(attr) is None)
        & (geo.findPrimAttrib(attr) is None)
        & (geo.findGlobalAttrib(attr) is None)
    )


def filter_nodes_by_attribute(source_sop: list[hou.SopNode], attr: str) -> list[hou.SopNode]:
    """
    sourece_sopノード群のうち、attrを含むノードのみ抽出しその下流を削除したうえで返却

    Args:
        source_sop: 検索元SopNodeリスト
        attr: 抽出条件attribute

    Returns:
        filtered_sop: 抽出 + 下流削除後のノード名リスト
    """
    filtered_sop: list[hou.SopNode] = []
    for source in source_sop:
        try:
            if has_attrib(source.geometry(), attr):
                filtered_sop.append(source)
        except AttributeError:
            continue

    # 下流削除関数に通して返却
    return remove_downstream_nodes(filtered_sop)


def remove_downstream_nodes(source_sop: list[hou.SopNode]) -> list[hou.SopNode]:
    """ノード群に於いて下流に当たるノードを除外

    source_sopノード群のうち「ワイヤーで繋がっているもの」を探索しその最上流以外を除去する
    独立したノード群を受け取っても変更操作はしない

    Args:
        source_sop: フィルタ適用前SopNodeリスト

    Returns:
        filtered_sop: フィルタ適用後SopNodeリスト
    """
    filtered_sop: list[hou.SopNode] = []
    source_list: list[str] = [x.name() for x in source_sop]

    for sop in source_sop:
        # 各ノードに対して "そのノードのinputノード" リストを取得
        input_list: list[str] = [x.name() for x in sop.inputs()]
        # "inputノードリスト" と "（元の）検索範囲のノードリスト" の集合が無いもののみ別途保持
        if not set(input_list) & set(source_list):
            filtered_sop.append(sop)

    return filtered_sop


def get_node_name_from_sop_list(sop_list: list[hou.SopNode]) -> list[str]:
    """
    SopNodeリストを受け取り、その名称（str）のリストにして返却 
    """
    result: list[str] = []

    for sop in sop_list:
        result.append(sop.name())

    return result
```
</details>

元々 [型ヒント](https://docs.python.org/ja/3/library/typing.html) ありで書いていてここでもその通りに紹介しているが、試しにMacに持っていったらエラーが噴出したので除外バージョンもリポジトリに置いてみた。

Mac版HoudiniのPythonバージョンが低く、対応していない書き方があるのを忘れてた。。




### 補足

メモ代わりなので箇条書きで。

- 検索範囲は別に分岐させる必要はない
    - `upstream_only` 実行後に見つからなかったら `current_dir` など
    - 更に別階層も探索、なども良い
        - その辺りは用途に合わせて調整が良さそう
- 増やそうと思えば設定項目は色々増やせる
    - inputノードの何番目（input1 ~ input4）まで探索するか
        - `in sop.inputs()` 箇所で全取得 -> 個数取得に切り替える、など
    - 「上流」の検索時にsubnet内も含める、クックノードのみに絞る、など
        - `hou.SopNode.inputAncestors()` の引数参照
        - https://www.sidefx.com/ja/docs/houdini/hom/hou/SopNode.html
    - `Object Merge` ノードは除くオプション、など
        - 「他のネットワークを参照する」 `Object Merge` ノードは最上流という抽出条件を満たしやすい


# HDA版

ベースとなるロジックがあれば、それをどう活用するかは各自のやり方によるので蛇足かな…とは思いつつ、せっかくなのでHDA作成の練習も兼ねて作ってみた。

:::note warn
直前に[@d658tさんが「HDA作成ガイドライン」記事をあげられていた](https://qiita.com/d658t/items/a9f785d741037021e6ee)ので、そちらを参考にさせて頂きアップロードファイルを少しだけ修正。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152044/d1b66e56-c420-4edd-cad8-b3a06b5d4dc6.png" width="400">

- namespaceとしてのauthor / branch指定
- 初期はバージョンを含めない
- ノード名や保存名は汚染しない
辺りを意識すると、現バージョンでのHDA作成ではこんなイメージ…？

これで名前空間分けたことになってるんだろか。
でも呼び出すときの階層に `/utils` が含まれてなかった。。
:::

結果は最初に見せた通り。
使っているPythonコードも既に説明したものを各地に散らしただけなので、HDA内PythonModule部以外について軽く補足する。

やりたいことはPythonModuleとMenu Scriptで実現してしまったのでsubnetの中身は空。
そのガワとしての機能だけ利用することになった。

#### Ordered Menu

とりあえずコード（型ヒント除去版）を。

```python: [Search Target Attribute]
def getAttrList(geo):
    """
    対象ジオメトリの全attributeリスト（文字列）を取得
    """
    attrList = []
    attrList.extend([ x.name() for x in geo.pointAttribs() ])
    attrList.extend([ x.name() for x in geo.primAttribs() ])
    attrList.extend([ x.name() for x in geo.globalAttribs() ])
    return attrList

def is_connected():
    """
    そのノードのinputに何かつながっていたらTrue, なければFalseを返却
    """
    inputs = hou.pwd().inputs()
    return True if len(list(inputs)) > 0 else False

result = ["default", "-- connect HDA to any node --"]

if is_connected():
    geo = hou.pwd().geometry()
    attrs = list(set([ x for x in getAttrList(geo)]))
    result = sum(zip(attrs, attrs),())

return result
```

上記Pythonコードによって

- HDAに何も繋いでいないときは `-- connect HDA to any node --` のみが表示される
- 繋がったらその「繋げた先ノード」のattributeリストを取得し、プルダウンに表示
  - "P" のような汎用的なattributeはここから除外しても良いかも

するプルダウンメニューが実現できる。



#### Button

Callback Scriptにこれ。 
`target_attrib` は上記 `[Search Target Attribute]` メニューのparameter nameのこと。

```python: [Exec Search]
hou.phm().exec_search(hou.pwd().parm("target_attrib").evalAsString())
```

HDA > Python Moduleに登録した関数が `hou.phm()` 経由で参照できるので、プルダウンで選択した探索対象attributeをPython Module内 `exec_search()` 関数に渡しているところ。


#### PythonModule
<details><summary>ここはほぼ使いまわしなので折り畳み。</summary>

```python
def has_attrib(geo, attr):
    return not (
        (geo.findPointAttrib(attr) is None)
        & (geo.findVertexAttrib(attr) is None)
        & (geo.findPrimAttrib(attr) is None)
        & (geo.findGlobalAttrib(attr) is None)
    )


def filter_nodes_by_attribute(source_sop, attr):
    filtered_sop = []
    for source in source_sop:
        try:
            if has_attrib(source.geometry(), attr):
                filtered_sop.append(source)
        except AttributeError:
            continue

    return remove_downstream_nodes(filtered_sop)


def remove_downstream_nodes(source_sop):
    filtered_sop = []
    source_list = [x.name() for x in source_sop]

    for sop in source_sop:
        input_list = [x.name() for x in sop.inputs()]
        if not set(input_list) & set(source_list):
            filtered_sop.append(sop)

    return filtered_sop


def get_node_name_from_sop_list(sop_list):
    result = []

    for sop in sop_list:
        result.append(sop.name())

    return result


def exec_search(target_attribute):
    parent_dir = hou.pwd().path().replace("/" + hou.pwd().name(), "")
    current_sop = hou.node(parent_dir).children()
    for sop in current_sop:
        sop.setTemplateFlag(False)

    scope = hou.pwd().parm("search_scope").evalAsString()
    if scope == "upstream_only":
        base_sop = hou.pwd().inputAncestors()
    elif scope == "current_dir":
        base_sop = current_sop

    result_sop = filter_nodes_by_attribute(source_sop=base_sop, attr=target_attribute)

    # print(" ============================= ")
    # pattern 1
    # print("result_sop: ", get_node_name_from_sop_list(sop_list=result_sop))
    # pattern 2
    if len(result_sop) == 0:
        print("Node not found.")
    else:
        for sop in result_sop:
            sop.setSelected(True)
            sop.setTemplateFlag(True)

```
</details>

# 終わりに

単純なPythonコードではあったものの、記事制作過程で4,5回は作り直した（そもそも初期案は「候補」を出すだけで答えを絞れなかった）。「下流を消す」ロジックを加えてから処理が改善。

`has_attrib()` 関数も最初は全ノードの全attributeを洗い出し `target_attr` を含むかどうかをいちいちチェックしていたが、`findXXXXAttrib(target_attr)` ベースの今の形に書き換えたら劇的に早くなった。

ドキュメント読むのって大事だなってのを改めて感じつつ、こういうシンプルなケースでも改良の効果を実感できるのは面白い。