2026-01-01

This commit is contained in:
2026-03-17 15:16:34 -06:00
parent ec4cf523fb
commit b80274187b
263 changed files with 95164 additions and 3848 deletions
@@ -6,6 +6,8 @@ import bpy
from bpy.utils import register_class, unregister_class
import importlib
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = locals().get("_VPM_AGENT_IMMEDIATE_REGISTER_DONE", False)
module_names = (
"op_pie_wrappers",
"op_copy_to_selected",
@@ -57,10 +59,8 @@ def register_unregister_modules(modules: list, register: bool):
for c in m.registry:
try:
register_func(c)
except Exception as e:
print(
f"Warning: Pie Menus failed to {un}register class: {c.__name__}"
)
except (AttributeError, RuntimeError, TypeError, ValueError) as e:
print(f"Warning: Pie Menus failed to {un}register class: {c.__name__}")
print(e)
if hasattr(m, 'modules'):
@@ -78,8 +78,27 @@ def delayed_register(_scene=None):
register_unregister_modules(modules, True)
def register():
# NOTE: persistent=True must be set, otherwise this doesn't work when opening a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
"""
We prefer an *immediate* register during startup, because other add-ons may touch
keyconfig initialization very early, and Blender's keymap diff application appears
sensitive to timing.
If immediate registration fails (e.g. missing WM in edge cases), fall back to the
legacy timer-based delayed registration.
"""
global _VPM_AGENT_IMMEDIATE_REGISTER_DONE
if not _VPM_AGENT_IMMEDIATE_REGISTER_DONE:
try:
register_unregister_modules(modules, True)
_VPM_AGENT_IMMEDIATE_REGISTER_DONE = True
return
except Exception as e:
# Keep behavior unchanged (fallback to timer), but avoid raising during registration.
pass
# NOTE: persistent=True must be set, otherwise this doesn't work when opening
# a .blend file directly from a file browser.
bpy.app.timers.register(delayed_register, first_interval=0.0, persistent=True)
def unregister():
register_unregister_modules(reversed(modules), False)
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "viewport_pie_menus"
name = "3D Viewport Pie Menus"
version = "1.7.1"
version = "1.7.3"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -4,11 +4,17 @@
# This file requires (and is made possible by) Blender 5.0 due to the find_match() API call.
import hashlib
from typing import Callable, Any
import bpy
import json
from bpy.types import KeyMap, KeyMapItem, UILayout
if "ADDON_KEYMAPS" not in locals():
ADDON_KEYMAPS = []
# Preserve across Reload Scripts (module reload) when possible, but also keep
# names defined for static analyzers.
ADDON_KEYMAPS = locals().get("ADDON_KEYMAPS", [])
KMI_HASHES = locals().get("KMI_HASHES", {})
KEYMAP_ICONS = {
'Object Mode': 'OBJECT_DATAMODE',
@@ -29,20 +35,30 @@ KEYMAP_UI_NAMES = {
}
KMI_DEFAULTS = {
prop: KeyMapItem.bl_rna.properties[prop].default
for prop in KeyMapItem.bl_rna.properties.keys()
prop.identifier: prop.default
for prop in KeyMapItem.bl_rna.properties
if hasattr(prop, 'default')
}
def register_hotkey(
bl_idname,
*,
op_kwargs={},
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
bl_idname,
*,
op_kwargs=None,
hotkey_kwargs=None,
keymap_name='Window'
):
global ADDON_KEYMAPS
wm = bpy.context.window_manager
if op_kwargs is None:
op_kwargs = {}
if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context
wm = context.window_manager
kmi_hash = any_to_hash(op_kwargs, hotkey_kwargs, keymap_name)
if kmi_hash in KMI_HASHES:
# Avoid re-registering on Reload Scripts.
return
space_type = wm.keyconfigs.default.keymaps[keymap_name].space_type
@@ -63,26 +79,27 @@ def register_hotkey(
# it is SUPPOSED TO stick around for ever.
# This allows Blender to store the associated user keymap, meaning the user's modifications
# will be stored and restored as expected, whenever the add-on is enabled again.
# if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
# ADDON_KEYMAPS.append((addon_km, existing_kmi))
if (addon_km, existing_kmi) not in ADDON_KEYMAPS:
ADDON_KEYMAPS.append((addon_km, existing_kmi))
return
addon_kmi = addon_km.keymap_items.new(bl_idname, **hotkey_kwargs)
for key in op_kwargs:
value = op_kwargs[key]
setattr(addon_kmi.properties, key, value)
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
ADDON_KEYMAPS.append((addon_km, addon_kmi))
def draw_hotkey_list(
context,
layout,
*,
compact=False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: callable=None,
):
context,
layout,
*,
compact=False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: Callable = None,
):
"""Draw the list of hotkeys registered by this add-on.
Will find the corresponding User KeyMapItems, which are safe to modify.
Supports two sorting modes:
@@ -99,7 +116,7 @@ def draw_hotkey_list(
if sort_mode == 'BY_OPERATOR':
layout = layout.column(align=True)
if compact == None:
if compact is None:
sidebar = get_sidebar(context)
if sidebar:
compact = sidebar.width < 600
@@ -130,41 +147,57 @@ def draw_hotkey_list(
if prev_kmi_name != kmi_name:
layout.separator()
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
draw_kmi(
user_km,
user_kmi,
layout,
compact=compact,
button_draw_func=button_draw_func,
debug=debug
)
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
ret = []
assert bpy.app.version >= (5, 0, 0), "This function requires Blender 5.0 or later."
context.window_manager.keyconfigs.update()
if do_update:
context.window_manager.keyconfigs.update()
for addon_km, addon_kmi in ADDON_KEYMAPS:
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
print("Failed to find User KeyMap: ", addon_km.name)
continue
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
if not user_kmi:
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
continue
ret.append((user_km, user_kmi))
user_km, user_kmi = get_user_kmi_of_addon(context, addon_km, addon_kmi)
if user_kmi:
ret.append((user_km, user_kmi))
return ret
def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
print("Failed to find User KeyMap: ", addon_km.name)
return None, None
user_kmi = user_km.keymap_items.find_match(addon_km, addon_kmi)
if not user_kmi:
# This shouldn't really happen, but maybe it can, eg. if user changes idname.
print("Failed to find User KeyMapItem: ", addon_km.name, addon_kmi.idname)
return None, None
return user_km, user_kmi
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
km_name = km.name
km_name: str = km.name
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
if kmi.properties and 'name' in kmi.properties:
name = kmi.properties.name
if name:
try:
if hasattr(bpy.types, kmi.properties.name):
bpy_type = getattr(bpy.types, kmi.properties.name)
kmi_name = bpy_type.bl_label
except:
else:
kmi_name = "Missing (code 1). Try restarting."
else:
kmi_name = "Missing (code 2). Try restarting."
@@ -174,7 +207,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
bpy_type = getattr(bpy.ops, parts[0])
bpy_type = getattr(bpy_type, parts[1])
kmi_name = bpy_type.get_rna_type().name
except:
except (AttributeError, IndexError, TypeError):
kmi_name = "Missing (code 3). Try restarting."
return km_icon, km_name, kmi_name
@@ -194,30 +227,49 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
if value != getattr(kmi, key):
return False
want_to_crash = False
if want_to_crash:
# These checks cause https://projects.blender.org/Mets/CloudRig/issues/201
# They don't seem necessary.
if kmi.properties == None:
# IMPORTANT:
# `wm.keyconfigs.addon` is shared by *all* add-ons. If we only match on idname+hotkey,
# we may incorrectly treat another add-on's KeyMapItem as ours and skip registering,
# which prevents Blender from applying the user's stored overrides on next startup
# (manifesting as "prefs/hotkeys reset", eg. when used together with Pie Menu Editor).
#
# We therefore include operator properties in the match, but do it defensively:
# - only compare simple scalar types (str/int/float/bool/None)
# - if anything unexpected is encountered, treat it as a mismatch rather than risking
# false positives or Blender RNA edge-case crashes.
if op_kwargs:
try:
if kmi.properties is None:
return False
for key, expected in op_kwargs.items():
if key not in kmi.properties:
return False
actual = getattr(kmi.properties, key, None)
# Compare only stable scalar values.
scalar_types = (str, int, float, bool, type(None))
if isinstance(expected, scalar_types) and isinstance(actual, scalar_types):
if actual != expected:
return False
else:
# Unknown/complex value: do not assume a match.
return False
except (AttributeError, KeyError, TypeError, RuntimeError):
# Be conservative: if Blender throws, don't match.
return False
for key, value in op_kwargs.items():
if key not in kmi.properties:
return False
if value != kmi.properties[key]:
return False
return True
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
def draw_kmi(
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: callable=None,
debug=False,
):
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: Callable = None,
debug=False,
):
"""Draw a KeyMapItem in the provided UI.
This function is designed specifically to be used in an add-on's preferences:
- It does not allow removing the KeyMapItem, since add-on KMIs should never be removed.
@@ -249,14 +301,14 @@ def draw_kmi(
sub.prop(kmi, "type", text="", full_event=True)
if kmi.is_user_modified:
row2.context_pointer_set("keymap", km) # NOTE: Yes, this actually matters.
row2.operator(
"preferences.keyitem_restore", text="", icon='BACK'
).item_id = kmi.id
# Make `context.keymap` available in the drawing code (in this case blender's native code)
row2.context_pointer_set("keymap", km)
row2.operator("preferences.keyitem_restore", text="", icon='BACK').item_id = kmi.id
if debug and kmi.show_expanded:
layout.template_keymap_item_properties(kmi)
def get_sidebar(context):
if not context.area.type == 'VIEW_3D':
return None
@@ -264,16 +316,17 @@ def get_sidebar(context):
if region.type == 'UI':
return region
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap or None, KeyMapItem or None]:
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
target_km = find_matching_keymap(context, target_kc, src_km)
if not target_km:
raise Exception(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMapItem, that's easy, because that's what the API was meant for.
if target_kc == kc_user:
return target_km, target_km.keymap_items.find_match(src_km, src_kmi)
user_km, user_kmi = src_km, src_kmi
user_km = src_km
# If we want to find any other type of KeyMapItem, we have to do it indirectly, since we can only directly check for matches in the User KeyConfig.
# So eg. if we want to find an Addon KeyMapItem based on a User KeyMapItem, we have to loop over all Addon KeyMapItems, and find which one matches with the given User KeyMapItem.
for target_kmi in target_km.keymap_items:
@@ -283,14 +336,14 @@ def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMa
return target_km, target_kmi
except RuntimeError:
print("Failed to find matching KeyMapItem for: ", target_km.name, target_kmi.to_string())
# raise Exception(f"Failed to find KeyMapItem '{src_kmi.idname}' ({src_kmi.to_string()}) in KeyConfig '{target_kc.name}', KeyMap '{target_km.name}'")
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
return None, None
def find_matching_keymap(context, target_kc, src_km):
"""Find the equivalent keymap in another keyconfig."""
kc_user = context.window_manager.keyconfigs.user
# If we want to find a matching User KeyMap, that's easy, because that's what the API was meant for.
@@ -306,6 +359,7 @@ def find_matching_keymap(context, target_kc, src_km):
if match == src_km:
return km
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
bl_idname = "window.restore_deleted_hotkeys"
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
@@ -318,6 +372,7 @@ class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
self.report({'INFO'}, f"Restored {num_restored} deleted keymaps.")
return {'FINISHED'}
def restore_deleted_keymap_items_global(context) -> int:
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
@@ -339,6 +394,7 @@ def restore_deleted_keymap_items_global(context) -> int:
total_restored += num_restored
return total_restored
def restore_deleted_keymap_items(context, user_km_name) -> int:
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
@@ -348,7 +404,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
user_km = user_kc.keymaps[user_km_name]
# Step 1: Store modified and added KeyMapItems in a temp keymap.
temp_km_name = "temp_"+user_km_name
temp_km_name = "temp_" + user_km_name
temp_km = user_kc.keymaps.new(temp_km_name)
kmis_user_modified = []
kmis_user_defined = []
@@ -359,8 +415,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
continue
if user_kmi.is_user_modified:
temp_kmi = temp_km.keymap_items.new_from_item(user_kmi)
# Find the original keymap in either the Blender default or Addon KeyConfigs.
# Not sure if this works with presets like Industry Compatible keymap,
# Find the original keymap in either the Blender default or Addon KeyConfigs.
# Not sure if this works with presets like Industry Compatible keymap,
# but I assume they change the contents of the "default" keyconfig, so it would work.
default_km, default_kmi = find_matching_km_and_kmi(context, default_kc, user_km, user_kmi)
if not default_kmi:
@@ -370,7 +426,7 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
# Step 2: Restore User KeyMap to default.
num_kmis = len(user_km.keymap_items)
user_km.restore_to_default()
# XXX: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
# NOTE: restore_to_default() will shuffle the memory addresses, so we need to re-reference user_km.
# I don't think this was the case pre-Blender 5.0!!
user_km = user_kc.keymaps[user_km_name]
temp_km = user_kc.keymaps[temp_km_name]
@@ -381,7 +437,8 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier', 'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier',
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
setattr(user_kmi, key, getattr(temp_kmi, key))
if temp_kmi.properties:
for key in temp_kmi.properties.keys():
@@ -401,4 +458,23 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
return len(user_km.keymap_items) - num_kmis
def any_to_hash(*args) -> str:
"""Hash whatever."""
def stable(obj: Any):
# Make hashing deterministic across runs and independent of dict insertion order.
# Keep it conservative to avoid surprises with Blender RNA objects.
if isinstance(obj, dict):
return {str(k): stable(v) for k, v in sorted(obj.items(), key=lambda kv: str(kv[0]))}
if isinstance(obj, (list, tuple)):
return [stable(v) for v in obj]
return obj
try:
stringified = json.dumps([stable(a) for a in args], sort_keys=True, separators=(",", ":"), default=str)
except (TypeError, ValueError):
# Fallback: last resort stringification
stringified = ";".join([str(arg) for arg in args])
return hashlib.sha256(stringified.encode("utf-8")).hexdigest()
registry = [WINDOW_OT_restore_deleted_hotkeys]
@@ -1,12 +1,16 @@
import json
import os
from pathlib import Path
import bpy, json, os
from bpy.types import PropertyGroup
import bpy
from bpy.types import AddonPreferences, PropertyGroup
from rna_prop_ui import IDPropertyGroup
from bpy.types import AddonPreferences
from .. import __package__ as base_package
assert base_package
class PrefsFileSaveLoadMixin:
"""Mix-in class that can be used by any add-on to store their preferences in a file,
so that they don't get lost when the add-on is disabled.
@@ -48,6 +52,7 @@ class PrefsFileSaveLoadMixin:
return
if prefs:
prefs.load_and_apply_prefs_from_file()
bpy.app.timers.register(timer_func, first_interval=delay)
def apply_prefs_from_dict_recursive(self, propgroup: PropertyGroup, data: dict):
@@ -55,14 +60,14 @@ class PrefsFileSaveLoadMixin:
if not hasattr(propgroup, key):
# Property got removed or renamed in the implementation.
continue
if type(value) == list:
if type(value) is list:
for elem in value:
collprop = getattr(propgroup, key)
entry = collprop.get(elem['name'])
entry = collprop.get(elem["name"])
if not entry:
entry = collprop.add()
self.apply_prefs_from_dict_recursive(entry, elem)
elif type(value) == dict:
elif type(value) is dict:
self.apply_prefs_from_dict_recursive(getattr(propgroup, key), value)
else:
setattr(propgroup, key, value)
@@ -125,15 +130,17 @@ def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
ret = {}
for key in propgroup.bl_rna.properties.keys():
if key in skip or key in ['rna_type', 'bl_idname']:
if key in skip or key in ["rna_type", "bl_idname"]:
continue
value = getattr(propgroup, key)
if isinstance(value, bpy.types.bpy_prop_collection):
ret[key] = [props_to_dict_recursive(elem) for elem in value]
elif type(value) == IDPropertyGroup or isinstance(value, bpy.types.PropertyGroup):
elif isinstance(value, IDPropertyGroup) or isinstance(
value, bpy.types.PropertyGroup
):
ret[key] = props_to_dict_recursive(value)
else:
if hasattr(propgroup.bl_rna.properties[key], 'enum_items'):
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
# Save enum values as string, not int.
ret[key] = propgroup.bl_rna.properties[key].enum_items[value].identifier
else:
@@ -146,7 +153,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
context = bpy.context
addons = context.preferences.addons
if base_package.startswith('bl_ext'):
if base_package.startswith("bl_ext"):
# 4.2 and later
addon_key = base_package
else:
@@ -154,7 +161,7 @@ def get_addon_prefs(context=None) -> AddonPreferences | None:
addon_key = base_package.split(".")[0]
addon = addons.get(addon_key)
if addon == None:
if addon is None:
# print("This happens when packaging the extension, due to the registration delay.")
return
@@ -2,54 +2,69 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any
import bpy
from bpy.types import PropertyGroup, bpy_prop_collection, Object
from rna_prop_ui import IDPropertyGroup
from bpy.types import Object, PropertyGroup, bpy_prop_collection
from bpy.utils import flip_name
from mathutils import Matrix, Vector
from rna_prop_ui import IDPropertyGroup
# Functions to manage runtime properties, which include custom properties and add-on properties.
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
# a PropertyGroup, or a CollectionProperty.
# Compatible with the API changes in 5.0, but also older versions.
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
"""Copy add-on and custom properties from source to target.
"""Copy add-on and custom properties from source to target.
Both should be the same type.
Should support anything that supports custom properties or property registration.
"""
for prop_name in get_all_runtime_prop_names(src_id):
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
def copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
for prop_name in get_custom_prop_names(src_id):
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=x_mirror)
def get_all_runtime_prop_names(owner):
custom_props = list(owner.keys())
addon_props = get_addon_prop_names(owner)
props = custom_props + addon_props
return props
def get_custom_prop_names(owner):
for prop_name in get_all_runtime_prop_names(owner):
if is_custom_prop(owner, prop_name):
yield prop_name
def get_addon_prop_names(owner):
if bpy.app.version >= (5, 0, 0):
sys_props = owner.bl_system_properties_get()
if sys_props == None:
if sys_props is None:
# If there aren't any add-on properties.
return []
return list(sys_props.keys())
else:
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
return [
prop_name for prop_name in owner.keys()
if is_addon_prop(owner, prop_name)
]
def rename_custom_prop(owner, from_name, to_name):
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
assert is_custom_prop(owner, from_name), (
f"Property {from_name} of {owner} is not a Custom Property."
)
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
remove_property(owner, from_name)
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
"""Copy add-on properties or custom properties."""
if is_addon_prop(src_id, prop_name):
@@ -64,7 +79,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
else:
if bpy.app.version >= (5, 0, 0):
# HACK: If we need to copy add-on properties, but the add-on is not present,
# HACK: If we need to copy add-on properties, but the add-on is not present,
# we have to write to the system properties, which is API abuse that could
# lose support any moment, but there is no other way to do this atm.
tgt_props = tgt_id.bl_system_properties_get()
@@ -81,6 +96,7 @@ def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
else:
copy_custom_property(src_id, tgt_id, prop_name)
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
"""
Copy the values from one PropertyGroup into another of the same type.
@@ -90,7 +106,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
assert tgt_pg.__class__ == src_pg.__class__
for prop_name in src_pg.bl_rna.properties.keys():
if prop_name in ('rna_type', 'bl_rna'):
if prop_name in ("rna_type", "bl_rna"):
continue
if not src_pg.is_property_set(prop_name):
tgt_pg.property_unset(prop_name)
@@ -108,6 +124,7 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
# PropertyGroups also support custom properties.
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_cp.clear()
for src_pg in src_cp:
@@ -115,6 +132,7 @@ def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_pg = tgt_cp.add()
copy_property_group(src_pg, tgt_pg, x_mirror)
def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=False):
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
if not new_name:
@@ -133,10 +151,13 @@ def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=
tgt_owner[new_name] = value
new_prop = tgt_owner.id_properties_ui(new_name)
new_prop.update_from(src_prop)
tgt_owner.property_overridable_library_set(f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]'))
tgt_owner.property_overridable_library_set(
f'["{new_name}"]', src_owner.is_property_overridable_library(f'["{prop_name}"]')
)
return tgt_owner.id_properties_ui(new_name)
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
if src.is_property_readonly(prop_name):
# This "early" exit has to come after CollectionProperty & PropertyGroup
# checks, since they are technically read-only.
@@ -145,10 +166,11 @@ def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
value = getattr(src, prop_name)
if x_mirror:
value = x_mirror_value(value)
setattr(tgt, prop_name, value)
return True
def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
@@ -157,29 +179,38 @@ def x_mirror_value(value):
else:
return value
def get_opposite_obj(obj: Object) -> Object:
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
flipped_name = flip_name(obj.name)
lib = obj.library
return (
bpy.data.objects.get((lib, flipped_name)) if lib else
bpy.data.objects.get(flipped_name)
bpy.data.objects.get((lib, flipped_name))
if lib
else bpy.data.objects.get(flipped_name)
) or obj
def is_addon_prop(owner, prop_name):
if bpy.app.version >= (5, 0, 0):
return prop_name in get_addon_prop_names(owner)
else:
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
# They just behave completely as custom properties.
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
return prop_name in owner and (
isinstance(owner[prop_name], IDPropertyGroup)
or isinstance(owner[prop_name], list)
)
def is_registered_addon_prop(owner, prop_name):
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
def is_custom_prop(owner, prop_name):
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
def remove_property(obj, prop_name):
if is_custom_prop(obj, prop_name):
del obj[prop_name]
@@ -190,3 +221,33 @@ def remove_property(obj, prop_name):
del disabled_addon_props[prop_name]
else:
raise KeyError(f"{prop_name} not found in {obj.name}")
def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str, Any]:
def get_default(prop):
if not hasattr(prop, "default"):
return None
if hasattr(prop, "default"):
if hasattr(prop, "default_array"):
default_array = list(prop.default_array)
if default_array:
if len(default_array) == 9:
return Matrix.Identity(3)
elif len(default_array) == 16:
return Matrix.Identity(4)
elif len(default_array) == 3 and prop.type == 'FLOAT':
default = default_array[0]
return Vector((default, default, default))
else:
return default_array
return prop.default
assert False, f"Couldn't find default for {prop}"
return {
prop.identifier: get_default(prop)
for prop in bpy_type.bl_rna.properties
if not prop.is_readonly and prop.identifier not in exclude
}
@@ -4,7 +4,8 @@
from bpy.types import UILayout
def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
def aligned_label(layout: UILayout, *, alert=False, alignment="LEFT", **kwargs):
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
row = layout.split(factor=0.4)
row.separator()
@@ -12,11 +13,12 @@ def aligned_label(layout: UILayout, *, alert=False, alignment='LEFT', **kwargs):
row.alignment = alignment
row.label(**kwargs)
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
"""Return an empty UILayout with a text label to its left in the single-column-layout style."""
split = layout.split(factor=0.4, align=True)
split.alert = alert
row = split.row(align=True)
row.alignment = 'RIGHT'
row.alignment = "RIGHT"
row.label(**kwargs)
return split
return split
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import os
from bpy.types import Operator
from bpy.props import StringProperty, BoolProperty
import json
@@ -68,7 +69,14 @@ class WM_OT_call_menu_pie_drag_only(Operator):
if op_cls.poll():
try:
return op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
# 1. Execute the original operator and capture the result
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
# 2. [Added] Check if it's a Save operation and report the message manually
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
# Get current filename, or default to untitled
filename = os.path.basename(bpy.data.filepath) if bpy.data.filepath else "untitled.blend"
self.report({'INFO'}, f'Saved "{filename}"')
except TypeError:
# This can apparently happen sometimes, see issue #86.
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
@@ -96,19 +104,30 @@ class WM_OT_call_menu_pie_drag_only(Operator):
*,
keymap_name: str,
pie_name: str,
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
hotkey_kwargs=None,
default_fallback_op="",
default_fallback_kwargs={},
default_fallback_kwargs=None,
on_drag=True,
):
if hotkey_kwargs is None:
hotkey_kwargs = {'type': "SPACE", 'value': "PRESS"}
context = bpy.context
fallback_operator = default_fallback_op
fallback_op_kwargs = default_fallback_kwargs
user_kc = context.window_manager.keyconfigs.user
km = user_kc.keymaps.get(keymap_name)
fallback_op_kwargs = default_fallback_kwargs if default_fallback_kwargs is not None else {}
# IMPORTANT:
# Do NOT derive fallback operator/kwargs from the USER keyconfig.
# Other add-ons (eg. Pie Menu Editor) can legitimately alter user keymaps,
# and baking that dynamic state into our add-on KeyMapItem properties can
# make Blender fail to match/restore user overrides across restarts.
#
# Instead, derive fallback from the DEFAULT (active preset) keyconfig,
# which is stable and represents expected built-in behavior.
default_kc = context.window_manager.keyconfigs.default
km = default_kc.keymaps.get(keymap_name)
if km:
for kmi in km.keymap_items:
for i, condition in enumerate([
for condition in [
kmi.idname != 'wm.call_menu_pie_drag_only',
kmi.type == hotkey_kwargs.get('type', ""),
kmi.value == hotkey_kwargs.get('value', "PRESS"),
@@ -119,7 +138,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
kmi.any == hotkey_kwargs.get('any', False),
kmi.key_modifier == hotkey_kwargs.get('key_modifier', 'NONE'),
kmi.active
]):
]:
if not condition:
break
else:
@@ -133,14 +152,19 @@ class WM_OT_call_menu_pie_drag_only(Operator):
op_kwargs={
'name': pie_name,
'fallback_operator': fallback_operator,
'fallback_op_kwargs': json.dumps(fallback_op_kwargs),
# Deterministic JSON (sort_keys=True) helps keep KMI identity stable across sessions.
'fallback_op_kwargs': json.dumps(fallback_op_kwargs, sort_keys=True),
'on_drag': on_drag,
},
hotkey_kwargs=hotkey_kwargs,
keymap_name=keymap_name,
)
def register():
bpy.utils.register_class(WM_OT_call_menu_pie_drag_only)
registry = [
WM_OT_call_menu_pie_drag_only,
]
def unregister():
# HACK: As a workaround to https://projects.blender.org/blender/blender/issues/150229, we do not unregister
# this operator when the add-on is uninstalled, which is pretty bad.
# bpy.utils.unregister_class(WM_OT_call_menu_pie_drag_only)
pass
@@ -4,6 +4,7 @@
import bpy
from .bs_utils.prefs import get_addon_prefs
from .prefs import draw_prefs
class VIEW3D_PT_extra_pies(bpy.types.Panel):
bl_label = "Extra Pies"