0
$\begingroup$

Is there a recommended option for avoiding fence-post issues (as in waiting for input) when exiting or changing a modal operator in Blender? It's been something I've been trying to find a nice solution for with my add-on. Currently I have been using "cursor_warp" as the rough equivalent to a "tic" event (found in many game engine APIs) to tell Blender to run the modal event loop again, but this feels like a sloppy workaround. When a modal is changed or exited without being followed "cursor_warp" the current key settings and any on-screen messages do not change until a key press or a mouse move is detected to "bump" the event loop.

I know Blender has a lot of modals operators itself and I was curious what the favored solution was.

Rough demonstration of what the problem looks like in GIF form below:

exaggerated example using grab tool

Edit: sample code:

Note, the key areas to look at in this sample are the "main_entry" and "draw_callback_px" functions.

'''
BEGIN GPL LICENSE BLOCK

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software Foundation,
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

END GPL LICENSE BLOCK

============================================================

Code sample demonstrating a modal that draws a line using Blender 3D's
Python API and OpenGL interface. This script should exit after clicking
the mouse twice, but will stay running until user input causes the event
loop to update. If the "warp_cursor" function call at the bottom of the
main_entry function (line 211) is uncommented, the script will exit 
immediately after the second mouse click.
'''

from copy import deepcopy

import bpy
import bmesh
import bgl
from mathutils import Vector
from bpy_extras import view3d_utils
from bpy_extras.view3d_utils import location_3d_to_region_2d as loc3d_to_reg2d
from bpy_extras.view3d_utils import region_2d_to_vector_3d as reg2d_to_vec3d
from bpy_extras.view3d_utils import region_2d_to_location_3d as reg2d_to_loc3d
from bpy_extras.view3d_utils import region_2d_to_origin_3d as reg2d_to_org3d

print("Loaded add-on.\n")  # debug

# "Constant" values
(
    X,
    Y,
    Z,
    PLACE_1ST,
    PLACE_2ND,
    EXIT
) = range(6)


class Colr:
    red   = 1.0, 0.0, 0.0, 0.5
    green = 0.0, 1.0, 0.0, 0.5
    blue  = 0.0, 0.0, 1.0, 0.5
    white = 1.0, 1.0, 1.0, 1.0
    grey  = 1.0, 1.0, 1.0, 0.4
    black = 0.0, 0.0, 0.0, 1.0
    brown = 0.15, 0.15, 0.15, 0.20


def backup_blender_settings():
    backup = [
        deepcopy(bpy.context.tool_settings.use_snap),
        deepcopy(bpy.context.tool_settings.snap_element),
        deepcopy(bpy.context.tool_settings.snap_target),
        deepcopy(bpy.context.space_data.pivot_point),
        deepcopy(bpy.context.space_data.transform_orientation),
        deepcopy(bpy.context.space_data.show_manipulator),
        deepcopy(bpy.context.scene.cursor_location)]
    return backup


def init_blender_settings():
    bpy.context.tool_settings.use_snap = False
    bpy.context.tool_settings.snap_element = 'VERTEX'
    bpy.context.tool_settings.snap_target = 'CLOSEST'
    bpy.context.space_data.pivot_point = 'ACTIVE_ELEMENT'
    bpy.context.space_data.transform_orientation = 'GLOBAL'
    bpy.context.space_data.show_manipulator = False
    return


def restore_blender_settings(backup):
    bpy.context.tool_settings.use_snap = deepcopy(backup[0])
    bpy.context.tool_settings.snap_element = deepcopy(backup[1])
    bpy.context.tool_settings.snap_target = deepcopy(backup[2])
    bpy.context.space_data.pivot_point = deepcopy(backup[3])
    bpy.context.space_data.transform_orientation = deepcopy(backup[4])
    bpy.context.space_data.show_manipulator = deepcopy(backup[5])
    bpy.context.scene.cursor_location = deepcopy(backup[6])
    return


def draw_pt_2D(pt_co, pt_color):
    if pt_co is not None:
        bgl.glEnable(bgl.GL_BLEND)
        bgl.glPointSize(40)
        bgl.glColor4f(*pt_color)
        bgl.glBegin(bgl.GL_POINTS)
        bgl.glVertex2f(*pt_co)
        bgl.glEnd()
    return


def draw_line_2D(pt_co_1, pt_co_2, pt_color):
    if None not in (pt_co_1, pt_co_2):
        bgl.glEnable(bgl.GL_BLEND)
        bgl.glPointSize(15)
        bgl.glColor4f(*pt_color)
        bgl.glBegin(bgl.GL_LINE_STRIP)
        bgl.glVertex2f(*pt_co_1)
        bgl.glVertex2f(*pt_co_2)
        bgl.glEnd()
    return


# Creates a mesh object origin to use as a "snapping" point and for plotting
# locations in the 3D Viewport
class SnapPoint():
    def __init__(self):
        self.mode = "OBJECT"
        self.point = None
        self.ob = bpy.context.scene.objects

    # todo : move outside SnapPoint ?
    def get_mouse_3d(self, mous_loc):
        region = bpy.context.region
        rv3d = bpy.context.region_data

        mous_vec3d = reg2d_to_vec3d(region, rv3d, mous_loc)
        enterloc = reg2d_to_loc3d(region, rv3d, mous_loc, mous_vec3d)
        test2d = loc3d_to_reg2d(region, rv3d, enterloc)
        # make sure converted mouse location is visible from the 3D view,
        # if not, use less accurate alternative for getting mouses 3D coordinates
        if test2d is None:
            persp_md_fix = mous_vec3d / 5
            enterloc = reg2d_to_org3d(region, rv3d, mous_loc) + persp_md_fix

        return enterloc

    def create(self, ms_loc_2d, ed_type):
        ms_loc_3d = self.get_mouse_3d(ms_loc_2d)
        if ed_type == 'OBJECT':
            bpy.ops.object.add(type = 'MESH', location = ms_loc_3d)
            self.point = bpy.context.object
        bpy.ops.transform.translate('INVOKE_DEFAULT')

    # Makes sure only the "guide point" object or vert
    # added with create is grabbed.
    def grab(self, ed_type):
        if ed_type == 'OBJECT':
            bpy.ops.object.select_all(action='DESELECT')
            self.ob[0].select = True
            bpy.ops.transform.translate('INVOKE_DEFAULT')

    # Makes sure only the "guide point" object or vert
    # added with create is deleted.
    def remove(self, ed_type):
        if ed_type == 'OBJECT':
            bpy.ops.object.select_all(action='DESELECT')
            self.ob[0].select = True
            bpy.ops.object.delete()
        self.point = None

    def get_co(self, ed_type):
        if self.mode == 'OBJECT':
            return self.point.location.copy()


def exit_addon(self):
    if self.curr_ed_type == 'EDIT_MESH':
        bpy.ops.object.editmode_toggle()
        self.curr_ed_type = bpy.context.mode
    self.snap.remove(self.curr_ed_type)
    restore_blender_settings(self.settings_backup)
    print("\n\nAdd-On Exited!\n")  # debug


def warp_cursor(self, context, dest_co):
    if dest_co is None:
        return
    area = context.area
    win = None
    for r in area.regions:
        if r.type == "WINDOW":
            win = r
            break
    if win is not None:
        warpco = dest_co[0] + win.x, dest_co[1] + win.y
        context.window.cursor_warp(*warpco)


def main_entry(self, context):
    snap = self.snap.get_co(self.curr_ed_type)

    if self.stage == PLACE_1ST:
        if self.left_click:
            self.left_click = False
            self.first_pt.append(snap)
            self.stage += 1
            self.snap.grab(self.curr_ed_type)

    elif self.stage == PLACE_2ND:
        if self.left_click:
            self.left_click = False
            self.stage = EXIT
            #warp_cursor(self, context, self.mouse_loc)


def draw_callback_px(self, context):
    main_entry(self, context)

    reg = bpy.context.region
    rv3d = bpy.context.region_data
    snap = self.snap.get_co(self.curr_ed_type)
    snap2d = loc3d_to_reg2d(reg, rv3d, snap)
    pts2d = []
    line_pts = []

    if self.stage == PLACE_2ND:
        line_pts = self.first_pt[0], snap
        pts2d = [loc3d_to_reg2d(reg, rv3d, i) for i in line_pts]
        draw_line_2D(pts2d[0], pts2d[1], Colr.white)
        draw_pt_2D(pts2d[0], Colr.white)

    draw_pt_2D(snap2d, Colr.green)


class DrawLineTest(bpy.types.Operator):
    '''Draw a line with the mouse'''
    bl_idname = "view3d.add_on_test"
    bl_label = "Draw a Line"

    # Only launch Add-On from OBJECT or EDIT modes
    @classmethod
    def poll(self, context):
        return context.mode == 'OBJECT' or context.mode == 'EDIT_MESH'

    def modal(self, context, event):
        context.area.tag_redraw()
        self.curr_ed_type = context.mode

        if event.type == 'MOUSEMOVE':
            self.mouse_loc = Vector((event.mouse_region_x, event.mouse_region_y))

        if event.type in {'RET', 'LEFTMOUSE', 'SPACE'} and event.value == 'RELEASE':
            self.left_click = True

        if event.type == 'D' and event.value == 'RELEASE':
            # start debug console
            __import__('code').interact(local=dict(globals(), **locals()))

        if event.type in {'ESC', 'RIGHTMOUSE'} and event.value == 'RELEASE':
            bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
            exit_addon(self)
            return {'CANCELLED'} 

        if self.stage == EXIT:
            bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
            exit_addon(self)
            return {'FINISHED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        if context.area.type == 'VIEW_3D':
            args = (self, context)

            # Add the region OpenGL drawing callback
            # draw in view space with 'POST_VIEW' and 'PRE_VIEW'
            self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_callback_px,
                    args, 'WINDOW', 'POST_PIXEL')

            if context.mode == 'EDIT_MESH':
                bpy.ops.object.editmode_toggle()
            bpy.ops.object.select_all(action='DESELECT')

            self.curr_ed_type = context.mode  # current Blender Editor Type
            self.stage = PLACE_1ST
            self.left_click = False
            self.mouse_loc = Vector((event.mouse_region_x, event.mouse_region_y))
            self.first_pt = []
            self.snap = SnapPoint()
            self.settings_backup = backup_blender_settings()
            self.debug_flag = False

            context.window_manager.modal_handler_add(self)

            init_blender_settings()
            self.snap.create(self.mouse_loc, self.curr_ed_type)
            print("Add-on started!")  # debug

            return {'RUNNING_MODAL'}
        else:
            self.report({'WARNING'}, "View3D not found, cannot run operator")
            return {'CANCELLED'}


def register():
    bpy.utils.register_class(DrawLineTest)

def unregister():
    bpy.utils.unregister_class(DrawLineTest)

if __name__ == "__main__":
    register()
$\endgroup$
1
  • 1
    $\begingroup$ problem is too abstract for me (although I could guess, I might guess wrong too), try show the simplest possible example, explain exactly what goes wrong. $\endgroup$
    – ideasman42
    Commented Oct 27, 2017 at 4:13

1 Answer 1

0
$\begingroup$

I just realized what was causing the issue. The problem (at least with my sample) was handling the modal input (the left clicks) outside the modal method and in the "main_entry" function. If the code handling left clicks in my example is instead moved inside the "modal" method in "DrawLineTest" the script exits as expected without needing to use "cursor_warp" to bump the event loop.

Corrected code below:

'''
BEGIN GPL LICENSE BLOCK

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software Foundation,
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

END GPL LICENSE BLOCK

============================================================

Corrected code sample demonstrating a modal that draws a line using
Blender 3D's Python API and OpenGL interface. This script will now exit
after clicking the left mouse twice instead of continuing to run until
user input causes the event loop to update.
'''

from copy import deepcopy

import bpy
import bmesh
import bgl
from mathutils import Vector
from bpy_extras import view3d_utils
from bpy_extras.view3d_utils import location_3d_to_region_2d as loc3d_to_reg2d
from bpy_extras.view3d_utils import region_2d_to_vector_3d as reg2d_to_vec3d
from bpy_extras.view3d_utils import region_2d_to_location_3d as reg2d_to_loc3d
from bpy_extras.view3d_utils import region_2d_to_origin_3d as reg2d_to_org3d

print("Loaded add-on.\n")  # debug

# "Constant" values
(
    X,
    Y,
    Z,
    PLACE_1ST,
    PLACE_2ND,
    EXIT
) = range(6)


class Colr:
    red   = 1.0, 0.0, 0.0, 0.5
    green = 0.0, 1.0, 0.0, 0.5
    blue  = 0.0, 0.0, 1.0, 0.5
    white = 1.0, 1.0, 1.0, 1.0
    grey  = 1.0, 1.0, 1.0, 0.4
    black = 0.0, 0.0, 0.0, 1.0
    brown = 0.15, 0.15, 0.15, 0.20


def backup_blender_settings():
    backup = [
        deepcopy(bpy.context.tool_settings.use_snap),
        deepcopy(bpy.context.tool_settings.snap_element),
        deepcopy(bpy.context.tool_settings.snap_target),
        deepcopy(bpy.context.space_data.pivot_point),
        deepcopy(bpy.context.space_data.transform_orientation),
        deepcopy(bpy.context.space_data.show_manipulator),
        deepcopy(bpy.context.scene.cursor_location)]
    return backup


def init_blender_settings():
    bpy.context.tool_settings.use_snap = False
    bpy.context.tool_settings.snap_element = 'VERTEX'
    bpy.context.tool_settings.snap_target = 'CLOSEST'
    bpy.context.space_data.pivot_point = 'ACTIVE_ELEMENT'
    bpy.context.space_data.transform_orientation = 'GLOBAL'
    bpy.context.space_data.show_manipulator = False
    return


def restore_blender_settings(backup):
    bpy.context.tool_settings.use_snap = deepcopy(backup[0])
    bpy.context.tool_settings.snap_element = deepcopy(backup[1])
    bpy.context.tool_settings.snap_target = deepcopy(backup[2])
    bpy.context.space_data.pivot_point = deepcopy(backup[3])
    bpy.context.space_data.transform_orientation = deepcopy(backup[4])
    bpy.context.space_data.show_manipulator = deepcopy(backup[5])
    bpy.context.scene.cursor_location = deepcopy(backup[6])
    return


def draw_pt_2D(pt_co, pt_color):
    if pt_co is not None:
        bgl.glEnable(bgl.GL_BLEND)
        bgl.glPointSize(40)
        bgl.glColor4f(*pt_color)
        bgl.glBegin(bgl.GL_POINTS)
        bgl.glVertex2f(*pt_co)
        bgl.glEnd()
    return


def draw_line_2D(pt_co_1, pt_co_2, pt_color):
    if None not in (pt_co_1, pt_co_2):
        bgl.glEnable(bgl.GL_BLEND)
        bgl.glPointSize(15)
        bgl.glColor4f(*pt_color)
        bgl.glBegin(bgl.GL_LINE_STRIP)
        bgl.glVertex2f(*pt_co_1)
        bgl.glVertex2f(*pt_co_2)
        bgl.glEnd()
    return


# Creates a mesh object origin to use as a "snapping" point and for plotting
# locations in the 3D Viewport
class SnapPoint():
    def __init__(self):
        self.mode = "OBJECT"
        self.point = None
        self.ob = bpy.context.scene.objects

    # todo : move outside SnapPoint ?
    def get_mouse_3d(self, mous_loc):
        region = bpy.context.region
        rv3d = bpy.context.region_data

        mous_vec3d = reg2d_to_vec3d(region, rv3d, mous_loc)
        enterloc = reg2d_to_loc3d(region, rv3d, mous_loc, mous_vec3d)
        test2d = loc3d_to_reg2d(region, rv3d, enterloc)
        # make sure converted mouse location is visible from the 3D view,
        # if not, use less accurate alternative for getting mouses 3D coordinates
        if test2d is None:
            persp_md_fix = mous_vec3d / 5
            enterloc = reg2d_to_org3d(region, rv3d, mous_loc) + persp_md_fix

        return enterloc

    def create(self, ms_loc_2d, ed_type):
        ms_loc_3d = self.get_mouse_3d(ms_loc_2d)
        if ed_type == 'OBJECT':
            bpy.ops.object.add(type = 'MESH', location = ms_loc_3d)
            self.point = bpy.context.object
        bpy.ops.transform.translate('INVOKE_DEFAULT')

    # Makes sure only the "guide point" object or vert
    # added with create is grabbed.
    def grab(self, ed_type):
        if ed_type == 'OBJECT':
            bpy.ops.object.select_all(action='DESELECT')
            self.ob[0].select = True
            bpy.ops.transform.translate('INVOKE_DEFAULT')

    # Makes sure only the "guide point" object or vert
    # added with create is deleted.
    def remove(self, ed_type):
        if ed_type == 'OBJECT':
            bpy.ops.object.select_all(action='DESELECT')
            self.ob[0].select = True
            bpy.ops.object.delete()
        self.point = None

    def get_co(self, ed_type):
        if self.mode == 'OBJECT':
            return self.point.location.copy()


def exit_addon(self):
    if self.curr_ed_type == 'EDIT_MESH':
        bpy.ops.object.editmode_toggle()
        self.curr_ed_type = bpy.context.mode
    self.snap.remove(self.curr_ed_type)
    restore_blender_settings(self.settings_backup)
    print("\n\nAdd-On Exited!\n")  # debug


def warp_cursor(self, context, dest_co):
    if dest_co is None:
        return
    area = context.area
    win = None
    for r in area.regions:
        if r.type == "WINDOW":
            win = r
            break
    if win is not None:
        warpco = dest_co[0] + win.x, dest_co[1] + win.y
        context.window.cursor_warp(*warpco)


def draw_callback_px(self, context):
    #main_entry(self, context)  moved to modal in DrawLineTest

    reg = bpy.context.region
    rv3d = bpy.context.region_data
    snap = self.snap.get_co(self.curr_ed_type)
    snap2d = loc3d_to_reg2d(reg, rv3d, snap)
    pts2d = []
    line_pts = []

    if self.stage == PLACE_2ND:
        line_pts = self.first_pt[0], snap
        pts2d = [loc3d_to_reg2d(reg, rv3d, i) for i in line_pts]
        draw_line_2D(pts2d[0], pts2d[1], Colr.white)
        draw_pt_2D(pts2d[0], Colr.white)

    draw_pt_2D(snap2d, Colr.green)


class DrawLineTest(bpy.types.Operator):
    '''Draw a line with the mouse'''
    bl_idname = "view3d.add_on_test"
    bl_label = "Draw a Line"

    # Only launch Add-On from OBJECT or EDIT modes
    @classmethod
    def poll(self, context):
        return context.mode == 'OBJECT' or context.mode == 'EDIT_MESH'

    def modal(self, context, event):
        context.area.tag_redraw()
        self.curr_ed_type = context.mode

        if event.type == 'MOUSEMOVE':
            self.mouse_loc = Vector((event.mouse_region_x, event.mouse_region_y))

        if event.type in {'RET', 'LEFTMOUSE', 'SPACE'} and event.value == 'RELEASE':
            #self.left_click = True
            snap = self.snap.get_co(self.curr_ed_type)

            if self.stage == PLACE_1ST:
                #if self.left_click:
                #self.left_click = False
                self.first_pt.append(snap)
                self.stage += 1
                self.snap.grab(self.curr_ed_type)

            elif self.stage == PLACE_2ND:
                #if self.left_click:
                #self.left_click = False
                self.stage = EXIT
                #warp_cursor(self, context, self.mouse_loc)

        if event.type == 'D' and event.value == 'RELEASE':
            # start debug console
            __import__('code').interact(local=dict(globals(), **locals()))

        if event.type in {'ESC', 'RIGHTMOUSE'} and event.value == 'RELEASE':
            bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
            exit_addon(self)
            return {'CANCELLED'} 

        if self.stage == EXIT:
            bpy.types.SpaceView3D.draw_handler_remove(self._handle, 'WINDOW')
            exit_addon(self)
            return {'FINISHED'}

        return {'RUNNING_MODAL'}

    def invoke(self, context, event):
        if context.area.type == 'VIEW_3D':
            args = (self, context)

            # Add the region OpenGL drawing callback
            # draw in view space with 'POST_VIEW' and 'PRE_VIEW'
            self._handle = bpy.types.SpaceView3D.draw_handler_add(draw_callback_px,
                    args, 'WINDOW', 'POST_PIXEL')

            if context.mode == 'EDIT_MESH':
                bpy.ops.object.editmode_toggle()
            bpy.ops.object.select_all(action='DESELECT')

            self.curr_ed_type = context.mode  # current Blender Editor Type
            self.stage = PLACE_1ST
            self.left_click = False
            self.mouse_loc = Vector((event.mouse_region_x, event.mouse_region_y))
            self.first_pt = []
            self.snap = SnapPoint()
            self.settings_backup = backup_blender_settings()
            self.debug_flag = False

            context.window_manager.modal_handler_add(self)

            init_blender_settings()
            self.snap.create(self.mouse_loc, self.curr_ed_type)
            print("Add-on started!")  # debug

            return {'RUNNING_MODAL'}
        else:
            self.report({'WARNING'}, "View3D not found, cannot run operator")
            return {'CANCELLED'}


def register():
    bpy.utils.register_class(DrawLineTest)

def unregister():
    bpy.utils.unregister_class(DrawLineTest)

if __name__ == "__main__":
    register()
$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .