Python + MaxScriptのツール開発入門(Max2020版)その11

2020年5月25日

 さて、その10からのその11ですが、実はその10まではMax2017用に書いたもののリライトで、内容的には2年半ぐらい空いての「その11」ということになります。そのため、ちょっと予定していた内容とズレた話になりますが、その10のラストで宣言した内容は今後全部書きますのでご安心ください。

 今回はツールの改善を行う前に、前回までに作ったツールを3dsmax のマクロスクリプト化して、メニューやボタンから起動できるようにする、という部分をやります。

3dsmax のマクロスクリプト

マクロスクリプトの利点

 3dsmax にはマクロスクリプトという仕組みが用意されています。マクロスクリプトには以下のような利点があります。

  • 一度評価すると次回以降、起動時に評価されるようになる(usermacros フォルダに格納される)
  • 一度評価するとツールバー、メニュー、クアッドメニューに追加したり、キーボードショートカットで実行したりできるようになる

マクロスクリプトの書き方

 マクロスクリプトは普通のスクリプトとほとんど同じですが、以下のような決まりがあります。

macroscript MacroName
buttonText: "ButtonText"
Category: "CategoryName"
(
    -- 実行される内容
)

 このように書きます。macroscript にはこのマクロを識別するための名前を、buttonText にはメニューに表示する文字列を、Category にはカテゴリ名を書きます。

マクロスクリプトを書いてみる

 では実際に、gridCopy をマクロスクリプトにしてみましょう。

macroscript GridCopy
buttonText: "GridCopy"
Category: "Rainy_CG_Lab"
(
    local file_path = "full_path\\to\\gridCopy.py"
    python.ExecuteFile file_path throwOnError:true clearUndoBuffer:false
)

 こんな感じでマクロスクリプトを記述し、これをgridCopy.mcr というファイル名で保存しましょう。

 ここでは

  • マクロ名: GridCopy
  • ボタン表示: GridCopy
  • カテゴリ名:Rainy_CG_Lab

でマクロを作成しています。

 実行は、前回までに作成したgridCopy.py をpython.ExecuteFile で実行する、という内容です。

 実行オプションのthrowOnError:true とclearUndoBuffer:false はpython.executeFile を使用するときのオススメのオプションです。特に理由がない場合は毎回この設定で実行すると良いでしょう。

 保存したら3dsmax のスクリプトエディタでこれを開き、エディタ上でCtrl+E で全体を評価してください。するとスクリプトリスナーに青い字で5桁の数字が表示されると思います。この5桁の数字は3dsmax のセッションごとに変化します。

評価したマクロの使い方

 ではこれを以下の手順でツールバーボタンとして登録してみましょう。

  1. メインメニューのカスタマイズ→ユーザインタフェースのカスタマイズ→ツールバータブを開く
  2. カテゴリのプルダウンからマクロスクリプト内で指定したカテゴリ名を選択
  3. 右のツールバー選択のところで「新規」を押して新たなツールバーを選択
  4. ツールバー上にマクロスクリプト名をドラッグすると、buttonTextで指定した文字列が表示される
  5. ツールバー自体を好きなところにドッキングする
images/gridcopy07.gif

マクロを実行してみる

 さて、ここまでやってこのボタンを押すとエラーが発生すると思います。前回までに作ってきたgridCopy.py をマクロ化するとエラーが出るのです。

images/ss23.png

エラーの内容

 エラーが発生したら「うわー」となって思考停止に陥る人がいますが、エラーは出るのが当然ぐらいに思って対処しましょう。慣れてくると、むしろエラーが出るとホッとするようになります。

-- MAXScript マクロスクリプト エラー 例外:
-- ランタイム エラー: 
Traceback (most recent call last):
SyntaxError: encoding declaration in Unicode string (gridCopy.py, line 0)

 このエラーは次のような意味です。

  • エラーの種類はSyntaxError である(SyntaxErrorとは文法的な誤りがあるという意味)
  • エンコードの宣言がユニコード文字列の中で行われたことが問題である
  • それが起きたのは gridCopy.py の0行目である  つまり、gridCopy.py の先頭の行に書いたエンコード指定に問題がある、ということです。
# -*- cofing: utf-8 -*-

というあれです。

エラーが発生する理由

 さて、このエラーですが、問題の行を削除するだけで解決はします。理由など良い、動けば良い、という人はこの行だけ削除すればOKですが、ここではなぜこのようなエラーが起きるのかを掘り下げてみます。

 まず、前回までのように、スクリプト→スクリプトを起動 で実行した場合、これは特にエラーにはなりませんでした。

 ためしに、この行を削除して、スクリプト→スクリプトを起動 で実行してみてください。すると今度はエラーになります。

 つまり、

  • エンコード指定あり
    • スクリプトを起動 → OK
    • python.executeFile → NG
  • エンコード指定なし
    • スクリプトを起動 → NG
    • python.executeFile → OK

ということになります。なお、エンコード指定なしでスクリプトを起動から実行してエラーになるのは、コードの中に日本語等のマルチバイト文字が含まれている場合だけです。コードのすべてがASCII文字(半角英数)で書かれている場合、エラーになりません。

 さて、なぜこのようなことが発生するのでしょうか。

 それはスクリプトを起動の実行と、python.executeFile の実行では .py ファイルを読み込んでいるプログラムが異なるからです。

 スクリプトを起動メニューを使用して .py ファイルを実行する場合、目的のファイルはそのままpython に渡されます。3dsmax に同梱されている3dsmaxpy.exe という実行ファイルがその本体です。

 つまりこの場合、.py ファイルの中身を読み込むのはpython エンジンで、python エンジンはファイル先頭に書いてあるコーディング指示を見て中身を解釈します。そのため、指示がないのにマルチバイト文字があると、Non-ASCII character in file というSyntaxError が発生します。

 一方、python.executeFile を実行した場合、ファイルはmaxscript のエンジンが読み込みます。maxscript のエンジンは3dsmax の文字コード設定に基づいてファイルの中身を解釈します。そのため、ファイルをロードする時点ですでに文字コードは決まっているわけです。

 ここでロードされたファイルの中身がpython に渡されて実行されますが、python が受け取る時点ですでにUnicode になっています。Unicode に変換された文字列の中にコーディング指定が書いてあるため、エラーになるのです。

 python.executeFile を使用する場合、実行する .py ファイルを保存したときの文字コードと、3dsmax のカスタマイズ→ファイル→ファイル文字列データの扱い の設定内容が一致している場合は、エンコード指定を書かずに保存すれば実行できます。

どのように修正するのが良いか

 さてこの問題、どう対処するのが良いでしょうか。問題のエンコード指定を削除すれば、とりあえずエラーは出ずに実行できるようになります。スクリプトを起動から実行するとエラーになりますが、そんなことをしなければこれでいいのではないか、と思われます。

 が、実は少々問題があります。それは、このファイルを他のpythonファイルからインポートする場合です。

 たとえば別のツールを作成したときに、このツールで作った機能を使いたい、というようなことが発生したとします。そういう場合、他のツールでこのgridCopy.py からGridCopy クラスを読み込んで使う、というようなことができるわけですが、その際に、エンコード指定がなく、さらにコードの中で日本語(コメントも含め)を使っているとエラーになるのです。

 そこで、ここではこの問題を回避するために、マクロスクリプトから呼び出す実行用の .py ファイルを新たに作り、そこからGridCopy クラスをインポートして使う、という構造にしましょう。

 またツールの配布方法を検討する際に詳述しようと思いますが、この方式にすることには他にもいろいろとメリットがあります。

実行用の呼び出しファイルを作成する

 では忘れないうちに、gridCopy.py にエンコード指定を戻しておきましょう。

 次に、新規に呼び出し用の call_gridCopy.py というファイルを作成してください。保存する場所はこれまでのファイルと同じ場所で構いません。

 このcall_gridCopy.py に、gridCopy.py からGridCopy クラスの外で作ったものをカット&ペーストします。gridCopy.py からは削除して移動させてください。

def main(f_path):
    win = GridCopy(MaxPlus.GetQMaxMainWindow(), f_path)
    win.show()

if __name__ == "__main__":
    main(os.path.dirname(os.path.abspath(__file__)))

 あとはこれに必要なものをインポートしていきますが、まず最初に、future モジュールからのインポート部分を書きます。これはpython2 系でpython3 系のような書き方をできるようにするものなので、基本的にすべてのファイルでインポートするようにしましょう。

from __future__ import absolute_import, print_function, division

 あとはコードの実行を追いかけながら必要なものを足します。まずファイルがロードされるとif 分が実行されます。その中でos.path.dirname みたいなことをやっているので、os モジュールが必要です。

 さらに、main() の中を見ると、GridCopy のコンストラクタをMaxPlus.GetQMaxMainWindow()で得られるものを引数にして呼んでいます。そのためMaxPlus とGridCopy も必要です。

import os
import MaxPlus

from gridCopy import GridCopy

 GridCopy クラスはgridCopy.py ファイル内で定義されているので、gridCopy モジュールからGridCopy クラスをimport する、という書き方になります。

 しかし、ここでgridCopy.py のある場所(パス)がpython の検索対象になっていないとgridCopy インポートすることができません。
 そこで、このcall_gridCopy.py ファイルのある場所を検索対象に追加します。それにはsys モジュールのインポートが必要になります。その上で、検索パスの追加操作を、gridCopy のインポートよりも前に書きます。

import os
import sys

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

※Python のモジュールについては項を改めて書きます。

 これでcall_gridCopy.py の全体は次のようになります。

from __future__ import absolute_import, print_function, division

import os
import sys

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

import MaxPlus

from gridCopy import GridCopy

def main(f_path):
    win = GridCopy(MaxPlus.GetQMaxMainWindow(), f_path)
    win.show()


if __name__ == "__main__":
    main(os.path.dirname(os.path.abspath(__file__)))

マクロスクリプトの呼び出しを修正する

macroscript GridCopy
buttonText: "GridCopy"
Category: "Rainy_CG_Lab"
(
    local file_path = "full_path\\to\\call_gridCopy.py"
    python.ExecuteFile file_path throwOnError:true clearUndoBuffer:false
)

マクロスクリプトの呼び出しファイル名を変更し、改めてCtrl+E で評価し直します。

クラス本体を確認

 念のためにgridCopy.py の全貌も改めて確認しておきましょう。

# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, division

import os

import MaxPlus
import pymxs

from PySide2 import QtWidgets


import gridCopy_ui as UI

reload(UI)

class GridCopy(QtWidgets.QDialog, UI.Ui_Dialog):
    def __init__(self, parent, f_path):
        super(GridCopy, self).__init__(parent)
        self.setupUi(self)

        # MaxScript関数の評価
        script_path = os.path.join(f_path,  "gridCopy.ms")
        pymxs.runtime.fileIn(script_path)

        # シグナルとスロットのコネクト
        self.pb_exec.clicked.connect(self.doGridCopy)
        self.pb_clear.clicked.connect(self.clearInputs)

    def doGridCopy(self):
        sel_count = MaxPlus.SelectionManager.GetCount()
        if sel_count != 1:
            mb = QtWidgets.QMessageBox()
            mb.setText(u"コピー元オブジェクトを1つ選択してください。")
            mb.exec_()
            return
        pymxs.runtime.doGridCopy(self.spn_x_count.value(), self.spn_y_count.value(), self.spn_z_count.value(), self.spn_x_interval.value(), self.spn_y_interval.value(), self.spn_z_interval.value())
        MaxPlus.ViewportManager.ForceCompleteRedraw()

    def clearInputs(self):
        self.spn_x_count.setValue(0)
        self.spn_y_count.setValue(0)
        self.spn_z_count.setValue(0)
        self.spn_x_interval.setValue(0)
        self.spn_y_interval.setValue(0)
        self.spn_z_interval.setValue(0)

 これで、マクロスクリプトから実行することができ、さらには実行部分だけを別のツールからインポートして使うこともできるGridCopy クラスが完成しました。

 その10で掲げたツールの改善はその12以降で進めていきましょう。

まとめ

  • マクロスクリプトを使用するとツールバーボタン、メニュー、クアッドメニュー、キーボードショートカットなどから実行できるようになる
  • マクロスクリプトでPython ファイルを実行する場合はエンコード指示を書けない
  • エンコード指示のないPython ファイルはPython エンジンが直接読み込むときに問題になる
  • マクロスクリプトからPython ファイルを実行するには、呼び出し用のファイルとツール本体を分けると良い