It seems like most aspects of Undo support for addon Operators remain troublesome, full of land mines, and generally ‘broken’ enough to stay away from entirely.
I’m attempting to write an Operator, typically invoked after the user is already in Edit mode, that performs several steps and Undo is completely broken for it.
The guidance seems to be to stick to native API calls as much as possible but that is quite the ordeal when there’s about 10 or so core bpy.ops which would have to be reimplemented and maintained etc. Is this still the case?
Additionally, even native API calls directly are problematic. Consider what the Undo stack looks like after attempting 1 Operator call:
My Operator
[several steps of inconsequential actions…]
Toggle Editmode
original
In order for the user to Undo what is 1 logical operation (my operator), they have to hit Ctrl-Z as many times as it takes so they end up before the “magical” edit-model toggle which Blender seems to use as the restore point (erasing all their work in the meantime).
So what are my options?
I plan to explore keeping track of state in my Addon directly and providing a MyUndo operator there which would reverse as much state as possible
Is there a way to override that Toggle Editmode save-point somehow?
There’s a operator called bpy.ops.ed.undo_push() it will create a undo step each time you call it, you can use it to manually controll the undo from your operator. I use it all the time and it generally solves some problems regarding undo/redo.
Hmm, yeah this was suggested to me over on BA as well but the docs say this: Add an undo state (internal use only)
I’ll play around with it a bit to see if it can solve some of the issues I have. Hopefully, it remains “external” enough for addon devs through the 2.8 cycle.
Modifiers added to an object still remain, a newly added collection still remains, and a separated mesh part still remains separated – nothing was undone. I have to ctrl-z to before I entered edit-mode for it to work out.
Things pretty much work if I’m in Object mode first (and the user has done some steps which the addon would automate for them). However, my tooling is kinda geared to be used while you’re in Edit mode already for convenience and workflow speed.
See 1 part below. It’s just the collection aspect of things, there’s a variety of other operations performed. The usage of the collection API directly was suggested by brecht here: Where to find collection_index for moving an object?
Repro steps:
start with default scene
select the Cube and go into Edit mode (stay in edit mode for rest of steps)
make several edits to the Cube (just move some edges around)
Invoke the operator below (notice it does the right thing)
Ctrl-Z (it does not do the right thing; you’ll have to Ctrl-Z past the point where you went to edit mode for things to Undo)
class RB_OT_undo_issue(bpy.types.Operator):
bl_idname = "view3d.rb_undo_issue"
bl_label = "Test test"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Add new collection to the scene
col_test = bpy.data.collections.new("Test")
bpy.context.scene.collection.children.link(col_test)
# Remove default cube from Original and place it into Test...
col_orig = bpy.data.collections["Collection"]
cube = bpy.context.active_object
col_test.objects.link(cube)
col_orig.objects.unlink(cube)
return {"FINISHED"}
Hmm, that’s really weird. I also looked at my prefs and I can repro the issue with Global Undo either On or Off… I thought that might have affected things - darn.
So if you remain in edit mode the entire time, invoking the operator and then undoing just once, it removes the new collection and puts cube back in the original?
If you register the class without {‘REGISTER’, ‘UNDO’} and manually set the undo_push() right before linking as I suggested in the thread on BA, the undo will work.
import bpy
class RB_OT_undo_issue(bpy.types.Operator):
bl_idname = "view3d.rb_undo_issue"
bl_label = "Test test"
# bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# Remove any orphaned instances from previous run
test = bpy.data.collections.get("Test")
if test:
bpy.data.collections.remove(test)
# Add new collection to the scene
col_test = bpy.data.collections.new("Test")
# Manually add undo here
bpy.ops.ed.undo_push()
bpy.context.scene.collection.children.link(col_test)
# Remove default cube from Original and place it into Test...
col_orig = bpy.data.collections["Collection"]
cube = bpy.context.active_object
col_test.objects.link(cube)
col_orig.objects.unlink(cube)
return {"FINISHED"}
def register():
bpy.utils.register_class(RB_OT_undo_issue)
def unregister():
bpy.utils.unregister_class(RB_OT_undo_issue)
if __name__ == '__main__':
register()
Oh, interesting, removing the REGISTER, UNDO tags + adding a strategic undo_push does allow things to work a bit… better, but not completely.
I’m seeing a few immediate issues after those changes in my real operator. There’s the mesh separation problem from the BA thread that would still need addressing, Object mode undo now crashes in certain cases, and (the kicker) for whatever reason, I do not have to special case my operator’s bpy.data.collections.new call like you do above – no idea why. If I ignore all these things, it kinda sorta works :-/
At this point I’m still utterly confused on the proper way to do this. I’m not sure if I’m hitting bugs, unimplemented 2.8 features, or plain 2.7x legacy – or a combination of all of those.
@brecht, @ideasman42 : I hoped to address this without bugging devs, but I think we need your inputs to this. I can provide the complete Operator on request here if needed.
I think there’s probably a bug here, maybe related to the new unified undo stack. So it’s best to report a bug in the tracker.
Normally operators that edit the scene should use bl_options = {'REGISTER', 'UNDO'}, and then all their operations should be bundled as a single undo step.
Is there a way to bundle all operations that occur during the life of a modal operator as a single undo step? (e.g. bundling actions taken while in a custom mode)