動作を実装する(1) | 3dsmax python入門 #02

2023年5月22日

 早速ツールを作ってみようと思うわけですが、まず最初にすべきことは、「どんな機能を持ったツールにするか」という部分です。このチュートリアルでは、2020版のチュートリアルで作ったのと同じ機能をもつツールを作りましょう。同じ機能を実装していきますが、全体をPythonで作り、MaxPlus を使わずにpymxs を使用することになるため、コードは全く違うものになります。

仕様の検討

必要な動作

 この部分は以前の記事と重複しますが、次のような動作をするツールを考えます。

 このように、あるオブジェクトを、X軸、Y軸、Z軸方向にそれぞれ指定した個数、間隔でコピーするものを考えます。以前のチュートリアルでは単純にコピーしただけでしたが、今回のツールではコピー、インスタンス、参照を選べるようにしてみましょう。

入力の検討

 まず、ユーザに入力させる情報をどうするか考えます。必要な情報をリストアップしてみます。

  • X方向のコピー数とコピー間隔
  • Y方向のコピー数とコピー間隔
  • Z方向のコピー数とコピー間隔  シーン内のオブジェクトを選択し、6つの情報を入力して実行したら結果が得られる、というのが良さそうです。
  • コピー、インスタンス、参照の選択

 これらの情報をユーザから受け取り、実行すると結果が得られる、という動作が想定されます。情報を受け取る部分はUI(ユーザインターフェース)が受け持ちますが、一旦UIは後回しにし、まずは機能の部分だけで実装を進めていきましょう。あとでUIから操作できるようにする必要があるので、それを見越した設計をしていきます。

設計

クラスか関数群(モジュール)か

 動作として想定されるのは、必要な情報を入力した上で実行ボタンを押すと結果が得られる、というものです。大掛かりなツールであれば実行部分もクラスとして設計したほうが良いのですが、今回は実行する内容が非常にシンプルなので、関数群(モジュール)として実装していこうと思います。

 今回はクラスの詳しい説明は省略しますが、クラスというのは簡単に言うと「何らかのデータとそのデータを操作する関数をセットにしたもの」なので、クラスにするか関数群にするかは、その関数によって変更されるデータを保持しておく必要があるかどうか、というあたりで判断すると良いと思います。今回のツールはシーンの状態を操作するだけで、結果の状態を保持しておく必要はないのでクラスにする必要はないという判断です。(チュートリアルの後半で説明しますが、機能とUIを合体させるところで最終的にはクラス化します)

フォルダ構造

 それほど大規模なツールを作るわけではないので複雑なフォルダ構造は必要ありませんが、このツールに関連するファイルだけは一つにまとまっていたほうが開発しやすいので、ツール名のフォルダを作ってその中にファイルを置くことにしましょう。サブフォルダが必要になったら適宜追加するとして、まずはツール名のフォルダを作ります。ここではツールの動作から「GridCopyTool」という名前にし、その中にGridCopy.py というファイルを置くことにします。

 Pythonがインストールされていると、拡張子.py はPython のアイコンになります。このGridCopy.py に関数を書いていきましょう。

関数設計のポイント

 ここから実際の関数を考えますが、まず最初の方針として、必要なデータはすべて引数として受け取るようにしましょう。クラスのメンバ関数の場合は、メンバ変数をメンバ関数内で使用するといった実装も行いますが、単体の関数として設計するのであれば必要なものはすべて引数として受け取り、関数の外にある変数などを使わないような設計にしておくのが良いです。そうすることでこの関数の再利用性が高まり、コードの可読性(読みやすさ)も高まります。

関数を定義する

 ではさっそく、今回の実行の本体になる関数を実装していきましょう。まずは関数の枠を書きます。その前に、ファイルの先頭にライブラリのインポートを書きましょう。必要なものが出てきたら都度ファイルの先頭に書き足していきます。ここではまず、3dsmax のツールを作るときに欠かせないものを書いておきましょう。

from pymxs import runtime

 実は最近の3dsmax では起動時にすでにpymxs はインポートされた状態になっているのですが、コードとしては書いておかないとコード単体で読むときにわからなくなるので、明示的にインポートしておきます。pymxs全体をインポートしても良いのですが、必要なものを限定して書いたほうが何をインポートしているのか意識するようになるので、個別にインポートする習慣をつけておくのが良いと思います。

関数の枠を書く

 まず関数が受け取るべき引数を考えます。必要なのは

  • 元のオブジェクト
  • X軸方向のコピー個数と間隔
  • Y軸方向のコピー個数と間隔
  • Z軸方向のコピー個数と間隔
  • コピー種別(コピー、インスタンス、参照のいずれか)

 このような感じです。けっこうたくさんの引数が必要ですね。全部で8個になります。この関数は結果を返すのではなく、シーン自体を操作するものなので、一旦戻り値はなしで書いていきます。(成否を返す関数にすることもできますが、それは追々考えることにして、一旦は戻り値なしで書いていきましょう)

 ポイントとして、関数を考えるときはまず受け取る引数と関数名を考え、なにも中身のない関数として枠だけ作る、というのがお勧めです。

def gridCopy(src_node, x_count, x_interval, y_count, y_interval, z_count, z_interval, copy_type):
    pass

 関数を作るときは、まずこのような枠だけ作り、何を渡して何が返るのかを考えると良いです。

複雑な動作をシンプルな動作に分解する

 このgridCopy という関数は枠を見るとわかるように、かなり多くの引数を受け取ってけっこう複雑な処理をすることになります。ちょっと複雑なので、ここで最初にやった手動の操作を振り返ってみましょう。重複になりますがもう一度さっきのgif を見てみます。

 この動作を言葉で説明してみましょう。

  1. コピー元のオブジェクトを選択する
  2. 選択したオブジェクトをy軸方向に複数コピー
  3. コピー後の一列分をx軸方向に複数コピー
  4. xy平面に並んだ一面分をZ軸方向に複数コピー

 という動作です。最初に選択されたものをコピーし、コピーされた結果のものを選択してまたコピー、という動作をx、y、zそれぞれの軸に対して行っています。このgifでは最初にy軸方向へコピーしていますが、x軸方向を先に行っても同じなので、ツールではx→y→zの順に行いましょう。「元になるものを等間隔に複数コピーする」という動作が共通なので、ある軸方向にこれを行う動作をサブ関数として実装します。

 このような複雑な一連の操作を行いたい場合、その操作を解きほぐして、シンプルな動作の組み合わせにできないかを考えましょう。そのうえで、一つ一つは単機能になる関数を作り、それを組み合わせて複雑な処理を実現するという発想で考えるのが良いでしょう。

 ここでは、「元のオブジェクト」「コピーする個数」「コピーする間隔」の三つの情報を受け取って1方向にコピーするlineCopy という関数を考えましょう。これをそれぞれの軸方向に3回実行することでgridCopy を実現します。lineCopy も枠だけ書いてみましょう。

def lineCopy(src_nodes, count, offset, copy_type):
    pass

 枠はこんな感じです。元オブジェクトのリスト、コピー個数、コピー間隔、コピー種別の4つを受け取ってコピー操作を実行します。元オブジェクトをリストにするのは、gridCopy における2回目以降の処理に対応するためです。つまり1回目は元オブジェクト1つだけが入ったリストを受け取る、という想定です。

 これだとコピーする軸方向が指定できないような気がしますが、offset(移動距離)をPoint3というベクトルのデータで受け取ることでコピー方向を指定する想定です。どの引数がどんな型(タイプ)なのかわかるように、コメントに書いておきましょう。

def lineCopy(sec_nodes, count, offset, copy_type):
    '''
        元オブジェクトを指定した個数、間隔で線形にコピーして並べる
        args:
            list: 元オブジェクトのリスト
            int: コピー個数
            Point3: コピーの移動距離(ベクトル)
            name: コピーの種類(コピー、参照、インスタンス)
    '''
    pass

※ 最近のPython は引数や戻り値の型を指定して書く方法もあるのですが、そこを説明すると少し難しくなるので、このチュートリアルでは旧来のPythonの書き方で、型は自動識別を使用するように書いていきます。

※ ここで書いたPoint3 やname というのはPythonに用意されている型ではなく、pymxs のruntime モジュール内で定義されている型です。そのため、pymxs.runtime がインポートされている状態でないと使えません。

 ここで、このlineCopy という関数は最終的にはgridCopy という関数の中で呼ばれる、ということを想定しておきます。使われ方として、まず元のオブジェクトを選択し、x軸方向にlineCopy を行います。次に、x軸方向にコピーされたものたちを元オブジェクトとしてy軸方向にlineCopy を行います。つまり、lineCopy した結果できたもののリストが次のlineCopy の引数になるということなので、コピーしてできたオブジェクトのリストをlineCopy 関数から返す必要があるわけです。

 では関数を書いていきましょう。

def lineCopy(src_nodes, count, offset, copy_type):
    # 戻り値として返すための配列を用意し、元オブジェクトとして渡されてきたものをコピーしておく
    return_nodes = copy(src_nodes)

 まず、戻り値を返すためにreturn_nodes というリストを用意します。これは次のlineCopy の元オブジェクトになるリストなので、今回元オブジェクトとして渡されてきたものも含まれている必要があります。

 ここで、copy という関数を使っていますが、この関数はリストオブジェクトのコピーを生成して返すという関数です。Pythonでは、通常の代入でlist を扱うとコピーはされず、同じものを参照するような動作になります。このため、物理的に別物として扱いたい場合は「同じ要素を持つリストをもう一個生成する」という動作を行う必要があります。copy 関数はそれを行うためのもので、copy というモジュールに入っています。そのためインポートセクションに以下を追加してください。

from copy import copy

 次に、渡された元オブジェクトのリストに含まれているものを、指定された移動間隔で配置しながらコピーする処理を書きます。

# 元オブジェクトリストに含まれているオブジェクトそれぞれに対して実行
for n in src_nodes:
    # 今対象になっているオブジェクトの配置先(移動距離)をcur_offset とする
    cur_offset = offset
  # 指定個数分のコピーを行う
    for i in range(count - 1):
        # オブジェクトをコピーする処理

 複数のオブジェクトを複数個コピーするので、二重のfor ループになっています。もっと効率の良い方法があるだろうという話はありますが、ここではコードのわかりやすさを重視してこのまま二重ループで進めていきます。

 最初なので、少々冗長ではありますが、ここでの「オブジェクトをコピーする処理」の部分をどうやって実装するのか、リファレンスの読み方も含めて説明していきます。

必要な機能を探す方法

 3dsmax 2023 のPython 開発で主流なのはpymxs というモジュールを使う方法です。このモジュールはpython からMaxScript の機能を使用するものなので、MaxScript の知識が必要です。目的の機能を実装するのに調べるべきは、MaxScript のヘルプなのです。

 3dsmax のメインメニューからスクリプト→MAXScript ヘルプ を開きます。最近のヘルプはオンラインヘルプなのでWebブラウザが起動して情報が表示されます。このMAXScript ヘルプを使って目的の機能を探します。

 今回使うのは、maxOps というインタフェースの中にあるcloneNodes という関数です。該当部分のリファレンスは以下のようになっています。

 MaxScript のリファレンスは非常にわかりやすく丁寧な説明がされているので便利です。渡すべき引数とその内容が細かく説明されています。これを見ると、cloneNodes に渡さなければならない引数は最初のnode (元オブジェクト)だけで、それ以外は全部オプション(渡さなかった場合はデフォルト値が使われる)だということがわかります。元オブジェクトだけ渡して実行すると、元のオブジェクトと同じ位置にコピーが生成される、という動作になります。

 基本的にはこうやって調べた関数をpymxs のruntime モジュールから使えば良い、という話なのですが、この関数は参照渡しの引数があるので少々ややこしいです。今回はその説明を詳しくしていきます。

3dsmax のPython でMaxScript の参照渡し引数を要する関数を使う方法

 長い小見出しになってしまいましたが、おそらく最初ここで躓く人が多いのではないかと思うのでちょっと冗長な説明をしてみたいと思います。

 参照渡しというのはMaxScript で”&”をつけて渡す引数のことです。ここでは、cloneNodes 関数によって生成された新しいオブジェクトを受け取るために、キーワード引数のnewNodes に配列を渡す必要があります。はじめに、MaxScript で書くとどうなるのかやってみましょう。

-- MAXScript
src = Box()
new = #()
maxOps.cloneNodes src newNodes: &new

new

 このコードは

  1. Box を生成してsrc という変数に格納
  2. new という空の配列を定義
  3. src をコピーし、新たに生成されたものをnew に格納
  4. new の内容を出力

 という動作をします。実行してみましょう。

 このように、Boxオブジェクトを生成し、それをコピーしたうえで新たに生成されたものを受け取る、という動作ができます。新たに生成されたものは戻り値としてではなく、参照引数で渡した変数の内容が更新されるという形で受け取ったことに注目してください。

 ちなみに、このcloneNodes はさらに移動距離を渡すoffset という引数にも対応しているので、offset と newNodes を渡してこれを実行すれば今回欲しいlineCopy の機能は実現できそうです。

 ここで問題になるのが、参照引数の渡し方です。ちょっと直感的にはわかりにくい書き方になるのでじっくりついてきてください。

pymxs.byref をインポートする

 pymxs モジュールには、MaxScript の参照引数を使うためのbyref という型が用意されています。これをインポートして使うのですが、ちょっととっつきにくい書き方になります。上記のMaxScript をそのままPython で書き直してみましょう。

from pymxs import runtime, byref

src = runtime.box()

# 参照渡しの関数を使用すると、関数の実行結果と、参照引数として受け取る内容のものが返ってきます。
# 戻り値が複数になるため、複数の変数で受け取ります。
# ここれは関数の戻り値をr で受け、newNodes に渡して得られるはずの参照引数の中身をnew で受け取っています。
r, new = runtime.maxOps.cloneNodes(src, newNodes=byref(None))

print(r)
print(new)

 結果はこのようになります。

 byref を使う関数を実行すると、MAXScript で実行したときの戻り値のほか、参照引数として受け取れるはずのものが戻り値として返ってきます。Python での書き方は、受け取る変数を用意しておき、その参照引数を渡す箇所にbyref(None) を渡す、という感じになります。

 これを踏まえてlineCopy のコピー部分を考えましょう

コピー操作の実装

# 元オブジェクトリストに含まれているオブジェクトそれぞれに対して実行
for n in src_nodes:
    # 今対象になっているオブジェクトの配置先(移動距離)をcur_offset とする
    cur_offset = offset
  # 指定個数分のコピーを行う
    for i in range(count - 1):
        # オブジェクトをコピーする処理
        # 受け取るけれど使用する予定のない戻り値は_ で受けるのが慣例
        # コピー種別も渡すようにしておく
        _, new_nodes = runtime.maxOps.CloneNodes(n, offset=cur_offset, cloneType=copy_type, newNodes=byref(None))
     # new_nodes で受け取ったものを最終的に返すreturn_nodes に追加する
        return_nodes.extend(new_nodes)
        # cur_offset に offset 1つ分を足す
        cur_offset += offset

 このような形になります。これを入れてlineCopy を完成させましょう。

def lineCopy(src_nodes, count, offset, copy_type):
    return_nodes = copy(src_nodes)
    for n in src_nodes:
        cur_offset = offset
        for i in range(count - 1):
            _, new_nodes = runtime.maxOps.CloneNodes(n, offset=cur_offset, cloneType=copy_type, newNodes=byref(None))
            return_nodes.extend(new_nodes)
            cur_offset += offset
    return return_nodes

 関数の最後で出来上がったreturn_nodes を返して終わります。

まとめ

 かなり長くなったのでこの辺でいったん中断しましょう。

  • 機能は関数として考える
  • 関数は引数を受け取り、戻り値を返すように設計する
  • 複雑な操作は単純な操作に分解する
  • MaxScript の参照変数をPythonから使うときはpymxs.byref を使う