graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

Reading PyMEL: ex01. ノード周り書いてみた

いくら趣味とはいえずっと読んでるのも大変だから、試しに自分でも書いてみた。
まずは PyNode に相当する部分から。

github.com

QyMEL って名前に特に意味はなくて、まぁとりあえず "P" の隣で "Q" にしとこうかな*1ってくらい。

基本設計

PyMEL をベースにもうちょいオブジェクト指向*2に寄せてみる。

クラス図は以下のとおり。PyMEL とそう大きくは変わらないと思う。Node reference で確認できるノード間の継承関係を、できるだけそのまま実装に落とし込むかたちになってる。
f:id:hal1932:20191022165024p:plain

__new__ を使わない

宗教上の理由で、Factory 系のパターン を素直に実装してみた。eval って名前は何か大袈裟な気もするけど、まぁ文字列をノードに変換する Factory 的な何かということで。

なので、PyNode 生成部を切り出してこんなかんじにしてみる。

import maya.cmds as cmds
import qymel.core as qm

cmds.polyCube()  # -> [u'pCube1', u'polyCube1']

print qm.eval('pCube1')  # -> Transform('|pCube1')
print qm.eval('pCube1.tx')  # -> Plug('pCube1.translateX')
print qm.eval('pCube1.vtx[*]')  # -> MeshVertex('pCube1.vtx[0:7]')

print qm.eval_node('pCube1')  # -> Transform('|pCube1')
print qm.eval_plug('pCube1.tx')  # -> Plug('pCube1.translateX')
print qm.eval_component('pCube1.vtx[*]')  # -> MeshVertex('pCube1.vtx[0:7]')

あるいは、型判定が不要な場合は、明示的に型指定してノード生成できるようにした。ただし諸々の型判定を一切行っていないので、間違った型を指定しても生成エラーにはならない。

print qm.Transform('pCube1')  # -> Transform('|pCube1')

cube1 = qm.Mesh('pCube1')  # ここではエラーにならない
print cube1.vertex_count  # -> ここで RuntimeError

プロパティを明確に露出する

宗教上の理由で、できるだけ素直に MObject や MFn*** を露出したい*3。なので、各クラスの get プロパティとしてインターフェイスを設けるようにした。

cube = qm.eval('pCube1')

print type(cube.mobject)  # -> <type 'OpenMaya.MObject'>
print type(cube.mfn)  # -> <type 'OpenMaya.MFnTransform'>
print type(cube.mdagpath)  # -> <type 'OpenMaya.MDagPath'>
print cube.mel_object  # -> |pCube1

maya.cmds との併用を前提とする

宗教上の理由で、「コマンドも何もかもすべてライブラリ内に取り込む」のではなく、コマンドとかの返り値を雑に eval すればそれっぽい結果を返すようにした。逆に、ノードをコマンドに渡すときは mel_object プロパティ*4でいけるようにした。

唯一の例外は ls で、まぁこれくらいはやってみようかと。

cube, poly_cube = qm.eval(cmds.polyCube())

print cube, poly_cube  # -> Transform('|pCube1') DependNode('polyCube1')
print cmds.listRelatives(cube.mel_object)  # -> [u'pCubeShape1']

print qm.ls('pCube1')  # -> [Transform('|pCube1')]
print qm.ls(type='transform')  # -> [Transform('|front'), Transform('|pCube1'), Transform('|persp'), ...

もし PyMEL のように「すべて取り込む」なら、Python 3.7 で モジュールに対する __getattr__ が実装できるようになるので、CY2020 を待ってからコマンドキャッシュ機構を実装するのが、現時点では筋がよさそう。

__repr__()

cube, poly_cube = qm.eval(cmds.polyCube())
print cube  # -> Transform('|pCube1')

from qymel.core.nodetypes import *
print eval(repr(cube))  # -> Transform('|pCube1')

__repr__ と __str__ の違いについてはいろんな解説記事があるのでそちらを読めばいいとして、ちゃんと representattion として機能するような文字列を返すようにする。これのために、Transform をはじめとする各種ノード用クラスの基底クラス DependNode のコンストラクタは、MObject と文字列の両方を受け取るように実装することになった。*5

class DependNode(_general.MayaObject):
        def __init__(self, obj):
        # type: (Union[om2.MObject, str]) -> NoReturn
        if isinstance(obj, (str, unicode)):
            obj, _ = _graphs.get_mobject(obj)
        super(DependNode, self).__init__(obj)

コード生成

ノード用のクラス定義では似たようなコードを大量に書くことになるので、雛形を自動生成 してみた。

ただコード生成といってもわりとゆるいかんじで、CSV をもとにコードを生成して、必要に応じて書き換えて使う。跡形もなく書き換えてしまうことも間々あるので、あくまで、コーディングをちょっと楽にする程度の意図。*6

こんなコードが雛形として生成されるので、適当に書き換えていく。

class ObjectSet(Entity):

    _mfn_type = om2.MFn.kSet
    _mfn_set = om2.MFnSet
    _mel_type = 'objectSet'

    @staticmethod
    def ls(*args, **kwargs):
        # type: (Any, Any) -> List[ObjectSet]
        kwargs['type'] = ObjectSet._mel_type
        return _graphs.ls_nodes(*args, **kwargs)

    @staticmethod
    def create(**kwargs):
        # type: (Any) -> ObjectSet
        return _graphs.create_node(ObjectSet._mel_type, **kwargs)

    def __init__(self, obj):
        # type: (Union[om2.MObject, str]) -> NoReturn
        super(ObjectSet, self).__init__(obj)

型判定

やってること自体は PyMEL とあまり変わらないんだけど、できるだけ try-except を減らして整理してみた。eval_*** の中身 も比較的シンプルで、MSelectionList 経由で MObject や MDagPath をとってきて、DependNode や Plug、Component あたりの __init__ に渡してるだけ。

# qymel/core/general.py
def eval(obj_name):
    tmp_mfn_comp = om2.MFnComponent()
    tmp_mfn_node = om2.MFnDependencyNode()
    if isinstance(obj_name, (str, unicode)):
        return _graphs.eval(obj_name, tmp_mfn_comp, tmp_mfn_node)
    else:
        return [_graphs.eval(name, tmp_mfn_comp, tmp_mfn_node) for name in obj_name]
# qymel/internal/graphs.py
def eval(obj_name, tmp_mfn_comp, tmp_mfn_node):
    if '.' in obj_name:
        plug = eval_plug(obj_name)
        if plug is not None:
            return plug
        comp = eval_component(obj_name, tmp_mfn_comp)
        if comp is not None:
            return comp
    else:
        node = eval_node(obj_name, tmp_mfn_node)
        if node is not None:
            return node
    raise RuntimeError('unknown object type: {}'.format(obj_name))

どの MObject がどのノード(Transform とか)に相当するかは、PyMEL と同じように Factory クラス を用意してそちらに丸投げしてる。Factory へのクラス登録 も PyMEL とほぼ同じ。

クラス設計

MayaObject

PyNode 相当、ではなく、MObject をラップするためのクラス

class MayaObject(object):
    @property
    def mobject(self): ...

    @property
    def is_null_object(self): ...

    @property
    def mel_object(self): ...

    @property
    def exists(self): ...

    def __init__(self, mobj): ...
    def __eq__(self, other): ...
    def __ne__(self, other): ...
    def __hash__(self): ...
    def __str__(self): ...

    def has_fn(self, mfn_type): ...

なので、Maya API でも MObject として表現される DependNode と Component は MayaObject を継承するようにしたけど、MPlug は MObject を継承しないので、Plug クラスは MayaObject を継承していない。attribute と plug のどちらを使うかは微妙に迷ったんだけど、よく考えたらそのへんの操作ってほとんどが MPlug 経由で行う*7ので、そのまま素直に Plug クラスとして実装した。*8

で、MayaObject は MObject に対応するクラスなので、当然ながらノードに対する各種操作(listConnections とか)は実装されてない。というわけで、QyMEL には「PyNode に相当するクラス」が存在しないことになる。

併せて、MayaObject を直接インスタンス化することも想定していない。qymel.ls() と qymel.eval() が DependNode, Component, Plug のどれかを返すので、それをそのまま使えばいい。そもそも Maya API 自体が、各種オブジェクトを MObject として扱う上で、MObject に対する操作はほぼすべて MFn*** や MPlug を通して行うことになる。であれば、最初から MFn*** や MPlug を抱えた状態でインスタンス化したほうが都合が良いことも多い。*9

Plug

PyMEL でいうところの Attribute に相当する、MPlug をラップするクラス。PyMEL に倣うなら Attribute と命名すべきだったけど、Maya 的に attribute と plug は明確に別物なので、("Attribute" と表現するほうが分かりやすかろうと理解した上で)正確さを優先した。

cube, _ = qm.eval(cmds.polyCube())
print cube.t  # -> Plug('pCube1.translate')
print cube.t.get()  # -> (0.0, 0.0, 0.0)
print cube.tx.get()  # -> 0.0

Plug の取得は、PyMEL と同じように DependNode の __getattr__ で実装してる。

cmds.getAttr に相当する MPlug.get については、基本的なところはだいたい API *10 で取れるようにしてみた。漏れはあるけど、まぁあとはひたすら穴埋めしていくだけなので、必要に応じて揃えていけばいいかなと。あと、MPlug.get の返り値はできるだけ cmds.getAttr に沿うようにしてる。PyMEL みたいに独自の Vector 型とか用意してもよかったけど、とはいえ数値計算のレイヤーを担保するのも何か違うような気がしてそのままにしてある。

Plug.set は cmds.setAttr をそのまま呼び出してるだけ。

Component

こちらもほぼ PyMEL と同じ設計。

f:id:hal1932:20191022165108p:plain

ただしイテレーターは別系統にしてある。理由は、Maya ヘルプ によるとコンポーネントってのはあくまでデータタイプとインデクスを抱えるものであって、それをシェイプ情報と一緒にイテレーターに渡すことで実際のデータ群にアクセスできるようにするもの。てことは、component と shape っていう 2 つのデータがあって、それをイテレーターが仲介するデータモデルってことになる。それを設計に起こすなら、コンポーネントイテレーターは別物と定義したほうが筋が良さそうだなと。*11

そんなわけで、QyMEL で頂点とかにアクセスするには「メッシュから取得したコンポーネントを使ってイテレーターを作成する」という流れにすることにした。*12

cube, _ = qm.eval(cmds.polyCube())
mesh = cube.shape()

vtx_comp = mesh.vertex_comp([0, 1, 2])  # メッシュから頂点コンポーネントを取得
print vtx_comp  # -> MeshVertex('pCube1.vtx[0:2]')
for vtx in mesh.vertices(vtx_comp):  # 頂点コンポーネント使ってイテレーターを作成
    print vtx.index

for vtx in mesh.vertices():  # 全頂点をイテレートするならコンポーネント指定は不要
    print vtx.index

Iterator

Component を継承しないこと以外は PyMEL と何も変わらないので、特に書くことが無い。MIt*** のラッパークラスとして動作するだけ。

DependNode

この項目で最後。

実装ボリュームとしてもライブラリインターフェイスとしても全体の要になる部分ではあるのだけど、実のところ面倒はあまりなく、Node reference で function set の親子関係をチェックしながら粛々と必要な機能を実装していくだけだったりする。設計部分でネタになりそうなところは、ここまででもうだいぶ書いてしまった。あえていうなら MFn*** の生成周りくらいか。

class DependNode(_general.MayaObject):
    _mfn_set = om2.MFnDependencyNode

    @property
    def mfn(self):
        mfn_set = self.__class__._mfn_set
        if mfn_set is None:
            return None

        mfn = self._mfn
        if mfn is None:
            mfn = mfn_set(self.mobject)
            self._mfn = mfn

        return mfn
class DagNode(Entity):
    _mfn_type = om2.MFn.kDagNode

    @property
    def mfn(self):
        ...
        mfn = self._mfn
        if mfn is None:
            mfn = mfn_set(self.mdagpath)
            self._mfn = mfn
        ...

たとえば DependNode なら MFnDependencyNode というように、各クラスによって必要な function set の型が決まってる。で、基本的にすべての function set は MObject から生成できるので、DependNode.mfn の中でそのへんをまるっと扱うようにした。ただし DagNode に関しては MDagPath から MFnDagNode を生成してやらないと MSpace.kWorld とかを引数にとるやつら*13が動かなくなっちゃうから、そこだけ再定義してやる必要がある。

おわり。

*1:宗教は同じだけど宗派が違うイメージ。

*2:宗教的な意味で

*3:ユーザーのリテラシーが高い前提で自由度を担保するとか、ライブラリ実装を厚くしすぎないようにとか。

*4:PyNode.__melobject__() のプロパティ版

*5:より正確にいうと、最初は MObject 以外を受け取る気はなかったのだけど、repr の必要性に途中で気付いたので、後付けで文字列も受け取れるように変更した。

*6:この手の自動化は凝りだすとキリがないし、たとえば ContainerBase.create() を実装すべきか?みたいな個別の話題も多い。それならいっそざっくり自動生成した上で、必要な個々のケースを手書きしていくほうが扱いやすい。

*7:というか、attribute の MObject ってプラグインノードを書くときを除けば、MPlug と比べるとあまり触る機会がない。

*8:Attribute クラスを実装するのであれば MayaObject を継承すべきだろうけど、今回は Plug なので MayaObject を継承していない。

*9:というか PyMEL 自体がそういう発想だし、良い考えだと思うのでそのまま受け継いだ。

*10:cmds.getAttr より速かったから。

*11:PyMEL 側はそのへんも分かった上で MItComponent に Component を継承させてるんだと思う。Maya の GUI 上でみたら、例えば頂点コンポーネントなんて実際の頂点そのものにしか見えないわけで、それを素直に設計に起こせばコンポーネントイテレーターが同じ親を持つのも理解できる。

*12:ちなみに PyMEL では _componentAttributes で定義されたコンポーネントに __getattr__ でアクセスできるようになっていて、抽象度でいえば PyMEL のほうが一段高い。実態は comp 関数で作成したコンポーネントイテレーターに渡してるので、やってること自体はこちらとあまり変わらない。

*13:MFnTransform.translation(MSpace.kWorld) とか、ノードの親子関係に応じて結果がかわる関数を使うときには function set に MDagPath を持たせてやる必要がある。一方で、親子関係を参照しない処理で MDagPath が渡されていても問題はないので、それなら常に MDagPath を渡してやるほうが合理的。パフォーマンス的には少し不利なんだけど、そこまでの速さを求めるならそもそも Python を使うべきではない。