はじめてのBlenderアドオン開発

Last Update: 2023.3.1

Blender 2.7

はじめてのBlenderアドオン開発

Blender 2.7

Last Update: 2023.3.1

3-3. タイマのイベントを扱う

3-1節3-2節 では、マウスやキーボードといった、ユーザからの入力イベントを扱う方法を説明しました。 イベントを発生させる手段としては、ある時間が経過したときにイベントを発生させるタイマを設定する方法があります。 一定間隔で処理を実行するアドオンを作るためには、本節で説明する、タイマを使ったイベント処理を理解する必要があります。

作成するアドオンの仕様

タイマのイベントを扱う方法を理解するため、定期的にイベントが発生することを利用した次の機能を持つアドオンを作成します。

アドオンを作成する

1-5節 を参考にして以下のソースコードを入力し、ファイル名を sample_3_3.py として保存してください。

import bpy
from bpy.props import BoolProperty, PointerProperty
from mathutils import Vector
import math


bl_info = {
    "name": "サンプル3-3: メッシュ型のオブジェクトを一定間隔で動かす",
    "author": "Nutti",
    "version": (2, 0),
    "blender": (2, 75, 0),
    "location": "3Dビュー > プロパティパネル > 一定間隔でオブジェクトを移動",
    "description": "選択中のメッシュ型オブジェクトが一定間隔ごとに円を描くように移動するアドオン",
    "warning": "",
    "support": "TESTING",
    "wiki_url": "",
    "tracker_url": "",
    "category": "Object"
}


# プロパティ
class MOI_Properties(bpy.types.PropertyGroup):

    running = BoolProperty(
        name="一定間隔でオブジェクト移動中",
        description="一定間隔でオブジェクト移動中か?",
        default=False
    )


# オブジェクト移動の処理
class MoveObjectInterval(bpy.types.Operator):

    bl_idname = "object.move_object_interval"
    bl_label = "一定間隔でオブジェクトを移動"
    bl_description = "一定間隔でオブジェクトを移動します"

    def __init__(self):
        self.__timer = None           # タイマのハンドラ
        self.__count = 0.0             # タイマイベントが発生した回数
        self.__orig_obj_loc = {}      # 初期のオブジェクトの位置

    def __handle_add(self, context):
        if self.__timer is None:
            # タイマを登録
            self.__timer = context.window_manager.event_timer_add(
                0.1, context.window)
            # モーダルモードへの移行
            context.window_manager.modal_handler_add(self)

    def __handle_remove(self, context):
        if self.__timer is not None:
            # タイマの登録を解除
            context.window_manager.event_timer_remove(self.__timer)
            self.__timer = None

    # オブジェクトの位置を更新
    def __update_object_location(self, context):
        self.__count = self.__count + 1
        radius = 5.0                 # 回転半径
        angular_velocity = 3.0  # 角速度
        angle = angular_velocity * self.__count * math.pi / 180
        for obj, loc in self.__orig_obj_loc.items():
            obj.location = loc + Vector(
                (radius * math.sin(angle), radius * math.cos(angle), 0.0)
            )

    def modal(self, context, event):
        props = context.scene.moi_props

        # タイマイベント以外の場合は無視
        if event.type != 'TIMER':
            return {'PASS_THROUGH'}

        # 3Dビューの画面を更新
        if context.area:
            context.area.tag_redraw()

        # オブジェクトの移動を停止
        if props.running is False:
            self.__handle_remove(context)
            # オブジェクトを初期の位置に移動する
            for obj, loc in self.__orig_obj_loc.items():
                obj.location = loc
            return {'FINISHED'}

        # オブジェクトの位置を更新
        self.__update_object_location(context)

        return {'PASS_THROUGH'}

    def invoke(self, context, event):
        props = context.scene.moi_props
        if context.area.type == 'VIEW_3D':
            # 開始ボタンが押された時の処理
            if props.running is False:
                self.__orig_obj_loc = {
                    obj: obj.location.copy()
                    for obj in bpy.data.objects
                    if obj.type == 'MESH' and obj.select
                }
                props.running = True
                self.__handle_add(context)
                print("サンプル3-3: 一定間隔でオブジェクトが移動するようになります。")
                return {'RUNNING_MODAL'}
            # 終了ボタンが押された時の処理
            else:
                props.running = False
                print("サンプル3-3: 一定間隔でオブジェクトが移動しなくなります。")
                return {'FINISHED'}
        else:
            return {'CANCELLED'}


# UI
class OBJECT_PT_MOI(bpy.types.Panel):

    bl_label = "一定間隔でオブジェクトを移動"
    bl_space_type = "VIEW_3D"
    bl_region_type = "UI"

    @classmethod
    def poll(cls, context):
        objs = [
            obj
            for obj in bpy.data.objects
            if obj.type == 'MESH' and obj.select and obj.mode == 'OBJECT'
        ]
        if len(objs) == 0:
            return False
        return True

    def draw(self, context):
        sc = context.scene
        layout = self.layout
        props = sc.moi_props
        # 開始/停止ボタンを追加
        if props.running is False:
            layout.operator(
                MoveObjectInterval.bl_idname, text="開始", icon="PLAY"
            )
        else:
            layout.operator(
                MoveObjectInterval.bl_idname, text="終了", icon="PAUSE"
            )


# プロパティの作成
def init_props():
    sc = bpy.types.Scene
    sc.moi_props = PointerProperty(
        name="プロパティ",
        description="本アドオンで利用するプロパティ一覧",
        type=MOI_Properties
    )


# プロパティの削除
def clear_props():
    sc = bpy.types.Scene
    del sc.moi_props


def register():
    bpy.utils.register_module(__name__)
    init_props()
    print("サンプル3-3: アドオン「サンプル3-3」が有効化されました。")


def unregister():
    clear_props()
    bpy.utils.unregister_module(__name__)
    print("サンプル3-3: アドオン「サンプル3-3」が無効化されました。")


if __name__ == "__main__":
    register()

アドオンを使用する

アドオンを有効化する

1-5節 を参考に、作成したアドオンを有効化するとコンソールウィンドウに以下の文字列が出力されます。

サンプル3-3: アドオン「サンプル3-3」が有効化されました。

プロパティパネルを表示し、項目 [一定間隔でオブジェクトを移動] が追加されていることを確認します。

アドオンの機能を使用する

有効化したアドオンの機能を使い、動作を確認します。

1 [3Dビュー] エリアのプロパティパネルの項目 [一定間隔でオブジェクトを移動] に配置されている [開始] ボタンを押します。
2 選択中のオブジェクトが約0.1秒ごとに、開始ボタンを押したときにオブジェクトが配置されていた位置を中心として、円を描くように移動します。
3 [終了] ボタンを押すとオブジェクトが移動しなくなり、[開始] ボタンを押したときの位置にオブジェクトが移動します。
開始ボタンを押した後の移動中も、通常と同じ方法でオブジェクトを移動することができますが、タイマイベントを契機に元の場所に自動的に戻ります。

アドオンを無効化する

1-5節 を参考にして有効化したアドオンを無効化すると、コンソールウィンドウに以下の文字列が出力されます。

サンプル3-3: アドオン「サンプル3-3」が無効化されました。

ソースコードの解説

本節では、タイマイベントを扱う処理と作業時間計測の処理に絞り、サンプルのソースコードを解説します。 これまでに説明してきた内容については説明を省いています。 処理がわからなくなってしまった時は、ソースコード中のコメントやこれまでの説明を参考にしてください。 本節のサンプルのソースコードに関して、ポイントとなる点を次に示します。

本節では、オブジェクトを一定間隔で移動するモードをモーダルモードと書いている部分があります。 以降、モーダルモードと書かれていたら、オブジェクトを一定間隔で移動するモードとして読み進めても問題ありません。

アドオン内で利用するプロパティを定義する

本節のサンプルでも、複数のクラス間でデータを共有します。 サンプルで定義しているプロパティ一覧を次に示します。

プロパティ 意味
running オブジェクトを一定間隔で移動するモード中のときに、値が True となる

タイマの登録

タイマイベントを発生させるためには、タイマを登録する必要があります。 タイマの登録処理は、次に示す __handle_add メソッドで行います。

def __handle_add(self, context):
    if self.__timer is None:
        # タイマを登録
        self.__timer = context.window_manager.event_timer_add(
            0.1, context.window)
        # モーダルモードへの移行
        context.window_manager.modal_handler_add(self)

タイマは、context.window_manager.event_timer_add 関数を呼び出すことで登録することができます。 context.window_manager.event_timer_add 関数は次に示す引数を受け取り、戻り値としてタイマのハンドラを返します。

引数 値の意味
第1引数 タイマイベントを発生させる間隔を秒単位で指定
第2引数 タイマの登録先ウィンドウ

本節のサンプルでは第1引数に 0.1 を指定することで、タイマによるイベントを0.1秒ごとに発生させます。 作業時間の測定を開始した時に押したボタンが存在するウィンドウでタイマイベントを発生させたいため、第2引数には context.window を指定します。

戻り値として返されたハンドラはタイマの登録を解除するときに使用するため、インスタンス変数 __timer に保存します。

最後にモーダルモードへ移行しますが、必ずしも __handle_add メソッド内で行う必要はありません。 __handle_add メソッド自体が invoke メソッドから呼び出されているため、3-1節3-2節 と同様に、invoke メソッドの処理内で context.window_manager.modal_handler_add 関数を呼んでモーダルモードへ移行しても問題ありません。

タイマの登録を解除

タイマを登録すると、タイマの登録を解除するまでタイマイベントが送られてきます。 このため、タイマが不要になったら登録を解除する必要があります。

タイマの登録解除処理は、次に示す __handle_remove メソッドで行っています。

def __handle_remove(self, context):
    if self.__timer is not None:
        # タイマの登録を解除
        context.window_manager.event_timer_remove(self.__timer)
        self.__timer = None

タイマは context.window_manager.event_timer_remove 関数を呼び出すことで登録解除できますが、引数には context.window_manager.modal_handler_add 関数の戻り値として返されたタイマのハンドラを渡す必要があります。 本節のサンプルでは、タイマのハンドラを保存したインスタンス変数 __timer を引数に渡し、タイマの登録を解除します。

登録解除済のタイマのハンドラにアクセスすることによる不正な動作を避けるために、タイマのハンドラを保存するインスタンス変数 __timerNone を代入します。

modalメソッド

タイマイベントが発生すると、modal メソッドが呼ばれます。

3-1節3-2節 と同様に modal メソッドの最初で、[3Dビュー] エリアの画面更新と modal メソッドの終了判定処理を行います。

# タイマイベント以外の場合は無視
if event.type != 'TIMER':
    return {'PASS_THROUGH'}

3-1節3-2節 で説明したように、modal メソッドはキーボードやマウスのイベントが発生したときにも呼ばれます。 このためタイマイベントが発生したときのみオブジェクトを移動するようにしないと、キーボードやマウスの入力イベントが発生するたびにオブジェクトが移動してしまいます。 そこで発生したイベントがタイマイベントではないときに {'PASS_THROUGH'} を返すことで、マウスやキーボードからのイベントが発生したときにオブジェクトが移動しないようにします。

# オブジェクトの移動を停止
if props.running is False:
    self.__handle_remove(context)
    # オブジェクトを初期の位置に移動する
    for obj, loc in self.__orig_obj_loc.items():
        obj.location = loc
    return {'FINISHED'}

続いて、[終了] ボタンが押されたときにモーダルモードを終了する処理を実行します。 [終了] ボタンが押されると、invoke メソッドの処理内で props.runningFalse に設定されます。 props.runningFalse が設定されていた場合は、__handle_remove メソッドを呼び出してタイマを登録解除したあとにオブジェクトを初期位置に移動し、{'FINISHED'} を返してモーダルモードを終了します。

オブジェクトを初期位置に移動するために、メンバ変数 __orig_obj_loc に保存された初期位置を使っています。 メンバ変数 __orig_obj_loc にオブジェクトの初期位置を保存する処理については、invoke メソッドの処理で説明します。

最後に、__update_object_location メソッドを呼び出してオブジェクトの位置を更新します。 __update_object_location メソッド内で行なっている処理については、次に説明します。

オブジェクトの位置を更新する

タイマイベントが発生したときにオブジェクトの位置を更新する処理は、__update_object_location メソッドで行います。

# オブジェクトの位置を更新
def __update_object_location(self, context):
    self.__count = self.__count + 1
    radius = 5.0                 # 回転半径
    angular_velocity = 3.0  # 角速度
    angle = angular_velocity * self.__count * math.pi / 180
    for obj, loc in self.__orig_obj_loc.items():
        obj.location = loc + Vector(
            (radius * math.sin(angle), radius * math.cos(angle), 0.0)
        )

オブジェクトの初期位置はインスタンス変数 __orig_obj_loc に保存されているため、オブジェクトの初期位置に移動先の位置を相対座標として加えることで、オブジェクトの位置を更新します。 オブジェクトの位置は obj.location から参照・変更することができます。

更新するオブジェクトの位置(x, y, z)=(X, Y, Z)は、初期位置を(x, y, z)=(ix, iy, iz)、半径r、回転角度aとして次の計算式で求めます。

(X, Y, Z) = (ix + r * sin(a), iy + r * cos(a), iz)

本節のサンプルでは、タイマイベントが発生して __update_object_location メソッドが呼び出されたときに、インスタンス変数 __count をカウントアップします。 回転角a(ソースコード上の変数 angle)がインスタンス変数 __count の値が増えるに従って増加するため、初期位置を中心としてオブジェクトの位置が円を描くように回転するように移動します。

本節のサンプルでは、オブジェクトが初期位置を中心として半径(ソースコード上の変数 radius5.0 、角速度(ソースコード上の変数 angular_velocity3.0 で回転します。

invokeメソッド

# 開始ボタンが押された時の処理
if props.running is False:
    self.__orig_obj_loc = {
        obj: obj.location.copy()
        for obj in bpy.data.objects
        if obj.type == 'MESH' and obj.select
    }

オブジェクトの初期位置は、invoke メソッドの開始ボタンが押されたとき(props.runningFalse のとき)の処理の中で、インスタンス変数である __orig_obj_loc にオブジェクトをキーとして保存します。

本節のサンプルでは、選択中のメッシュ型のオブジェクトを移動の対象としているため、移動対象のオブジェクトを選別した上でオブジェクトの初期位置を保存する必要があります。

オブジェクトが選択中であることは、obj.selectTrue が代入されていることで判断できます。 また、オブジェクトの型は変数 obj.type で判断できます。 本節のサンプルでは、メッシュ型のオブジェクトであることを確認したいため、メッシュ型のオブジェクトであることを確認するためのコードは obj.type == 'MESH' となります。 オブジェクトの型の一覧を次に示します。

意味
MESH メッシュ
CURVE カーブ
SURFACE サーフェス
META メタオブジェクト
FONT テキストオブジェクト
ARMATURE アーマチュア
LATTICE ラティス
EMPTY 空のオブジェクト
CAMERA カメラ
LAMP ランプ
SPEAKER スピーカー
サンプルでは、obj.location.copy のように、copy メソッドを用いて位置情報を示すVectorオブジェクトのコピーを作っています。 これは、Vector オブジェクトのコピーを作らないと、__orig_obj_loc はオブジェクトの位置情報の「参照」を持ち続けることになってしまうからです。 obj.location への参照を持つということは、__update_object_location メソッドで obj.location を更新されたときに、更新された位置情報を持つことと同じことになるため、__orig_obj_loc を利用するオブジェクトの位置更新処理が正しく動作しません。 このように、BlenderのAPIを変数に代入する場合は、参照コピーなのか実体コピーなのかを気をつけて実装する必要があります。

最後に、__update_object_location メソッドを呼び出してオブジェクトの位置を更新します。

プロパティパネルの項目表示/非表示切り替え

[3Dビュー] エリアのプロパティパネルに追加した項目 [一定間隔でオブジェクトを移動] は、2-8節 で説明した poll クラスメソッドで、項目を表示する条件を絞っています。 本節のサンプルでは、最低でも1つのメッシュ型のオブジェクトが選択され、かつオブジェクトモードの時に項目を表示します。 オブジェクトの型がメッシュ型かつ選択された状態であるかを判定する方法は先ほど説明した通り、obj.typeMESH かつ obj.selectTrue の場合です。 そして、現在のオブジェクトが [オブジェクトモード] と [エディットモード] のどちらの状態にあるのかは obj.mode により取得することができることから、先ほどの項目の表示条件を満たしたことを判定するコードは次のようになります。

@classmethod
def poll(cls, context):
    objs = [
        obj
        for obj in bpy.data.objects
        if obj.type == 'MESH' and obj.select and obj.mode == 'OBJECT'
    ]
    if len(objs) == 0:
        return False
    return True

項目を表示する条件を満たすオブジェクトが存在しない場合、poll クラスメソッドは False を返すことで項目を表示しないようにします。

なお、obj.mode には次のような値が設定され、オブジェクトが現在どのようなモードであるかを確認するときに利用することができます。

モード
OBJECT オブジェクトモード
EDIT エディットモード
SCULPT スカルプトモード
VERTEX_PAINT 頂点ペイント
WEIGHT_PAINT ウェイトペイント
TEXTURE_PAINT テクスチャペイント
PARTICLE_EDIT パーティクル編集
POSE ポーズモード
オブジェクトモードかエディットモードかを判定する方法として、bpy.context.mode を参照して 'OBJECT' であることを確かめる方法もあります。 また、オブジェクトモード時のみプロパティパネルに項目を表示したい場合は、2-8節 で示したパネルクラスのクラス変数bl_contextにobjectmodeを指定することでも実現可能です。

まとめ

本節では、タイマのイベントを扱う方法を説明しました。 タイマを使うと指定した間隔でイベントを発生させることができるため、定期的に処理を実行するような機能を実現することができます。

3-1節 から本節まで3節にわたってイベントを扱う処理を説明しました。 イベントを扱う場合は、いずれの場合においても modal メソッドや invoke メソッドを実装する必要があるという点では同じです。

ポイント