| name | blender-impl-operators |
| description | Use when implementing complex Blender operators -- modal operators with timer callbacks, file browsers, batch processing, or multi-step workflows. Prevents the common mistake of blocking the UI thread in long operations instead of using modal + timer pattern. Covers modal operators, file browser integration, undo/redo support, progress reporting, and batch processing operators. Keywords: modal operator, timer callback, file browser, batch processing, progress reporting, undo support, multi-step workflow, INVOKE_DEFAULT, make custom button, add menu item, create toolbar button.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires Blender 3.x/4.x/5.x with Python. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
blender-impl-operators
Quick Reference
Critical Warnings
ALWAYS clean up timers in BOTH cancel() AND the {'FINISHED'} path. Leaked timers fire indefinitely and reference deleted operator instances.
ALWAYS call wm.modal_handler_add(self) BEFORE returning {'RUNNING_MODAL'} from invoke(). Returning {'RUNNING_MODAL'} without a handler registration means events go nowhere.
ALWAYS handle ESC and RIGHTMOUSE in modal() to allow user cancellation. Without an escape path, the user is locked in the modal state.
ALWAYS set bl_options = {'REGISTER', 'UNDO'} for operators that modify scene data. 'UNDO' alone has NO effect — 'REGISTER' is required for undo to work.
NEVER modify bpy.data from a background thread. Use bpy.app.timers.register() with a queue.Queue to marshal work to the main thread.
NEVER expect viewport redraws during a synchronous execute(). The UI is frozen until execute() returns. Use a modal operator with timer for progressive visual updates.
NEVER use threading.Timer for deferred Blender operations. Use bpy.app.timers.register() instead.
Implementation Decision Tree
Need to implement a complex Blender operator?
│
├─ Long-running operation that must show progress?
│ └─ Use modal operator with timer (Section 1)
│ └─ Add wm.progress_begin/update/end (Section 5)
│ └─ Process items incrementally per TIMER event
│
├─ Need user to select a file?
│ └─ Use ImportHelper/ExportHelper mixins (Section 2)
│ └─ Or manual fileselect_add for custom file dialogs
│
├─ Need to process many objects in batch?
│ ├─ Fast operation (< 1 second)?
│ │ └─ Use synchronous execute() with context.temp_override (Section 4)
│ └─ Slow operation (> 1 second)?
│ └─ Use modal timer + progress reporting (Section 1 + 5)
│
├─ Need multi-step user interaction?
│ ├─ Sequential clicks/points in viewport?
│ │ └─ Use state machine in modal() (Section 6)
│ └─ Parameter dialog before execution?
│ └─ Use invoke_props_dialog (Section 6)
│
├─ Need deferred execution outside an operator?
│ └─ Use bpy.app.timers.register() (Section 7)
│ └─ Return None to run once, return float to repeat
│
└─ Need undo support?
├─ Single operation?
│ └─ bl_options = {'REGISTER', 'UNDO'} (Section 3)
└─ Repeated rapid calls (e.g., timer-driven)?
└─ bl_options = {'REGISTER', 'UNDO_GROUPED'} (Section 3)
Version Compatibility Matrix
| Feature | Blender 3.x | Blender 4.0+ | Blender 5.x |
|---|
wm.event_timer_add(t, window=w) | Keyword arg | Keyword arg | Keyword arg |
bpy.app.timers.register() | Available | Available | Available |
context.temp_override() | Available from 3.2 | REQUIRED | REQUIRED |
| Dict context override | Deprecated (3.2+) | REMOVED | REMOVED |
UILayout.progress() | Not available | Available (4.0+) | Available |
ImportHelper/ExportHelper | Stable | Stable | Stable |
UNDO_GROUPED bl_option | Available | Available | Available |
Section 1: Modal Operators with Timer Callbacks
When to Use
Use modal operators with timers when an operation takes more than ~0.5 seconds and must:
- Show progress to the user
- Allow cancellation mid-operation
- Keep Blender's UI responsive during processing
Implementation Pattern
import bpy
class MYCAT_OT_long_process(bpy.types.Operator):
"""Process objects over multiple frames with progress"""
bl_idname = "mycat.long_process"
bl_label = "Long Process"
bl_options = {'REGISTER', 'UNDO'}
_timer = None
_items: list = None
_index: int = 0
@classmethod
def poll(cls, context):
return len(context.selected_objects) > 0
def invoke(self, context, event):
self._items = list(context.selected_objects)
self._index = 0
wm = context.window_manager
wm.progress_begin(0, len(self._items))
self._timer = wm.event_timer_add(0.01, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type in {'RIGHTMOUSE', 'ESC'}:
self._cleanup(context)
self.report({'INFO'}, "Processing cancelled")
return {'CANCELLED'}
if event.type == 'TIMER':
if self._index >= len(self._items):
self._cleanup(context)
self.report({'INFO'}, f"Processed {len(self._items)} items")
return {'FINISHED'}
obj = self._items[self._index]
self._process_item(context, obj)
self._index += 1
context.window_manager.progress_update(self._index)
return {'PASS_THROUGH'}
def _process_item(self, context, obj):
pass
def _cleanup(self, context):
wm = context.window_manager
wm.progress_end()
if self._timer is not None:
wm.event_timer_remove(self._timer)
self._timer = None
def cancel(self, context):
self._cleanup(context)
Rules
- Timer interval is a minimum — actual intervals may be longer due to Blender processing
event_timer_add() requires window as keyword: event_timer_add(0.1, window=context.window)
- Return
{'PASS_THROUGH'} from TIMER events to let keyboard shortcuts work
- Return
{'RUNNING_MODAL'} from TIMER events to block all other input
- ALWAYS create a shared
_cleanup() method called from both FINISHED and CANCELLED paths
Section 2: File Browser Integration
Using ImportHelper / ExportHelper
import bpy
import os
from bpy_extras.io_utils import ImportHelper, ExportHelper
class MYCAT_OT_import_data(bpy.types.Operator, ImportHelper):
"""Import data from file"""
bl_idname = "mycat.import_data"
bl_label = "Import Data"
bl_options = {'REGISTER', 'UNDO'}
filter_glob: bpy.props.StringProperty(
default="*.csv;*.json",
options={'HIDDEN'},
)
skip_header: bpy.props.BoolProperty(name="Skip Header", default=True)
def execute(self, context):
if not os.path.isfile(self.filepath):
self.report({'ERROR'}, f"File not found: {self.filepath}")
return {'CANCELLED'}
self.report({'INFO'}, f"Imported: {self.filepath}")
return {'FINISHED'}
ExportHelper Requirements
class MYCAT_OT_export_data(bpy.types.Operator, ExportHelper):
bl_idname = "mycat.export_data"
bl_label = "Export Data"
filename_ext = ".csv"
filter_glob: bpy.props.StringProperty(default="*.csv", options={'HIDDEN'})
def execute(self, context):
with open(self.filepath, 'w') as f:
f.write("data")
return {'FINISHED'}
Manual File Browser (without mixins)
Use filepath: StringProperty(subtype='FILE_PATH') or directory: StringProperty(subtype='DIR_PATH'), call context.window_manager.fileselect_add(self) in invoke(), return {'RUNNING_MODAL'}.
Menu Registration
Register import operators to bpy.types.TOPBAR_MT_file_import.append(menu_func). Register export operators to bpy.types.TOPBAR_MT_file_export.append(menu_func). ALWAYS remove in unregister() to prevent duplicate entries on addon reload.
Section 3: Undo/Redo Support
bl_options for Undo
| Option | Effect |
|---|
{'REGISTER', 'UNDO'} | One undo step on {'FINISHED'}. ALWAYS use for data-modifying operators. |
{'REGISTER', 'UNDO_GROUPED'} | Consecutive calls of the same operator produce one undo step. Use for repeated rapid calls. |
{'REGISTER'} alone | NO undo step. Use only for read-only / reporting operators. |
Rules
'UNDO' requires 'REGISTER' — without 'REGISTER', 'UNDO' has NO effect
- Returning
{'CANCELLED'} NEVER creates an undo step
- Modal operators with
{'UNDO'} push ONE undo step when {'FINISHED'} is returned — all intermediate modifications are bundled
- If
execute() modifies data then returns {'CANCELLED'}, those changes persist WITHOUT being undoable — ALWAYS restore state before cancelling
UNDO_GROUPED consolidates consecutive calls of the SAME operator into one undo step — use for timer-driven modifications
Cancel-Safe Pattern
def execute(self, context):
obj = context.active_object
original_location = obj.location.copy()
try:
self._do_work(obj)
except Exception as e:
obj.location = original_location
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
return {'FINISHED'}
Section 4: Batch Processing Operators
Synchronous Batch (fast operations)
class MYCAT_OT_batch_rename(bpy.types.Operator):
bl_idname = "mycat.batch_rename"
bl_label = "Batch Rename"
bl_options = {'REGISTER', 'UNDO'}
prefix: bpy.props.StringProperty(name="Prefix", default="obj_")
def execute(self, context):
objects = list(context.selected_objects)
for i, obj in enumerate(objects):
obj.name = f"{self.prefix}{i:03d}"
self.report({'INFO'}, f"Renamed {len(objects)} objects")
return {'FINISHED'}
Batch with Context Override (Blender 4.0+)
def batch_apply_modifiers(objects):
"""Apply all modifiers on multiple objects."""
for obj in objects:
with bpy.context.temp_override(active_object=obj, object=obj):
for mod in obj.modifiers[:]:
try:
bpy.ops.object.modifier_apply(modifier=mod.name)
except RuntimeError as e:
print(f"Cannot apply {mod.name} on {obj.name}: {e}")
Rules
- ALWAYS copy collections before iterating if the loop modifies them:
list(context.selected_objects)
- ALWAYS copy modifier lists before applying:
obj.modifiers[:]
- Use
context.temp_override() when calling operators that check context.active_object
- For batch operations on 100+ objects, use the modal timer pattern (Section 1) for responsiveness
Section 5: Progress Reporting
WindowManager Progress API
wm = context.window_manager
wm.progress_begin(0, total_count)
wm.progress_update(current_index)
wm.progress_end()
Header Text as Progress (alternative)
context.area.header_text_set(f"Processing: {i}/{total} ({i/total*100:.0f}%)")
context.area.header_text_set(None)
UILayout.progress() (Blender 4.0+)
layout.progress(factor=0.66, type='BAR', text="66%")
Known Issue
wm.progress_end() does not immediately reset the cursor — it stays as a busy indicator until the user moves the cursor. This is a confirmed Blender bug, not a coding error.
Section 6: Multi-Step Workflows
Parameter Dialog Before Execution
class MYCAT_OT_configured_action(bpy.types.Operator):
bl_idname = "mycat.configured_action"
bl_label = "Configured Action"
bl_options = {'REGISTER', 'UNDO'}
count: bpy.props.IntProperty(name="Count", default=5, min=1, max=100)
mode: bpy.props.EnumProperty(
name="Mode",
items=[('ADD', "Add", ""), ('REPLACE', "Replace", "")],
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=300)
def check(self, context):
return True
def draw(self, context):
layout = self.layout
layout.prop(self, "mode", expand=True)
layout.prop(self, "count")
def execute(self, context):
return {'FINISHED'}
State Machine Modal Operator
class MYCAT_OT_two_point_tool(bpy.types.Operator):
bl_idname = "mycat.two_point_tool"
bl_label = "Two Point Tool"
bl_options = {'REGISTER', 'UNDO'}
_state: str = 'PICK_FIRST'
_first_point: tuple = None
def invoke(self, context, event):
self._state = 'PICK_FIRST'
self._first_point = None
context.window_manager.modal_handler_add(self)
context.area.header_text_set("Click first point (ESC to cancel)")
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type in {'RIGHTMOUSE', 'ESC'}:
context.area.header_text_set(None)
return {'CANCELLED'}
if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
if self._state == 'PICK_FIRST':
self._first_point = (event.mouse_region_x, event.mouse_region_y)
self._state = 'PICK_SECOND'
context.area.header_text_set("Click second point (ESC to cancel)")
return {'RUNNING_MODAL'}
elif self._state == 'PICK_SECOND':
second = (event.mouse_region_x, event.mouse_region_y)
self._execute_action(context, self._first_point, second)
context.area.header_text_set(None)
return {'FINISHED'}
return {'PASS_THROUGH'}
def _execute_action(self, context, p1, p2):
pass
Invocation Methods Reference
| Method | Dialog | Calls execute() | Use Case |
|---|
invoke_props_dialog(self, width) | Property form + OK | On OK click | Parameter gathering |
invoke_props_popup(self, event) | Property popup | Immediately + on change | Interactive adjustment |
invoke_confirm(self, event) | OK/Cancel only | On OK click | Destructive actions |
invoke_popup(self, width) | Custom draw only | NEVER | Info display |
invoke_search_popup(self) | Searchable enum | On selection | Enum with many items |
Section 7: Deferred Execution with bpy.app.timers
When to Use (instead of modal operators)
- Fire-and-forget deferred work (no UI interaction needed)
- Thread-safe bridge from background threads to main thread
- Persistent recurring tasks that survive file loads
API
bpy.app.timers.register(function, first_interval=0, persistent=False)
bpy.app.timers.unregister(function)
bpy.app.timers.is_registered(function) -> bool
Return Value Semantics
| Return | Effect |
|---|
None | Timer unregistered — function never called again |
float | Function called again after that many seconds |
Thread-Safe Queue Pattern
import bpy
import queue
import threading
_queue = queue.Queue()
def _process_queue():
while not _queue.empty():
fn = _queue.get()
fn()
return 1.0
def run_on_main_thread(fn):
"""Schedule a function to run on Blender's main thread."""
_queue.put(fn)
bpy.app.timers.register(_process_queue, persistent=True)
bpy.app.timers vs wm.event_timer_add
| bpy.app.timers | wm.event_timer_add |
|---|
| Requires modal operator | No | Yes |
| Tied to specific window | No | Yes |
| Survives file load | Yes (persistent=True) | No |
| User-cancellable | No (must unregister) | Yes (ESC in modal) |
| Use case | Background tasks, thread bridge | Interactive modal tools |
Reference Links
Official Sources
Related Skills