株式会社デジタル・フロンティア-Digital Frontier

Header

Main

  • TOP
  • DF TALK
  • 【Maya】 Preset ボタンを自分のツールに

【Maya】 Preset ボタンを自分のツールに

2017/10/20

Tag: ,,,,

preset-sample

 Mayaのアトリビュートエディタ上に配置された「Presetボタン」。
 実際のところボタンは、ポップアップメニューを出す為の位置決め用のパーツで、本体はポップアップメニューの方なんですが…

 これを、自分の作るツールのUI上に持ちたい。

 …という希望があったとして、どんなふうに叶えるのか流れを紹介してみようと思います。

 今回記事の対象になる読者さんは、PythonでMaya用のツールを作っている、もしくは作ろうとしている方になります。
 書き手はTD神原です、よろしくどうぞ。

 

まず melを読む

 まずMayaのPresetがなにをやっているのか、知る必要があります。
 Presetをどこに持っていて、どうやって反映するのか。方法が分からないことには、どうにもなりません。

 まずは、Preset機能を実際に使って、その時のログを確認してみると、以下の2行が取得できました。

commitAENotes($gAECurrentTab);AEshowPresetMenu "MayaWindow|MainAttributeEditorLayout|formLayout2|AEmenuBarLayout|AErootLayout|AEStackLayout|AErootLayoutPane|AEbaseFormLayout|AEnodeNameHeaderLayout|AEpresetButton|menu" "|pSphere1";
applyPresetToNode "|pSphere1" "" "" "pSphere1" 1;

 「AEshowPresetMenu なんたら」と、「applyPresetToNode なんたら」という感じの処理が走っていることが分かりました。
 ですので、「whatIs AEshowPresetMenu」、「whatIs applyPresetToNode」のように、melモードのスクリプトエディタで実行してみると、今度は次の2行が出てきました。

// Result: Mel procedure found in: C:/Program Files/Autodesk/Maya2016/scripts/others/showEditor.mel // 
// Result: Mel procedure found in: C:/Program Files/Autodesk/Maya2016/scripts/others/presetMenuForDir.mel // 

 両方ともmelスクリプトで、保存先はパスにある通りです。
 とりあえず、showEditor.melを開いて、AEshowPresetMenuの方を追ってみます。
 「AEshowPresetMenu」を検索してみると、「global proc AEshowPresetMenu( string $presetMenu, string $node )」が、3287行に出て来ました。
 中を見ると、ごちゃごちゃ処理をしているように見えますが、処理結果を menuItemコマンドに出してる感じで、メニュー項目の中身を presetMenuForDir関数がなにやらやっているっぽい。presetMenuForDir関数は presetMenuForDir.melの方に入っていました。(「whatIs presetMenuForDir」で、パスが分かります。)
 presetMenuForDir関数には、関数の前の行に「Description: なんたら」って感じで英語の説明が付いていました。Google先生に翻訳をしてもらうと、以下の内容でした。

//説明:このプロシージャは、プリセットメニューをビルドするために呼び出されます。
//指定されたノードと同じノード型の既存のプリセット。

 この説明だけだと意味不明ですが、関数名は「presetMenuForDir」です。’Dir’は、まず’Directory’(ディレクトリ)の略なので、総合して考えたらディレクトリ基準でメニューを作る感じではないか、とあたりが付けられます。
 そのつもりで眺めてみると、はじめの方でパスを渡してファイルのリストを取得して、「applyPresetToNode関数」もしくは「applyPresetToSelectedNodes関数」に渡す引数を作っています。引数には、ファイル名から’.mel’を省いたファイル名を使っているようです。

 そういえば「applyPresetToNode」ですが、これって最初にログを取った時の2つ目の関数ですね。
 ということは、たぶんこれが最終的にPreset反映時に実行される関数で、それを実行する為のメニューを作るのが、AEshowPresetMenu ~ presetMenuForDir 関数の流れっぽい。と、なんとなく分かります。
 presetMenuForDir関数をもう少し下まで眺めると、確信に変わる記述もいろいろ出て来ました。
 menuItemコマンドに、-subMenuフラグの指定。-cフラグに「($menuCommand + “1”);」を渡していて、その直前に「string $menuCommand = (“applyPresetToNode” + $cmd );」と書かれています。
 処理的には、サブメニューの登録をしていて、メニュー選択時に実行するのは applyPresetToNode関数です。引数として渡す定数の名称も、kReplace, kReplaceSelected, kBlend90percent .. と完全に各Preset反映時の項目に合致します。

 動作としては、メニューを作る為にどこかのディレクトリのファイルリストを取得して、それを基準にメニューを生成。各メニューに対応する処理としては、applyPresetToNode関数 もしくは applyPresetToSelectedNodes関数が担当している…ということが分かりました。

基本的な作りについて

 melの基本的な流れが分かったので、それに倣って動作する処理を用意する必要があります。
 最終的にPresetを反映する処理「applyPresetToNode, applyPresetToSelectedNodes」は、改めて作る必要があるとは思えないのでそれを呼び出すまでの部分。これを、自分で用意する方針で考えてみます。ですので、さきほど見ていた2つの関数の動きを追って、必要な情報がなにかを考えます。

 まず、AEshowPresetMenu関数。
 最初に popupMenuを初期化して、menuItemが3つ。menuItemには見る限り、最初がノードタイプ、2つ目は定型文字列を与えています。アトリビュートエディタは、’Save タイプ名 Preset…’, ‘Edit Presets…’と表示するし、次の’menuItem -d true;’は、分割線の設定。実際の表示内容に合致するので、間違いなさそうです。
 menuItemは、他にも3つありますが、全部分割線なのです。その途中に、3度AEpresetMenuForDirが登場するので、どうもそれらを分割する為のmenuItem登録っぽいですね。
 AEpresetMenuForDir関数内で、残りの項目を作っているみたいです。

 AEpresetMenuForDir関数を見てみますが、presetMenuForDir関数の呼び出しだけでした。
 なので、presetMenuForDir関数を見てみます。
 引数を6つとりますが、AEpresetMenuForDir関数の呼び出しで後半2つは空文字であることが分かっているので、前半4つが有効な引数です。
 numPresetsInMenu, ppath, node, callWithFullPathの4つです。
 ppath, nodeは、どう考えてもファイルパスとノード名です。numPresetsInMenuは、「メニュー内のプリセットの番号」と変数名からも読み取れます。callWithFullPathは、「フルパスで呼び出す」のように読めます。
 その感じでpresetMenuForDir関数を眺めてみると、getPresetFiles関数は指定ディレクトリからファイルリストを取得するものですし、callWithFullPathはフルパスもしくはプリセット名でコマンド発行するフラグであることも分かります。(プリセット名は、直前の「substitute “.mel” $file “”」でファイル名から拡張子抜きのファイル名であると分かる。)
 続く記述も下記のような感じで、プリセットごとのメニュー登録が続いていますし、これで大まかなところは見えた感じです。

menuItem -subMenu true -label ($presetName);
  menuItem -label (uiRes("m_presetMenuForDir.kReplace")) -c ($menuCommand + "1");
  menuItem -label (uiRes("m_presetMenuForDir.kReplaceSelected")) -c ($applySelectedCommand + "1");
  :
  :

 ここまでに書いてないけれど、他に必要なのは getPresetFiles関数に渡すディレクトリのことくらいです。
 このディレクトリは、プリセットデータを格納する場所になりますが、AEshowPresetMenu関数内の「getenv ~」および「internalVar -userPrefDir」のあたりの記述で取得しています。

 この辺りは、次の項を見て貰うとして、とりあえず本項の目的「基本的な作り」のまとめをします。

 やっていたのは、基本的にメニューの構築作業で、これはPresetボタンをクリックした時に表示される内容そのものです。
 構築時には、Presetが格納されるディレクトリ参照をしていて、入っているファイル名毎に「Replace, Replace All Selected, Blend90/75/50/25/10%」のように定型の7項目が登録されます。
 今回Presetの新規保存&編集は行わない方針で考えようと思いますので、Presetボタンがクリックされる度にメニュー構築をして、以下のような構造を構築しようと思います。

 プリセット名1
         Replace
         Replace All Selected
         Blend90
         Blend75
         Blend50
         Blend25
         Blend10
 プリセット名2
         Replace
         Replace All Selected
         Blend90
          :
          :

PySide版を実装してみる

 まず、メニューの構築方法を調べます。
 基本的に、ポップアップメニューはコンテキストメニューと呼ばれていて、 QtGui.QMenuクラスで作れるようです。
 メニュー項目はQtGui.QActionをQMenu.addActionで登録。サブメニューを持たせたい項目(Preset名)は、addMenuで登録するみたいです。

 今回は、ボタンが押される度にメニューを新しく構築したいので、ボタンのクリックイベントでメニューを作成~表示まで一気に行う感じで作りたいと思います。
 「クリック –> メニュ構築(ディレクトリ検索, ファイルリスト取得, メニューに登録) –> メニュ表示」という流れ。
 そしてメニュ構築は、ここまでに見てきたmelの処理を踏襲する感じです。

 できたクラスは、QPushButtonを継承して作りました。
 自分の作ったツールのWidget上のverticalLayoutなんかに載せてやれば、下図のようにPresetボタンが追加されます。

preset-sample_pyside

 
 すっかりほったらかしだった「プリセットデータを格納場所」ですが、これはgetPresetPaths関数で取得しています。
 パスは「os.environ[‘MAYA_LOCATION’], os.environ[‘MAYA_PRESET_PATH’], cmds.internalVar(userPrefDir=True)」の3箇所の情報を参照して、その下の’/presets/attrPresets/’や’/attrPresets/’のディレクトリに.melファイルが格納されていたら、収集するという処理になっています。(これは決まった場所を探す形になるので、AEshowPresetMenu関数をPythonに書き換える形になります。)

(ボタンを載せるサンプル記述)

 btn = QPresetMenuButton()
 verticalLayout.addWidget(btn)

(サンプル実装)

# -*- coding: utf-8 -*-

from PySide import QtCore, QtGui

import maya.cmds as cmds
import maya.mel as mel
import os
import glob
import re
from functools import partial


class QPresetMenuButton(QtGui.QPushButton):

    # コンテキストメニュー用の定義
    (
     REPLACE,
     REPLACE_ALL,
     BLEND90,
     BLEND75,
     BLEND50,
     BLEND25,
     BLEND10
    ) = range(0,7)

    _parent = None

    def __init__(self, parent=None, text='Preset'):
        super(QPresetMenuButton,self).__init__(parent)
        
        self._parent = parent
        if text:
            self.setText(text)
        
        self.clicked.connect(self.contextMenu)

    def contextMenu(self):
        # コンテキストメニューを表示
        
        def addAction(self, menu, name, func):
            action = QtGui.QAction(name,self)
            action.triggered.connect(partial(func,menu.num))
            menu.addAction(action)
        
        items = (('Replace', self.replace),
                 ('Replace All Selected', self.replaceAll),
                 ('Blend 90%', partial(self.blend, self.BLEND90)),
                 ('Blend 75%', partial(self.blend, self.BLEND75)),
                 ('Blend 50%', partial(self.blend, self.BLEND50)),
                 ('Blend 25%', partial(self.blend, self.BLEND25)),
                 ('Blend 10%', partial(self.blend, self.BLEND10)))

        node = cmds.ls(sl=True)
        if node:
            paths, _type = self.getPresetPaths(node[0])
            presets = self.getPresetNames(paths)
            self.paths = paths
            
            menu = QtGui.QMenu(self)
            action = QtGui.QAction('%s : %s'%(_type,node[0]),self)
            menu.addAction(action)
            
            num = 0
            for preset in presets:
                submenu = menu.addMenu(preset)
                submenu.num = num
                num += 1
                for name, func in items:
                    addAction(self, submenu, name, func)
                    
            point = QtGui.QCursor.pos()
            menu.exec_(point)
        
    # 以下、コンテキストメニュー用の処理関数
    def replace(self, num):
        print '# Replace'
        nodes = cmds.ls(sl=True)
        if nodes:
            applyPreset(nodes[0], self.paths[num], REPLACE)

    def replaceAll(self, num):
        print '# Replace All Selected'
        nodes = cmds.ls(sl=True)
        if nodes:
            cmds.undoInfo(ock=True)
            for node in nodes:
                applyPreset(node, self.paths[num], REPLACE_ALL)
            cmds.undoInfo(cck=True)

    def blend(self, value, num):
        val = {BLEND90:'90',BLEND75:'75',BLEND50:'50',BLEND25:'25',BLEND10:'10'}[value]
        print '# Blend %s%%'%val
        nodes = cmds.ls(sl=True)
        if nodes:
            applyPreset(nodes[0], self.paths[num], value)

    # 以下、プリセット処理関数(melで書いてあった機能を抜き出したもの。)
    def getPresetPaths(self,node):
        """
        preset パスを収集
        """
        paths = []
        _type = cmds.nodeType(node)
        
        # first show the released presets
        env = os.environ['MAYA_LOCATION']
        ppath = env + '/presets/attrPresets/' + _type
        paths.extend(glob.glob(ppath+'/*.mel'))
        
        # then show any presets specified by MAYA_PRESET_PATH
        # each entry in the path points at equivalents to the presets directory
        env = os.environ['MAYA_PRESET_PATH']
        envs = env.split(';')
        for env in envs:
            ppath = env + '/attrPresets/' + _type
            paths.extend(glob.glob(ppath+'/*.mel'))
        
        # finally show any local presets that the user has created
        env = cmds.internalVar(userPrefDir=True)
        env = env.replace('/prefs','/presets/attrPresets')
        ppath = env + _type
        paths.extend(glob.glob(ppath+'/*.mel'))
        
        paths = [i.replace('\\','/') for i in paths]
        
        return paths, _type


    def applyPreset(self, node, path, mode):
        """
        preset スクリプトを実行
        """
        cmd = ' "%s" "" "" "%s" '%(node,path)
        if mode == REPLACE:
            mel.eval('applyPresetToNode' + cmd + '1')
        elif mode ==  REPLACE_ALL:
            mel.eval('applyPresetToSelectedNodes' + cmd + '1')
        elif mode == BLEND90:
            mel.eval('applyPresetToNode' + cmd + '.9')
        elif mode == BLEND75:
            mel.eval('applyPresetToNode' + cmd + '.75')
        elif mode == BLEND50:
            mel.eval('applyPresetToNode' + cmd + '.51')
        elif mode == BLEND25:
            mel.eval('applyPresetToNode' + cmd + '.25')
        elif mode == BLEND10:
            mel.eval('applyPresetToNode' + cmd + '.1')
        else:
            raise ValueError


    def getPresetNames(self, paths):
        """
        preset名称を取得(ファイル名(拡張子なし))
        """
        names = []
        for i in paths:
            root,ext = os.path.splitext(i)
            names.append(os.path.basename(root))
        return names

MayaのネイティブUI版を実装してみる

 仕事では、昔作ったツールにPresetボタンを…please!みたいな話が僕のところに回ってきたもので、使い方もすっかり忘れ去っていたMayaのUI版も作ったのでした…。
 ネイティブUI版の場合は、メニューの実装が若干違っています。ボタンには、最初から空っぽのpopupMenuを登録しておいて、クリックでメニュー表示になる設定にしておき、popupMenuコマンドのpmcフラグをトリガーに、メニューの再構築を行いました。

ただちゃんとクラス化をしなかったので、必要なコードを並べておきます。

(ボタンの登録記述)

  preset_btn = cmds.button(label='Presets')
  pmenu = cmds.popupMenu(p=preset_btn, button=1, pmc=contextMenuFunc)

(メニューの実装)

def contextMenuFunc(*args):
	#(ui call point)
	contextMenu()
	
def contextMenu():
	
	def addAction(menu, num, name, func):
		cmds.menuItem(p=menu, l=name, c=partial(func,num))
	
	items = (('Replace', replace),
			 ('Replace All Selected', replaceAll),
			 ('Blend 90%', partial(blend, BLEND90)),
			 ('Blend 75%', partial(blend, BLEND75)),
			 ('Blend 50%', partial(blend, BLEND50)),
			 ('Blend 25%', partial(blend, BLEND25)),
			 ('Blend 10%', partial(blend, BLEND10)))

	cmds.popupMenu(pmenu, e=True, deleteAllItems=True)
	nodes = cmds.ls(sl=True)
	if nodes:
		node = nodes[0]
		paths, _type = self.getPresetPaths(node)
		presets = self.getPresetNames(paths)
		self.paths = paths
		
		cmds.menuItem(p=pmenu, l='%s : %s'%(_type,node))
		
		num = 0
		for preset in presets:
			submenu = cmds.menuItem(p=pmenu, l=preset, subMenu=1)
			for name, func in items:
				cmds.menuItem(p=submenu, l=name, c=partial(func,num))
			num += 1

def replace(self, num, args):
	print '# Replace'
	nodes = cmds.ls(sl=True)
	if nodes:
		self.applyPreset(nodes[0], self.paths[num], REPLACE)

def replaceAll(self, num, args):
	print '# Replace All Selected'
	nodes = cmds.ls(sl=True)
	if nodes:
		cmds.undoInfo(ock=True)
		for node in nodes:
			self.applyPreset(node, self.paths[num], REPLACE_ALL)
		cmds.undoInfo(cck=True)

def blend(self, value, num, args):
	val = {BLEND90:'90',BLEND75:'75',BLEND50:'50',BLEND25:'25',BLEND10:'10'}[value]
	print '# Blend %s%%'%val
	nodes = cmds.ls(sl=True)
	if nodes:
		self.applyPreset(nodes[0], self.paths[num], value)
		
def getPresetPaths(self, node):
	paths = []
	_type = cmds.nodeType(node)
	
	# first show the released presets
	env = os.environ['MAYA_LOCATION']
	ppath = env + '/presets/attrPresets/' + _type
	paths.extend(glob.glob(ppath+'/*.mel'))
	
	# then show any presets specified by MAYA_PRESET_PATH
	# each entry in the path points at equivalents to the presets directory
	env = os.environ['MAYA_PRESET_PATH']
	envs = env.split(';')
	for env in envs:
		ppath = env + '/attrPresets/' + _type
		paths.extend(glob.glob(ppath+'/*.mel'))
	
	# finally show any local presets that the user has created
	env = cmds.internalVar(userPrefDir=True)
	env = env.replace('/prefs','/presets/attrPresets')
	ppath = env + _type
	paths.extend(glob.glob(ppath+'/*.mel'))
	
	paths = [i.replace('\\','/') for i in paths]
	
	return paths, _type

def applyPreset(self, node, path, mode):
	cmd = ' "%s" "" "" "%s" '%(node,path)
	if mode == REPLACE:
		mel.eval('applyPresetToNode' + cmd + '1')
	elif mode ==  REPLACE_ALL:
		mel.eval('applyPresetToSelectedNodes' + cmd + '1')
	elif mode == BLEND90:
		mel.eval('applyPresetToNode' + cmd + '.9')
	elif mode == BLEND75:
		mel.eval('applyPresetToNode' + cmd + '.75')
	elif mode == BLEND50:
		mel.eval('applyPresetToNode' + cmd + '.51')
	elif mode == BLEND25:
		mel.eval('applyPresetToNode' + cmd + '.25')
	elif mode == BLEND10:
		mel.eval('applyPresetToNode' + cmd + '.1')
	else:
		raise ValueError

def getPresetNames(self, paths):
	names = []
	for i in paths:
		root,ext = os.path.splitext(i)
		names.append(os.path.basename(root))
	return names


※免責事項※
本記事内で公開している全ての手法・コードの有用性、安全性について、当方は一切の保証を与えるものではありません。
これらのコードを使用したことによって引き起こる直接的、間接的な損害に対し、当方は一切責任を負うものではありません。
自己責任でご使用ください。

Pocket

コメント

コメントはありません

コメントフォーム

コメントは承認制ですので、即時に反映されません。ご了承ください。

*