Maya Pythonの undo・redo あれこれ

こんにちは。はじめまして。
KLabのテクニカルアーティストグループに所属している、こかろまと申します。

去年の KLab Creative Advent Calender 2018 以来のブログ執筆となります。

今回の内容はMaya+Pythonでツールを書き始めた方を想定しています。

はじめに

ユーザーにとって「扱いやすいツール」とはどういった要素があげられるでしょうか。

作る方によって意見は変わってくるかと思いますが、私の場合は特に「ツールがユーザーにとって直観的であるかどうか(=ツールの直観性)」が重要だと考えています。

ここでいう「ツールの直観性」とは、使用するユーザーが普段利用しているツールの挙動に合わせることであったり、アプリケーションに最低限備わっているユーザーインターフェイスにできる限り合わせることとして位置づけています。

ユーザーインターフェイスの部品の一例としては、普段オペレーティングシステムで
使っている「ダイアログ」や...

image1

アプリケーションによって異なりますが、「Ctrl+Z」のundo(元に戻す)・
「Ctrl+Y」のredo(やり直し)などのショートカットであったり...

そこで、今回は社内のツール実装方針として共有する目的も兼ねて、
Maya+Pythonにおける「undo(元に戻す)」「redo(やり直し)」についてお話ししたいと思います。

Maya+Pythonにおけるundo・redoの挙動

Maya+Pythonでツールを実装するにあたって、undo・redoで躓く点は
大きく分けて下記の二つになると思います。

  • ユーザーが求めるundoができないケース
  • そもそもundo・redoできないケース

それぞれの対処方法についてお話ししていきます。

ユーザーが求めるundoができないケース

Mayaでツールを実装する際に考慮していないと陥りやすいケースです。

「シーン上にあるpCube1オブジェクトを選択する」だけのツールを例に挙げてみます。分かりやすいようにバリデータなども一切組んでいません。image2

# select_tool.py
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import maya.cmds as cmds

class SelectTool(object):

   def __init__(self, select_object_name=''):
       self.select_object = select_object_name

   def execute(self):
       cmds.select(self.select_object, r=True)
       return

def main():
    _select_tool = SelectTool('pCube1')
    _select_tool.execute()

Maya+PythonにおいてMayaのdagノードを触るようなツールを実装する際には
方針によって様々ですが、基本以下のいずれかを利用するかと思います。

  • maya.cmds
  • maya.mel
  • pymel
  • openmaya(1.0 / 2.0)

このうち、「maya.cmds」「maya.mel」は各コマンドにundo・redo機能が実装されており、「一つのコマンドごとに」mayaのコマンド履歴の待ち行列に溜まっていきます。

「pymel」も内部的に「maya.cmds」「maya.mel」を利用しているため、同様の挙動となります。

つまり、上記のような「シーン上にあるpCube1オブジェクトを選択する」だけのツールの場合、特にツール側に考慮は必要なくundo(pCube1選択前に戻る)・redo(pCube1選択後に戻る)することができます。

image5

ですが、ツールは往往にして複雑になっていくものです。

たとえば、選択しているオブジェクトすべてを「Object_」というPrefixを付け、
後ろに数字を3桁の連番ごとにリネームするようなツールが必要になったとします。

その場合、大雑把ですが下記のような実装になると思います。

# rename_tool.py
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import maya.cmds as cmds


class RenameTool(object):

   def __init__(self):
       pass

   @staticmethod
   def validate():
       if not cmds.lockNode(q=True, l=True):
           return False

       return True
  
   def execute(self):
       _selection_dag_nodes = cmds.ls(sl=True, sn=True, fl=True, dag=True, tr=True)

       for _index, _value in enumerate(_selection_dag_nodes ):
           if self.validate() is False:
               continue

           _rename = 'object_{0:03d}'.format(_index)
           cmds.select(_value, r=True)
           cmds.rename(_rename)

       return

ツール自体は問題なく、シーン上の選択可能なdagノードがリネームされます。

また、undo・redoも行うことができます。

ただし、一点このツールには問題があります。

選択しているノードが大量にある場合のundo・redoの挙動です。

たとえば、50個くらいリネームすることができるdagノードを選択している場合に
上記のツールを実行します。

その後「あ、間違えてツール実行しちゃった!戻さなきゃ」とユーザーがundoを行うとします。

発行したコマンド回数分undoを行わないとツール実行前に戻れません。

今回の例では1つのdagノードあたりの実行順にすると「cmds.lockNode」「cmds.ls」「cmds.select」「cmds.rename」の4回分コマンドを発行しているので、ツール実行前の状態に戻す場合、1つのdagノードあたり合計4回undoを行う必要がありますが、mayaで設定されている待ち行列以上にundoすることはできません。

※正確にはコマンド単位ではなく、用意されている関数によって異なります。
Autodesk社のMayaコマンドリファレンスサイトに『「元に戻す」が可能』と記載があるものが待ち行列追加対象のものとなります。下記はlsコマンドのリファレンスです。

image6-1

出典:Autodesk Maya Help:Python コマンド:ls 

余談ですが、mayaの待ち行列の回数はMayaの『プリファレンス』⇒『元に戻す』項目より設定することができます。

インストール時点ではデフォルトで50に設定されています。

image4

ツールによってコマンドが発行される回数次第で、ツール実行前の履歴を消してしまうこともありえます。

こういったツールは、「ユーザーにとって扱いやすいツール」とはいえません。

解決策

昨今のmayaでは待ち行列に登録する範囲を設定するチャンクが用意されています。

maya.cmds.undoInfoのopenChunk, closeChunkがそれにあたります。

openChunk関数でチャンクを開き、closeChunkでチャンクを閉じる間に処理を書くことで、その間の処理を1回の待ち行列として定義することができます。

今回のツールでは、シーン上のオブジェクトを回すイテレータを実行している「execute関数」に上記のチャンク範囲を適用することで、シーン上のオブジェクト数分のコマンド発行から「ツールを実行する」単位となり、undo・redoの回数を1回に抑えることができます。

# rename_tool.py
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import maya.cmds as cmds


class RenameTool(object):

   def __init__(self):
       pass

   @staticmethod
   def validate():
       if not cmds.lockNode(q=True, l=True):
           return False

       return True
   
   def execute(self):
        cmds.undoInfo(openChunk=True)

        _selection_dag_nodes = cmds.ls(sl=True, sn=True, fl=True, dag=True, tr=True)
        for _index, _value in enumerate(_selection_dag_nodes ):
            if self.validate() is False:
                continue

            _rename = 'object_{0:03d}'.format(_index)
            cmds.select(_value, r=True)
            cmds.rename(_rename)

        cmds.undoInfo(closeChunk=True)

        return

ツールを実行し、リネームが確認できた後、Ctrl+Zでundoしてみます。

今度は1回ですべて戻す(=ツール実行前に戻る)ことができました!

image3

上記でもチャンクを一つにまとめることはできますが、せっかくPythonを使っているので、本処理をデコレータとして定義しておき、共通ライブラリとして扱えるようにしておくとツールや関数ごとに定義する必要がないので便利です。

# undo_wrapper.py
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import maya.cmds as cmds

def undo_chunk(function):
   def wrapper(*args, **kwargs):
       cmds.undoInfo(ock=True)
       function(*args, **kwargs)
       cmds.undoInfo(cck=True)

   return wrapper
# rename_tool.py
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import maya.cmds as cmds

from . import undo_wrapper


class RenameTool(object):

   def __init__(self):
       pass

   @staticmethod
   def validate():
       if cmds.lockNode(q=True, l=True):
           return False

       return True
   
   @undo_wrapper.undo_chunk
   def execute(self):
       _selection_dag_nodes = cmds.ls(sl=True, sn=True, fl=True, dag=True, tr=True)
       for _index, _value in enumerate(_selection_dag_nodes ):
           if self.validate() is False:
               continue

           _rename = 'object_{0:03d}'.format(_index)
           cmds.select(_value, r=True)
           cmds.rename(_rename)

       return

そもそもundo・redoできないケース

頂点すべてに処理を加える、UVを展開する、スキンウェイトを調整するなど、
処理自体が重いツールを実装する場合、maya.cmdsやpymelでは実行速度に期待ができないのもあって、弊社では基本的にopenmaya for Python APIを利用しています。

openmaya for Python API では、undo・redo機能は提供されていません。

そもそも処理したことが待ち行列にスタックされないため、undoを行っても
openmaya for Python APIを実行する前の履歴が実行され、処理したことはそのまま残り続けます。

そのため、undo・redoが必須な場合はすべて自前で実装する必要があります。

解決策

openmayaでundo・redoを考慮に入れる場合、MPxCommandのプロキシクラスを継承したクラス単位で処理を実装する必要があります。

また、一種のコマンドとして実装する必要があるため、プラグイン化する必要があります。

プラグイン化については今回は省きますが、Autodesk社公式リファレンスが非常に参考になります。

選択しているオブジェクトの頂点カラーをすべて赤(1.0, 0.0, 0.0, 1.0)で
塗りつぶす簡単なプラグインを例に挙げます。

今回は簡単に説明するためにdagノードの名前単位で頂点カラー情報を保持していますが、
厳密にアンドゥ・リドゥを実装する際にはMTypeID等で制御するべきです。

 # vertex_color_fill_plugin.py
# coding: utf-8
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import re
import _ctypes

import maya.cmds as cmds
import maya.api.OpenMaya as OpenMaya


class VertexColorFillTool(OpenMaya.MPxCommand):
   kPluginCmdName = 'VertexColorFillTool'

   def __init__(self):
       OpenMaya.MPxCommand.__init__(self)
       self.before_calculate_vertex_color_dict = {}
       self.selection_list = []

   @staticmethod
   def initialize():
       return VertexColorFillTool()

   @staticmethod
   def validate():
       if not cmds.ls(sl=True):
           print('error : no selection')
           return False

       _color_set_list = cmds.polyColorSet(q=True, acs=True)
       if not _color_set_list:
           print('error : color set no exists')
           return False

       return True

   def get_active_vtx_selection_list(self):
       _vertex_list = self.get_select_vtx_list()
       if not _vertex_list :
           cmds.error(u'not selected dag object or vtx')
           return

       cmds.select(_vertex_list , r=True)

       return OpenMaya.MGlobal.getActiveSelectionList()

   @staticmethod
   def get_select_vtx_list():
       _vtx_list = cmds.polyListComponentConversion(tv=True)
       _vtx_list = cmds.ls(_vtx_list, fl=True)
       return _vtx_list

   def doIt(self, args):
       """
       コマンド実行時(関数実行) 関数
       :param args:
       :return:
       """
       if not self.validate():
           return

       self.selection_list = self.get_active_vtx_selection_list()

       for _selection_object in xrange(self.selection_list.length()):
           _vertex_colors = []
           _dag_path, _ = self.selection_list.getComponent(_selection_object)
           _target_mesh = OpenMaya.MFnMesh(_dag_path)

           # 頂点カラーの実行前の状態を保持
           self.before_calculate_vertex_color_dict[_selection_object] = _target_mesh.getVertexColors()
           _vertex_list = cmds.ls(self.selection_list.getSelectionStrings(_selection_object), fl=True)

           _vertex_colors = [(1.0, 0.0, 0.0, 1.0) for i in _vertex_list]

           _target_mesh.setVertexColors(_vertex_colors, xrange(_target_mesh.numVertices))
       return

   def redoIt(self):
       """
       待ち行列の実態(リドゥ) 基本処理を再度実行すればOK
       :return:
       """
       self.doIt()
       return

   def undoIt(self):
       """
       待ち行列の実態(アンドゥ) 実行前の値を書き込むことで元に戻す(ように見せる)
       :return:
       """
       for _selection_object in xrange(self.selection_list.length()):
           _dag_path, _ = self.selection_list.getComponent(_selection_object)
           target_mesh = OpenMaya.MFnMesh(_dag_path)

           target_mesh.setVertexColors(self.before_calculate_vertex_color_dict[_selection_object],
                                       xrange(target_mesh.numVertices))
       return

   def isUndoable(self):
       return True


def maya_useNewAPI():
   """
   プラグインに渡されるオブジェクトの型を示すための定義。openmaya for Python API 2.0以降のみ必須。
   :return:
   """
   pass


def initializePlugin(mobject):
   _open_maya_plugin = OpenMaya.MFnPlugin(mobject)
   try:
       _open_maya_plugin.registerCommand(VertexColorFillTool.kPluginCmdName, VertexColorFillTool.initialize)
   except TypeError:
       print('error initiliaze plugin')


def uninitializePlugin(mobject):
   _open_maya_plugin = OpenMaya.MFnPlugin(mobject)
   try:
       _open_maya_plugin .deregisterCommand(VertexColorFillTool.kPluginCmdName)
   except:
       print('error uninitiliaze plugin')

# 実行側のコード(スクリプトエディタなど)
import maya.cmds as cmds
cmds.loadPlugin('vertex_color_fill_plugin')
cmds.VertexColorFillTools()

プラグインとしてロードし、実行した結果が下記のとおりです。

各dagノードが元々持っていた頂点カラーに戻せていることが確認できます。

image7

あくまで上記のクラスを継承した際にはundoやredo等、ユーザ操作に対応した
継承元のコールバック関数が呼び出されるだけなので、

  • doIt関数「実行前の状態を記憶しておき、目的の処理を行い、場合に応じて実行後の処理状態を記憶する」
  • undoIt関数「doIt関数の際に記憶した、実行前の状態に戻すための処理を行う」
  • redoIt関数「実行後の処理状態に戻すための処理を行う」
    =今回のツールではdoIt関数を再度呼び出すことと結果が同じとなるため、doIt関数を呼び出すことでredo処理とする

上記3つの実装方針に基づいてundo・redoを実装することになります。

そのため、処理次第ではundo・redoではない処理を実装することもできてしまいます。

細心の注意を払って実装しないと、うっかりデータを破壊するような処理になりかねません。

また、プラグインの都合上Maya上でunInitialize(プラグインマネージャでプラグインのロードを外す等)されてしまうと当然そのツールで保持していた待ち行列は破棄されてしまうため、注意が必要です。

まとめ

ツールやユーザーのニーズによってundo・redoの範囲の要件は変わります。

また、undo・redoがそもそも必要ないケースもあるかと思います。

openmaya for Python API を扱う必要があるツールにundo・redoが必須要件としてツール完成後に加わる場合、単なるPythonのパッケージ群のツール設計』から『Mayaでのプラグイン設計が必須のツール設計』と、実装方式や再設計に係る工数が大きく変わることもあるため、必ず初期のツール設計段階の要件の一つとして洗い出しを行うことを強く推奨します。

今回は「ユーザーにとって扱いやすいツール」の一例として、undo・redoについて
お話しさせていただきました。

本記事がMayaでツールを作る人の手助けになれば幸いです。

このブログについて

KLabのクリエイターがゲームを制作・運営で培った技術やノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのクリエイターがゲームを制作・運営で培った技術やノウハウを発信します。