Maya の Callback 管理ツールの作成


皆様ごきげんよう。Maya を操作していて特定のタイミング (*たとえばファイルを開いたとき, 保存するとき, reference作成するとき...などなど*)で ***なにか*** を実行させたい！ということはよくありますね。そのようなとき [`MSceneMessage.addCallback`](http://help.autodesk.com/view/MAYAUL/2017/ENU/?guid=__py_ref_class_open_maya_1_1_m_scene_message_html) や [`scriptJob`](http://help.autodesk.com/cloudhelp/2017/ENU/Maya-Tech-Docs/CommandsPython/scriptJob.html) コマンドをつかいます[^1]。では *その機能を* ***オン・オフ*** *できるようにしもたい* といった要望が出てきた場合どのように対応したらよいでしょう？この記事では、そんな`MSceneMessage` のコールバック登録を便利にする Pythonモジュールの作成について解説します。

[^1]: 巷には同様の目的で組み込みの ***.mel の `global proc` 上書きというような黒魔術を行使するひとびともいるようです。


はじめに
=======

この記事で作成するツールは、パイプラインツールへ組み込む・あるいは複数のツールにまたがって利用することを想定しています。mayaでのツール作成について一定以上の知識がある方を対象に、ツールの作成に必要な思考ステップや作成テクニックを解説します。また、成果物は [github](https://github.com/yamahigashi/MayaCallbackManager) に公開しています。


要件
====

最初は <<<*コールバックを増やしていくと管理するのが面倒になりそうだなー・・・どうしよう*>>> 程度の漠然とした着想からスタートです。必要な機能を洗い出してみましょう。はいできました

* 以下の手続きの一元化
* コールバックの追加・削除
* オン・オフの設定
* 設定の永続化

ここまで考えたら、具体的な手段に目星をつけます。皆目見当つかないような場合は要件を再考するかあきらめるかレファレンスを通読するなどしましょう[^2]。

[^2]: レファレンスまじ重要。TD業だけでなく



設計
====

順にみていきましょう。

    以下の手続きの一元化

ナイスなモジュールを作成しそれを経由しコールバックを扱います。コールバック本体はモジュール外にあることを想定します。あくまでこのモジュールは追加・削除、オン・オフの管理に特化します。（逆に特定のモジュールに コールバックを凝集するという設計も当然ありです。自由度は減りそうですがそのぶん強力な支援を提供可能でしょう）


    コールバックの追加・削除

これは単に `OpenMaya.MSceneMessage.addCallback`類 をラップしてやればよさそうですね

    オン・オフの設定

コールバックごとに何らかの方法で設定を保存し、コールバック発火時に実行、抑制を切り替えてやります。ですがコールバック本体（*つまりモジュール利用者*）に切り替えロジックを乗せるのは避けたいです。あるいは実行抑制のきりかえでなく別解として、オン・オフの際に コールバックの登録・解除を行ってもよいでしょう。この場合最初（起動時）の登録処理が少々こみいりそうです。

    設定の永続化

つまりmaya を再起動しても設定を引き継ぎたいということです。いくつか思いつきますね。大別して A. 「maya の機能を利用する方法」, B. 「独自の方法を用意する方法」があります。どちらも一長一短ですがここでは A をとります。`cmds.optionVar()` を使いましょう。

    その他

インタフェースはどうしましょう？モジュール利用者（*つまりツール開発者*)からは add/remove がみえていれば十分です。

またツール利用者はどのようにオン・オフ操作すればよいでしょうか。このような操作にオサレユーアイは不要です。まずは最低限のものを maya の機能を用いて作成しましょう。（*2017sp5以降の plug-in manager ぽいものを書いてもよかったのですがそれを書くには余白が以下略*)サクッと `cmds.menuItem` の [checkbox](http://help.autodesk.com/cloudhelp/2017/ENU/Maya-Tech-Docs/CommandsPython/menuItem.html#flagcheckBox)を使いましょう。

以上のことを勘案し、パッケージングを決定します。最初から細分化するのも考え物ですが、(G)UI制御とロジック制御、それにmaya起動時の処理程度に分割しておきましょう。




実装
=====

ここまで読み進めてどうでしょうか、コードのカタチはつかめましたか？具体的なコードは、より状況に即しかわるものです。以下に示すものはあくまでも一例としてお考え下さい。


一番重要な登録部分のひな型を示します。

```entry.py

__CALLBACK_ENTRIES__ = OrderedDict()  # type: Dict[Text, Dict[Text, Any]]
def add(register_func, when, cb_func):
    # type: (Callable, int, Callable) -> int
    """Add callback.

    see [OpenMaya.MSceneMessage Class Reference](http://help.autodesk.com/view/MAYAUL/2017/ENU//?guid=__py_ref_class_open_maya_1_1_m_scene_message_html)
    for more detail.

    Args:
        register_func:  [addCallback,addCheckCallback,....]
        when: [kAfterCreateReference = 45,kAfterExport = 11...]
        cb_func: The callback for invoked.

    Returns:
        int: Identifier used for removing the callback.

    Example:
        >>> import maya.api.OpenMaya as om
        >>> cb_func = lambda client_data: print("callback fired")
        >>> isinstance(add(om.MSceneMessage.addCallback, om.MSceneMessage.kBeforeNew, cb_func), int)
        True

    """
    from . import menu
    global __CALLBACK_ENTRIES__

    # decorate callback function with `execute_if_option_enable`
    cb_id = register_func(when,  execute_if_option_enable(cb_func))  # ！！後で解説する！！
    keyname = get_key_name(cb_func)

    __CALLBACK_ENTRIES__[keyname] = {
        "prefkey": cb_func.__name__,
        "id": cb_id,
    }

    menu.reconstruct_menu()

    return cb_id
```

やっていることは単純です。コールバック登録関数へコールバック本体を登録し、モジュールレベルの変数 `__CALLBACK_ENTRIES__` にその情報を格納しています。また `menu_reconstruct_menu()` で maya メニューの同期を行います。

つぎにメニュー登録周りを見てみましょう。こちらは少々長いです。

```menu1.py

def reconstruct_menu():
    # type: () -> None

    if not cmds.menu(CONFIG_MENU_ENTRY_POINT, exists=True):
        return

    cmds.menu(CONFIG_MENU_ENTRY_POINT, deleteAllItems=True, edit=True)
    fill_menu()

    # TODO(implement later): add "reset to default" command here
```

メニュー入れ物以下を一回削除し、再びメニューを満たします。
該当メニューが存在しない場合は何もしません。さきにメニューの入れ物を用意しましょう。

```menu2.py
CONFIG_MENU_ENTRY_POINT = "callback_manager_cofig_menu"

# =============================================================================
def create_menu_entry_point():
    # type: () -> None
    """Create top menu of this package on maya main menu > window > config."""

    # build maya main menu and settings/preferences first.
    cmd = '''
    buildViewMenu MayaWindow|mainWindowMenu;
    buildPreferenceMenu mainOptionsMenu;

    string $parentName = "MayaWindow|mainWindowMenu";
    string $menuItems[] = `menu -q -ia $parentName`;

    for ($i = 0; $i < size($menuItems); $i += 1) {
        string $label = `menuItem -q -label $menuItems[$i]`;
        string $match = `match "Settings" $label`;
        if (0 < size($match)){
            $parentName = $parentName + "|" + $menuItems[$i];
            break;
        }
    }

    buildSettingsMenu $parentName;
    setParent -menu $parentName;
    '''
    if cmds.menu(CONFIG_MENU_ENTRY_POINT, exists=True):
        return

    mel.eval(cmd)

    cmds.menuItem(divider=True)
    cmds.menuItem(
        CONFIG_MENU_ENTRY_POINT,
        label="Callback Manager",
        subMenu=True,
        tearOff=True,
        # command=construct_menu
    )
```
少々説明が必要ですね。このコードの目的は、

　　　メインメニュー `Windows > Settings/Preferences` の子階層へ `Callback Manager` メニューを追加する

というものです。通常であれば特定のメニュー要素を取得するには `cmds.menu(名前)`で引いてくることが可能です。ここではなぜそれをしないのでしょう。menuで引いてくることができるメニュー要素には制限があります。コマンド実行時にすでに存在していないとならないのです。mayaのメニュー（の一部）はmayaを起動しただけでは非存在であり、表示しようとメニューを開いた際にはじめてメニューが生成されるものがあります。ここで示したまわりくどいmelコマンドはそのような遅延生成を先回りしています。入れ物が用意できたので次はコールバックに応じたメニューアイテムを用意しましょう。

```menu3.py
def fill_menu():
    # type: () -> None
    """Fill menu items with CB entries registered via entry.add()."""
    from . import entry

    for k, v in entry.get_entries().items():
        menu_name = "callback_manager_{}_on".format(k)
        checked = entry.is_enable(k)
        # print("register menu as {}".format(menu_name))
        cmds.menuItem(
            menu_name,
            label="{}".format(v.get("label").replace("_", " ").title()),
            parent=CONFIG_MENU_ENTRY_POINT,
            echoCommand=True,
            annotation=safe_encode(v.get("annotation", "")),
            checkBox=checked,
            command=dedent(
                """
                    import maya.cmds as cmds
                    {menu_name}_val = 1 if cmds.menuItem("{menu_name}", q=True, checkBox=True) else 0
                    cmds.optionVar(intValue=("{key}", {menu_name}_val))
                """.format(menu_name=menu_name, key=v.get("prefkey"))
            )
        )
```
`entry.get_entries()` は `__CALLBACK_ENTRIES__` を取得するコードです。モジュール変数に格納されたコールバックの情報をもとに ***チェックボックスを持つメニュー*** を作成しています。このメニューをトグルすると、

```checkbox.py
import maya.cmds as cmds
{menu_name}_val = 1 if cmds.menuItem("{menu_name}", q=True, checkBox=True) else 0
cmds.optionVar(intValue=("{key}", {menu_name}_val))
```
のコードがはしります。見てわかる通り、`cmds.optionVar()` にオン・オフの状態をトグルしています。ではこのオン・オフの状態はどのように活用されるでしょう

```entry2.py
def execute_if_option_enable(f):
    @functools.wraps(f)
    def decorated(*args, **kwargs):
        global __CALLBACK_ENTRIES__

        try:
            if is_enable(get_key_name(f)):
                return f(*args, **kwargs)

            else:
                try:
                    key = get_entry_for_func(f).get("prefkey")
                    print("optionVar {} is set False, {} skipped.".format(key, f.__name__))

                except AttributeError:
                    print("__CALLBACK_ENTRIES__ entry is not found for {}.".format(f.__name__))

        except Exception:
            traceback.print_exc()

    return decorated
```

decoratorですね。is_enable であれば関数を実行、そうでなければスキップするような単純なものです。しかしここまでのコードで＠でてきていませんね、どこで使っていたでしょう？じつは、`    cb_id = register_func(when,  execute_if_option_enable(cb_func))  # ！！後で解説する！！`の行の `execute_if_option_enable(cb_func)` がdecorator適用部分になります。そうです、@で修飾せずとも decatorの適用が可能です。（*逆だろという話もある。*)

完成
====
ここまでのコードをまとめてみましょう。https://github.com/yamahigashi/MayaCallbackManager こうなります。利用方法は

```sample.py
import maya.api.OpenMaya as om
import callbackmanager

def sample_func(client_data):
    print("fire!")

callbackmanager.add(
    om.MSceneMessage.addCallback,
    om.MSceneMessage.kBeforeNew,
    sample_func,
    label="Callback manager Sample",
    dafault=True
)
```


おわりに
=======

いかがでしたでしょうか。解説していない細かい事項もありますが、重要な点はおおむね以上です。小さいわりになかなか便利そうなものに仕上がったのではないでしょうか。原始的な機能しか搭載していないのでここからの発展は今後の課題です。記事では端折った点もありますが、なんらかのツールを作成する際の私の手法を詳らかにしてみました。もしこの記事が読者の方の一助となりましたら幸いです。

---