【MAYA】メニュー構成をjsonデータで記述して構築する

いろんなツールをまとめたツールセットみたいな物をつくってメニューにそれぞれの機能を登録していると、メニューの更新・整理時にだんだんと面倒な感じになってきてコードも荒れてきます。構造データだけ外だしにしてそこからメニューを自動で構築するようにしたら、見通しも良く更新も楽なんじゃないか、と思ったので、夜なべして作りました。
楽でした。:relaxed:
せっかくなのでQiitaで共有します。

## 実例

こんな感じのjsonデータを用意します。

```json
{
  "name": "すごいツールセット",
  "menu":[
    {
      "label": "python呼び出しサンプル",
      "python": "import os;print('current dir : {}'.format(os.getcwd()))"
    },
    {
      "label": "罫線サンプル"
    },
    {
      "label": "mel呼び出しサンプル",
      "mel": "print(\"hello mel!\\n\");"
    },
    {
      "label": "option メニューサンプル",
      "mel": "print(\"option!\\n\");",
      "option":true
    },
    {
      "label": "サブツール",
      "menu":[
        {
          "label": "python呼び出しサンプルその2",
          "python": "print('hello python2!')"
        }
      ]
    }
  ]
}
```

これをファイルに保存して、そのパスを今回作ったメニュービルドツールに食わせると、

```python
build_menu('path/to/your/menu.json')
```

<img width="472" alt=" 2018-09-29 1.33.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/8302/212ce1a4-9ef3-8d00-52b4-429d4b607be8.png">
こうなります。`menu`というプロパティで各メニューアイテムを表すオブジェクトの配列を指定します。どの要素にも`label`というプロパティが必須で、これがメニューに表示されるラベルにそのままなります。`python`のプロパティがあるとメニュー選択時に呼び出すpythonスクリプトに、`mel`のプロパティがあるとメニュー選択時に呼び出すmelスクリプトになります。そこに`option`が`true`値で存在するとメニュー上でオプションボックスになります。（図のメニュー中で□の表示になってる部分）`mel`/`python`どちらのプロパティもない場合、ディバイダーになります。`menu`は入れ子にすることができ、その場合メニューではサブメニューになります。


## jsonファイルをわざわざ保存するのが面倒臭い？

```python
build_menu({
    'name':'hoge menu',
    'menu':[
        {
            'label':'hoge item',
            'python':'print("hello maya python!")'
        }
    ]
})
```

直接 pythonのオブジェクトでメニュー構成を指定できるようにもしました。通常はこっちのほうが楽ですね。オブジェクトのフォーマットは上記jsonファイルをjsonモジュールでパースしたものと同じです。

## その他設定

```json
{
  "name": "すごいツールセット",
  "version": "0.1",
  "description":"ものすごいツールセットです。",
  "about_dialog":true,
  "top_submenu":true,
  "menu":[
:
:
```

メニュー名になる`name`以外にも色々かけます。`version`、`description`はこのjsonファイルのメタ情報ですが、ついでに`about_dialog`をtrueにしておくと、
<img width="234" alt=" 2018-09-29 1.53.23.png" src="https://qiita-image-store.s3.amazonaws.com/0/8302/c193d891-db35-402c-de69-47450ebd5efe.png">
ツールに`about`メニューが追加され、選択すると、
<img width="208" alt=" 2018-09-29 1.53.30.png" src="https://qiita-image-store.s3.amazonaws.com/0/8302/32a4bcda-6cf2-681b-2c94-2b4790a29c7e.png">
こんなダイアログがでます。

また、メニュー構築時に

```python
build_menu('path/to/menu.json', other_menu)
```
第二引数に別メニューの参照を渡すと、そのメニューのサブメニューとしてメニューを構築できるのですが、`top_submenu`という設定項目がtrueに設定されていると、その際にツール名でサブメニューを１段階挟みます。設定されていないとそのまま`menu`の配列で設定されている項目が並びます。実例は面倒なので省略。

## ソース

生ソース貼り付けます。シェルフに登録するなり、モジュールとして取り込むなりしてください。
Maya2017 Mac/2018 Macで動作確認。Windowsでは動作確認してませんが、まあ大丈夫でしょう。ところで、Maya2019はまだかいな。

```python
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals
from __future__ import print_function
from __future__ import division
import logging
import json
import pymel.core as pm
_logger = logging.getLogger(__name__)


class _MenuBuilderBase(object):
    main_window = None
    json_source_info = ''
    about_dialog = False
    top_submenu = False

    tool_name = ''
    tool_version = ''
    tool_description = ''

    def __init__(self):
        self._main_window = pm.mel.eval('$tmpVar=$gMainWindow')

    def create_top_menu(self, name):
        return pm.menu(
            'menu_{}'.format(name), parent=self._main_window, label=name, tearOff=True)

    def parse_param(self, json_obj):
        try:
            self.tool_name = json_obj['name']  # 必須
            if 'version' in json_obj:
                self.tool_version = json_obj['version']
            if 'description' in json_obj:
                self.tool_description = json_obj['description']
            if 'about_dialog' in json_obj:
                self.about_dialog = json_obj['about_dialog'] is True
            if 'top_submenu' in json_obj:
                self.top_submenu = json_obj['top_submenu']
        except:
            pm.displayError('menu json parse error : {}'.format(self.json_source_info))

    def create_sub_menu(self, parent_menu, label, tear_off=True):
        return pm.menuItem(parent=parent_menu, label=label, subMenu=True, tearOff=tear_off)

    def create_divider(self, parent_menu, label):
        pm.menuItem(parent=parent_menu, divider=True, dividerLabel=label)

    def create_command(self, parent_menu, label, command, is_python=True, option_box=False):
        source_type = 'mel'
        if is_python:
            source_type = 'python'
        return pm.menuItem(
            parent=parent_menu,
            label=label,
            command=command,
            sourceType=source_type,
            optionBox=option_box
        )

    def build_menu(self):
        # TO BE IMPLEMENTED AT SUB CLASS
        pass

    def create_about_dialog(self, parent_menu):
        if not self.about_dialog:
            return
        command = "pm.confirmDialog(title='{}', message='version: {}\\n\\n{}', button='OK')".format(
            self.tool_name, self.tool_version, self.tool_description)
        _logger.debug(command)
        self.create_command(parent_menu, 'about', command, is_python=True)


class _MenuBuilderV1(_MenuBuilderBase):

    def __init__(self):
        super(_MenuBuilderV1, self).__init__()

    def build_menu(self, json_obj, parent_menu=None):
        self.parse_param(json_obj)
        created_top_menu = None
        if not parent_menu:
            created_top_menu = self.create_top_menu(self.tool_name)
            parent_menu = created_top_menu
        if self.top_submenu and not created_top_menu:
            parent_menu = self.create_sub_menu(parent_menu, self.tool_name)
        try:
            self._build_menu(parent_menu, json_obj['menu'])
        except Exception as ex:
            pm.displayError(
                'error occured during parse menu json: {}\n{}'.format(self.json_source_info, ex.message))

        self.create_about_dialog(parent_menu)

    def _build_menu(self, parent_menu, menu_items):
        for item in menu_items:
            label = item['label']
            option = 'option' in item and item['option'] is True
            if 'python' in item:
                self.create_command(parent_menu, label, item['python'], is_python=True, option_box=option)
            elif 'mel' in item:
                self.create_command(parent_menu, label, item['mel'], is_python=False, option_box=option)
            elif 'menu' in item:
                sub_menu = self.create_sub_menu(parent_menu, label)
                self._build_menu(sub_menu, item['menu'])
            else:
                self.create_divider(parent_menu, label)


def _get_builder(json_obj):
    if 'format_version' in json_obj:
        version = float(json_obj['format_version'])
        if version < 2.0:
            return _MenuBuilderV1()
        pm.displayError('menu builder : no proper version : {}'.format(version))
        return None
    else:
        # バージョン指定がない場合のデフォルト
        return _MenuBuilderV1()


def build_menu_by_json_file(json_path, parent_menu=None):
    try:
        with open(json_path) as fp:
            json_obj = json.load(fp)
            _logger.debug(json_obj)
    except IOError:
        pm.displayError("build_menu error : couldn't load json file : {}".format(str(json_path)))
        return
    builder = _get_builder(json_obj)
    if builder:
        builder.json_source_info = json_path
        builder.build_menu(json_obj, parent_menu)


def build_menu_by_menu_obj(menu_obj, parent_menu=None):
    builder = _get_builder(menu_obj)
    if builder:
        builder.json_source_info = 'from json object'
        builder.build_menu(menu_obj, parent_menu)


def build_menu(_json, parent_menu=None):
    """
    jsonの構造を元にMayaのメニューを構築します

    :param _json: jsonファイルのパス もしくは jsonをパースしたものと等価のpythonObject
    :param parent_menu: 親メニュー
    """
    if isinstance(_json, dict):
        build_menu_by_menu_obj(_json, parent_menu)
    else:
        build_menu_by_json_file(_json, parent_menu)
```

毎度ソースコードをgitで公開しないのは、いろいろな手間とやんごとなき都合によるものです。