Prevent Panel from receiving UNDO hotkey event?

Blender Version 2.81 Stable

I have built a panel that has some ENUMs and Bools stored in a scene properties py file.

Whenever I press one of the operator buttons to create an object in the scene, and then press CTRL+Z to undo (remove the object), it also simultaneously undoes the modifications I made to my panel.

Example:

Any clue why blender behaves this way, and if there’s a way I can prevent the panel from receiving UNDO events?

indeed, it’s an issue I’ve encountered. creating the object then edit/object mode toggle seems to help.

I haven’t encountered this. UI isn’t supposed to be part of the undo system so the problem likely has something to do with your implementation.
Are the upper buttons properties or operators?

They are properties stored in a separate properties py file.

So they trigger the UI change with a callback?
And are there no operators being called between the first button press and the second?

I think I understand a little bit what’s going on, but I haven’t ran any tests yet to confirm.

I found this while searching google today:


Source: https://devtalk.blender.org/t/callback-on-bpy-prop-undo/2411

So, if I am understanding this correctly, any time you UNDO, it reloads the entire scene.

Since, my properties are being stored in the scene as a property group class, then essentially what happens is, my entire scene property group is being “reverted” or “undone” whenever I UNDO.

I guess I will have to rethink the way I have implemented my addon, so that it is less reliant upon the scene properties group…

This is a bummer for me because I have a popup that shows the same layout as the sidebar panel, and uses the same properties, so that when one is changed, the other automatically reflects the changes.

So, I can either live with this UNDO bug (which is unacceptable), or I have to create some method to pass property values between my sidebar panel and my popup.

This happens everywhere in blender.

You can get around it by storing the prop values in a python native object, then add a callback to one of the undo handlers in bpy.app.handlers to sync the value back to the prop.

I see. Interesting.

Is there an easy to follow tutorial for this? I’m checking the documentation here: https://docs.blender.org/api/current/bpy.app.handlers.html

and I see that I can take advantage of bpy.app.handlers.undo_post and bpy.app.handlers.undo_pre

I really have no idea what I’m doing here, any help would be greatly appreciated. :slight_smile:

When defining props, you can specify a function to be called when the prop value is changed:

# The function must be defined before (above) the prop
def prop_store(self, context):
    restore_prop.val = self.some_prop

class MyClass(bpy.types.Something):
    some_prop: bpy.props.BoolProperty(name="Prop", update=prop_store)

The function prop_store must take two arguments, self and context, where self points to the instance the prop was defined in. When triggered, the function stores the value of some_prop on an object called restore_prop, which will make sense below.
NOTE: that update function must be defined before the class and prop.

Adding/removing callbacks is easy - like appending to a python list:


def restore_prop(scene):
    val = getattr(restore_prop, "val", None)

    # Only change when the value differs to prevent
    # unnecessary rna updates whenever undo is called
    if val is not None and scene.some_prop != val:
        scene.some_prop = val

bpy.app.handlers.undo_post.append(restore_prop)

I’ve defined a callback function restore_prop. All callback functions need a scene parameter. Since prop_store (prop callback) stores the value on the restore_prop function itself, we can use getattr with a fallback in case it hasn’t been stored yet, eg. when the user hasn’t changed anything yet.

Callbacks are registered by appending to the appropriate handler.
_pre handlers would obviously trigger before an undo, so use _post.

To remove a callback, eg. for unregistering:

bpy.app.handlers.undo_post.remove(restore_prop)
2 Likes

Thank you for the explanation! :slight_smile: I will try to get this working tomorrow when I have a bit more time.

I’m just now getting a chance to play with this, and I seem to be running into a snag right off the bat.

def prop_store(self, context):
	restore_prop.val = self.primary_location_method #enum
	print(restore_prop)

This produces an error:

NameError: name 'restore_prop' is not defined

So, I tried this:

def prop_store(self, context):
	restore_prop = str()
	restore_prop.val = self.primary_location_method
	print(restore_prop)

And I get this error:

AttributeError: 'str' object has no attribute 'val'

However, this works:

def prop_store(self, context):
	restore_prop = str(self.primary_location_method)
	print(restore_prop)

result:

WORLD_ORIGIN

What should I do?

restore_prop is the undo callback function I wrote above.
Either you haven’t added it yet, or it’s not placed in module level:

def restore_prop(scene):
    val = getattr(restore_prop, "val", None)

    if val is not None and scene.some_prop != val:
        scene.some_prop = val

Ah, it’s beginning to make sense.

I believe I got it working!

I have a file called misc_functions.py where I stored the restore_prop function:

def restore_prop(scene):
	val = getattr(restore_prop, "val", None)

	# Only change when the value differs to prevent
	# unnecessary rna updates whenever undo is called
	if val is not None and scene.neltulzSmartObject.primary_location_method != val:
		scene.neltulzSmartObject.primary_location_method = val

Then, in my __init__.py file, I had to import it like this:

#misc_functions
from . import misc_functions

inside def register(): I added

bpy.app.handlers.undo_post.append(misc_functions.restore_prop)

inside def unregister(): I added:

bpy.app.handlers.undo_post.remove(misc_functions.restore_prop)

In my properties.py file, above my property group class, I added:

def prop_store(self, context):
	misc_functions.restore_prop.val = self.primary_location_method

Inside my class NeltulzSmartObject_IgnitProperties(PropertyGroup):, I added the update:

	primary_location_method_list = [
		("SMART", 		"Smart", 		"Automatically create object based on context", 	"FAKE_USER_ON", 	0),
		("WORLD_ORIGIN", 	"World Origin",	 	"Create object at World Origin", 			"WORLD", 		1),
		("CURSOR", 		"3D Cursor", 		"Create object at 3D Cursor Location",			"CURSOR", 		2),
		("VIEWPORT", 		"Viewport", 		"Create object at Viewport Location",			"RESTRICT_VIEW_ON",	3),
		("CUSTOM", 		"Custom", 		"Create object at a custom location",			"TOOL_SETTINGS",	4),
	]

	primary_location_method : EnumProperty(
		items=primary_location_method_list,
		description="Which to use (Default: Smart)",
		default="SMART",
		update=prop_store,
	)

Verified, working.

Amazing. I can’t believe this works!

Thank you so much for your help with this! :smiley:

Also, it appears I had to add a handler on the redo_post as well, because whenever I would go to adjust an operator using the popup in the lower left corner of the 3d viewport, it would sometimes revert my scene properties…

Does that seem normal?

Okay, so… be warned. I am sure this is written very poorly. I realize, it’s bad practice to use “eval” and “exec”, but I am unsure of how to achieve this without resorting to that.

Here’s what I have after hours of toiling:

Noteworthy things:

  • When updating a scene property, it checks to see if the undo/redo handlers exist. If they do not exist, they get added. This was necessary because, I noticed the undo handlers were not being added when blender is restarted… so, to make 100% absolutely sure the undo handlers are added, I am checking for their existence every time a property is updated.

  • Ability to restore multiple scene properties from a single function

  • If there is a better way to achieve this, (one that doesn’t require me to make separate functions per property) I am all ears. :slight_smile:

def load_handler():
    bpy.app.handlers.undo_post.append(ntz_smrt_obj_restore_props)
    bpy.app.handlers.redo_post.append(ntz_smrt_obj_restore_props)

def ntz_smrt_obj_restore_props(scene):
    
    propertyList = getattr(ntz_smrt_obj_restore_props, "val", None)

    
    # Only change when the value differs to prevent
    # unnecessary rna updates whenever undo is called
    
    if propertyList is not None:

        for propertyName, value in propertyList.items():

            current_sceneProperty = 'scene.neltulzSmartObject.' + propertyName #scene property name as a string
            current_sceneProperty_eval = eval(current_sceneProperty) #scene property

            propertyType = str( type(current_sceneProperty_eval) ) #type of property (e.g. string, vector, euler, etc)

            detectedValueChange = False #declare

            if propertyType == "<class 'str'>":
                if current_sceneProperty_eval != str(value):
                    detectedValueChange = True
                    value = '"' + str(value) + '"' #wrap value in quotes

            elif propertyType == "<class 'Vector'>":
                if current_sceneProperty_eval != value[:]:
                    detectedValueChange = True
                    value = 'mathutils.Vector( ' + str(value[:]) + ' )' #wrap value in Vector

            elif propertyType == "<class 'Euler'>":
                if current_sceneProperty_eval != value[:]:
                    detectedValueChange = True
                    value = 'mathutils.Euler( ' + str(value[:]) + ' )' #wrap value in Euler

            else:
                if current_sceneProperty_eval != str(value):
                    detectedValueChange = True
                    #no need to modify value


            if detectedValueChange:
                commandToExec = current_sceneProperty + ' = ' + str(value) #example: scene.primary_location_method = "SMART"
                exec( commandToExec ) #finally, restore scene property!
    

def prop_store(self, context):

    #Check to see if the undo handler is present.  If not, load the undo and redo handler so that scene properties can be restored on undo/redo events
    undo_handler_found = False #declare

    for handler in bpy.app.handlers.undo_post:
        if handler.__name__ == "ntz_smrt_obj_restore_props":
            undo_handler_found = True
            break

    if not undo_handler_found:
        load_handler() #load undo & redo handlers

    #list of properties to restore
    ntz_smrt_obj_restore_props.val = {

        #scene property name                                        #value
        'primary_location_method':              str(                self.primary_location_method        ),
        'show_options':                         bool(               self.show_options                   ),
        'fallback_location_method':             str(                self.fallback_location_method       ),
        'custom_objectLocation':                mathutils.Vector(   self.custom_objectLocation[:]       ),
        'rotation_method':                      str(                self.rotation_method                ),
        'custom_objectRotation':                mathutils.Euler(    self.custom_objectRotation[:]       ),
        'calc_center_method':                   str(                self.calc_center_method             ),
        'separate':                             str(                self.separate                       ),
        'enter_edit_mode':                      str(                self.enter_edit_mode                ),
        'reselect_objects':                     bool(               self.reselect_objects               ),
    }

You shouldn’t have to check if handlers exist every time a prop is changed. Add handlers once during register() then use a decorator @bpy.app.handlers.persistent on the callback function to make it persistent, eg. between files, and as long as blender is open.

Simplified the script above to make it more programmatic. Should be easier to maintain when you decide to add more properties.

def load_handler():
    bpy.app.handlers.undo_post.append(ntz_smrt_obj_restore_props)
    bpy.app.handlers.redo_post.append(ntz_smrt_obj_restore_props)

# Make callback persistent across blender files
@bpy.app.handlers.persistent
def ntz_smrt_obj_restore_props(scene):
    
    propertyList = getattr(ntz_smrt_obj_restore_props, "val", None)
    
    # Only change when the value differs to prevent
    # unnecessary rna updates whenever undo is called
    if propertyList is not None:

        path = scene.neltulzSmartObject
        for propertyName, value in propertyList.items():

            if getattr(path, propertyName) != propertyList[propertyName]:
                setattr(path, propertyName, propertyList[propertyName])
    

def prop_store(self, context):

    # Try getting the dict from ntz_smrt_obj_restore_props. If none, create it
    props_dict = vars(ntz_smrt_obj_restore_props).setdefault('val', {})

    # Get all properties from 'self' and update the dict
    props_dict.update({key: getattr(self, key) for key in self.__annotations__})

Hi, I finally got around to testing this.

So, I guess, it’s not really working as expected… which is odd.

  1. It seems to be storing a massive number of scene properties in the propertyList. Shouldn’t it only be storing the property that has update=misc_functions.prop_store? Perhaps there is a typo somewhere in the code, or it’s missing something. I’m not able to spot the problem.

  2. Certain properties are being restored, while others are not. For example, I have a bool called reselect_objects, that one is being restored correctly every time. However, I have many enum properties… none of those are being restored at all. Does the setattr() work on enum properties?

  3. I added the @bpy.app.handlers.persistent below the def register(). The undo & redo handlers are being loaded on register, which means, whenever the user enables the add-on, they get loaded. So far so good. However, whenever the user exits blender, then reopens blender, the undo/redo handlers don’t get loaded… which is a problem. I need the undo/redo handlers to load any time blender is opened… not just whenever the user loads a different blend file. Hopefully you understand. Perhaps I am not adding the @bpy.app.handlers.persistent in the right place, or perhaps I need something entirely different to achieve what I need…

I appreciate all of your help, and hopefully this is an easy fix! :slight_smile:

Would you mind sharing your current code so it can be tested?
I’d love to help, though it makes things a bit difficult without seeing the whole context.

Sure. Do you use Discord or something similar? Might be easier to communicate via direct message.

I do! I’ll send by PM :slight_smile: