Modal operators
To illustrate how to create a modal operator I created a small add-on, plumb_line, to illustrates a few key principles.
The add-on itself is a bit of a toy, allowing the user to move the 3d cursor around while showing the distance to an intersection point directly below it.
This is not very useful in itself, but it does
- implement a modal operator
- show how to create overlays in the 3d view, and
- how to use
Object.ray_cast()andScene.ray_cast()to find intersections
And because the code covers all kinds of functionality, we structured the code into separate modules,
so this add-on also implements some tricks to force module reloading on reinstalling the add-on to prevent having to restart Blender every time we change something.
It also touches briefly on using numpy, and offers a lot of configuration options in the user preferences.
Quite a lot for a demo add-on, but in this article we focus on the modal operator.
Anatomy of a modal operator
Not all modal operators look the same, but a common pattern is shown below:
class OBJECT_OT_my_modal_operator(bpy.types.Operator):
bl_idname = "object.modalop"
bl_label = "Modal Operator"
@classmethod
def poll(cls, context):
...
def modal(self, context, event):
if some_condition:
...
return {"RUNNING_MODAL"}
else:
...
return {"FINISHED"} # or CANCELED
def invoke(self, context, event):
...
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
def cancel(self, context: Context) -> None:
...
The poll() method has the same function as in a non-modal operator, but typically there is no execute() method.
If an operator is invoked, for example from a menu entry, its default invoke() method will typically call its execute() method.
In a modal operator we override the invoke() method, and instead of calling execute() we add a modal handler to the window manager and return "RUNNING_MODAL".
This will cause the window manager to call the modal() method on the handler, typically the operator itself, and keep on doing so as long as that method returns "RUNNING_MODAL".
The window manager will call the the modal() method each time an event occurs, and passes this event as an argument to the call.
This event can be anything from a key press to a mouse move and we can even cause timer events to be passed if we enable a timer in the window manager.
This arrangement makes it possible to create interactive add-ons where the user uses keyboard or mouse actions to work with objects in a scene, until they end the interaction, typically by pressing escape or with a right mouse click.
The plumb_line invoke() method
Our operator will be invoked from the Object menu and it needs to do a few things:
- position the the 3d cursor somewhere above the active object,
- add a modal handler, and
- add a pair of draw handlers that show the line and intersection point as well as some text with the distance.
It does a few other things as well, but the relevant lines of code look like this:
def invoke(self, context, event):
...
z = max_world_z_of_bounding_box(
context.active_object.bound_box,
context.active_object.matrix_world
)
context.scene.cursor.location = context.active_object.location.copy()
context.scene.cursor.location.z = z + 3 # arbitrary offset
...
context.window_manager.modal_handler_add(self)
...
self.post_view_handler = bpy.types.SpaceView3D.draw_handler_add(
draw_handler_post_view, (), "WINDOW", "POST_VIEW"
)
self.post_pixel_handler = bpy.types.SpaceView3D.draw_handler_add(
draw_handler_post_pixel, (), "WINDOW", "POST_PIXEL"
)
return {"RUNNING_MODAL"}
It uses a helper function defined elsewhere to calculate the highest z-coordinate of the active object's bounding box and then copies the location to keep the cursor centered above the object in the x and y directions, but sets its z-coordinate to whatever we calculated plus an arbitrary offset, 3 in this case. Note that we needed to copy the object location because assigning it to the cursor location would also cause the object location to change every time the cursor location is updated as they would refer to the same Vector object.
Next, we add a modal handler, in this case the operator itself. This will cause the window manager to calle the modal() method and we will look at that in the next section.
Finally, we install two draw handlers, also defined in a separate module. The post view draw handler works in 3d space, and will show the actual 'plumb line' going from the 3d cursor to the intersection with the active object. The post pixel draw handler works in 2d on top of everything, and is used to show the text with the measured distance and the intersection highlight. We might cover these handlers in a future blog post.
The return value indicates that we are not done yet, and want to keep on running.
The plumb_line modal() method
def modal(self, context, event):
context.area.tag_redraw()
if event.type in {"RIGHTMOUSE", "ESC"}:
self.cancel(context)
return {"CANCELLED"}
...
if event.type in {"UP_ARROW", "DOWN_ARROW", ...}:
if (
event.value == "PRESS"
):
...
context.scene.cursor.location.y += increment
... # calculate the intersection with the object below the cursor
context.window_manager.target = worldspace_location
context.window_manager.distance_label = ...
if (
event.type.find("MOUSE") >= 0 or event.type.find("NUMPAD") >= 0
):
return {"PASS_THROUGH"}
return {"RUNNING_MODAL"}
The first thing we do is to make sure that we mark the area for redraw, so that no matter what we do, our draw handlers will be executed.
Next we check if the event was a press of the escape key or a right mouse click, as these are the common way in Blender to interrupt an operation.
If it was, we call the cancel() method to remove our draw handlers and return "CANCELLED" to signal that we are done.
We look for some other key presses as well, but we skip that here, but we also look for arrow keys. If such a key was pressed, so not released because we do not want to process a key click twice, we calculate how the position of the 3d cursor should change and the update the cursor location. By looking for just key presses, as opposed to key releases, the user can also keep the key pressed and cause rapid repetition.
With the new cursor location we then calculate the point of intersection with object straight below the cursor. We don´t show that code here, we might cover that in a future blog article, but the result ends up in the worldspace_location variable.
We store that in the target property of the window_manager where it will be picked up by our draw handlers to draw a line from the 3d cursor to that point. We also calculate the distance and store that in a window manager property too, to be picked up by our other draw handler that will display this in a text label next to the line.
Because we also want to give the user the option to navigate the 3d view to get a different perspective, so we also check if the event was a mouse event or numpad key, in which case we return "PASS_THROUGH". This will not end our operator but cause the event to pass to Blender's regular event processing, which will move the view around accordingly.
Any other events are simply ignored and we return "RUNNING_MODAL" to keep the operator running.
You may have noticed that none of the paths through the code ever return "FINISHED" and that is intentional: This add-on doesn´t change anything in the scene, just displays information in an overlay, so there is no distinction between canceling an operation or finishing it. That is also why we didn't add "UNDO" to the bl_options variable, as there will never anything to be undone. (In fact, we didn´t define bl_options at all, but its default is just {"REGISTER"})
Code availability
The full code, with full type annotation and lots of additional information in the comments and a readme is available on GitHub.
More ...
If you like these kind of detailed explanations, keep an eye out for future blog posts, or have a look at the Blender add-on development playlist on my YouTube channel.
And if you really, really, like it (and can afford it), you might consider leaving me a tip on Ko-Fi.
No comments:
Post a Comment