2025-12-09

This commit is contained in:
2026-03-17 15:03:35 -06:00
parent 4b82b57113
commit aae574f8dc
137 changed files with 17355 additions and 4067 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,7 +10,6 @@ module_names = (
"op_pie_wrappers",
"op_copy_to_selected",
"bs_utils",
"hotkeys",
"prefs",
"sidebar",
"tweak_builtin_pies",
@@ -83,10 +82,4 @@ def register():
bpy.app.timers.register(delayed_register, first_interval=0.5, persistent=True)
def unregister():
# save add-on prefs to file before unregistering.
from .bs_utils.prefs import get_addon_prefs, update_prefs_on_file
addon_prefs = get_addon_prefs()
if addon_prefs:
if bpy.context.preferences.use_preferences_save:
update_prefs_on_file()
register_unregister_modules(reversed(modules), False)
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.0"
version = "1.7.1"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -7,24 +7,8 @@
import bpy
from bpy.types import KeyMap, KeyMapItem, UILayout
ADDON_KEYMAPS = []
class HotkeyDrawMixin:
"""Expose these functions as a mix-in class so that add-ons can more easily override functionality as needed.
Add-ons should simply inherit this class in their AddonPreferences class.
"""
@staticmethod
def draw_hotkey_list(context, layout, compact=False, debug=False, sort_mode='BY_KEYMAP', ignore_missing=False):
draw_hotkey_list(context, layout, compact, debug, sort_mode, ignore_missing)
@staticmethod
def get_user_kmis_of_addon(context) -> list[tuple[KeyMap, KeyMapItem]]:
return get_user_kmis_of_addon(context)
@staticmethod
def draw_kmi(km: KeyMap, kmi: KeyMapItem, layout: UILayout, compact=False, debug=False):
draw_kmi(km, kmi, layout, compact=compact, debug=debug)
if "ADDON_KEYMAPS" not in locals():
ADDON_KEYMAPS = []
KEYMAP_ICONS = {
'Object Mode': 'OBJECT_DATAMODE',
@@ -57,7 +41,6 @@ def register_hotkey(
hotkey_kwargs={'type': "SPACE", 'value': "PRESS"},
keymap_name='Window'
):
global ADDON_KEYMAPS
wm = bpy.context.window_manager
@@ -80,8 +63,8 @@ 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:
@@ -204,8 +187,6 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
def is_kmi_matching(kmi: KeyMapItem, hotkey_kwargs: dict, op_idname: str, op_kwargs: dict) -> bool:
if kmi.idname != op_idname:
return False
if kmi.properties == None:
return False
combined_hotkey = KMI_DEFAULTS.copy()
combined_hotkey.update(hotkey_kwargs)
@@ -213,11 +194,17 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
if value != getattr(kmi, key):
return False
for key, value in op_kwargs.items():
if key not in kmi.properties:
return False
if value != kmi.properties[key]:
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:
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
@@ -342,18 +329,27 @@ def restore_deleted_keymap_items_global(context) -> int:
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
total_restored = 0
for user_km in user_kc.keymaps:
total_restored += restore_deleted_keymap_items(context, user_km)
keymap_names = [km.name for km in user_kc.keymaps]
for km_name in keymap_names:
num_restored = restore_deleted_keymap_items(context, km_name)
user_km = user_kc.keymaps[km_name]
if num_restored != 0:
user_km = user_kc.keymaps[km_name]
print(f"{user_km.name}: Restored {num_restored}")
total_restored += num_restored
return total_restored
def restore_deleted_keymap_items(context, user_km) -> int:
def restore_deleted_keymap_items(context, user_km_name) -> int:
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
default_kc = keyconfigs.default
addon_kc = keyconfigs.addon
user_km = user_kc.keymaps[user_km_name]
# Step 1: Store modified and added KeyMapItems in a temp keymap.
temp_km = user_kc.keymaps.new("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 = []
for user_kmi in user_km.keymap_items:
@@ -374,6 +370,10 @@ def restore_deleted_keymap_items(context, user_km) -> 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.
# 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]
# Step 3: Restore modified and added KeyMapItems.
for temp_def_kmi in kmis_user_defined:
@@ -1,6 +1,6 @@
from pathlib import Path
import bpy, json
import bpy, json, os
from bpy.types import PropertyGroup
from rna_prop_ui import IDPropertyGroup
from bpy.types import AddonPreferences
@@ -40,12 +40,14 @@ class PrefsFileSaveLoadMixin:
# This could still fail if Blender loads too slowly, so it could be better.
# Ideally, Blender would simply save add-on preferences to disk, and none of this should be needed.
def timer_func(_scene=None):
prefs = None
try:
prefs = get_addon_prefs()
except KeyError:
# Add-on got un-registered in the meantime.
return
prefs.load_and_apply_prefs_from_file()
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):
@@ -71,6 +73,8 @@ class PrefsFileSaveLoadMixin:
def save_prefs_to_file(self, _context=None):
filepath = get_prefs_filepath()
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w") as f:
json.dump(self.to_dict(), f, indent=4)
@@ -4,18 +4,19 @@
from bpy.types import UILayout
def aligned_label(layout: UILayout, text: str, icon=None, 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()
row.alert = alert
row.alignment = alignment
row.label(text=text, icon=icon, **kwargs)
row.label(**kwargs)
def label_split(layout: UILayout, text: str, icon=None, alert=False, **kwargs) -> UILayout:
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.label(text=text)
row.label(**kwargs)
return split
@@ -1,52 +0,0 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
class WM_OT_toggle_keymap_item_on_drag(bpy.types.Operator):
"When Drag is enabled, this pie menu will only appear when the mouse is dragged while the assigned key combo is held down"
bl_idname = "wm.toggle_keymap_item_property"
bl_label = "Toggle On Drag"
bl_options = {'REGISTER', 'INTERNAL'}
km_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
kmi_idname: bpy.props.StringProperty(options={'SKIP_SAVE'})
pie_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
prop_name: bpy.props.StringProperty(options={'SKIP_SAVE'})
def execute(self, context):
# Another sign of the fragility of Blender's keymap API.
# The reason for the existence of this property wrapper operator is that
# when we draw the `on_drag` property in the UI directly, Blender's keymap
# system (for some reason??) doesn't realize that a keymap entry has changed,
# and fails to refresh caches, which has disasterous results.
# This operator fires a refreshing of internal keymap data via
# `user_kmi.type = user_kmi.type`
user_kc = context.window_manager.keyconfigs.user
user_km = user_kc.keymaps.get(self.km_name)
if not user_km:
# This really shouldn't happen.
self.report({'ERROR'}, f"Couldn't find KeyMap: {self.km_name}")
return {'CANCELLED'}
for user_kmi in user_km.keymap_items:
if user_kmi.idname == self.kmi_idname and user_kmi.properties and user_kmi.properties.name == self.pie_name:
if hasattr(user_kmi.properties, self.prop_name):
setattr(
user_kmi.properties,
self.prop_name,
not getattr(user_kmi.properties, self.prop_name),
)
# This is the magic line that causes internal keymap data to be kept up to date and not break.
user_kmi.type = user_kmi.type
else:
self.report({'ERROR'}, "Property not in keymap: " + self.prop_name)
return {'CANCELLED'}
return {'FINISHED'}
registry = [
WM_OT_toggle_keymap_item_on_drag,
]
@@ -29,12 +29,19 @@ class WM_OT_call_menu_pie_drag_only(Operator):
bl_label = "Pie Menu on Drag"
bl_options = {'REGISTER', 'INTERNAL'}
def update_kmi(self, context):
if not hasattr(context, 'keymapitem'):
return
kmi = context.keymapitem # Set via UILayout.context_pointer_set().
kmi.type = kmi.type
name: StringProperty(options={'SKIP_SAVE'})
on_drag: BoolProperty(
name="On Drag",
default=True,
description="Only show this pie menu on mouse drag, otherwise execute a default operator",
options={'SKIP_SAVE'},
update=update_kmi,
)
fallback_operator: StringProperty(options={'SKIP_SAVE'})
fallback_op_kwargs: StringProperty(default="{}", options={'SKIP_SAVE'})
@@ -102,6 +109,7 @@ class WM_OT_call_menu_pie_drag_only(Operator):
if km:
for kmi in km.keymap_items:
for i, condition in enumerate([
kmi.idname != 'wm.call_menu_pie_drag_only',
kmi.type == hotkey_kwargs.get('type', ""),
kmi.value == hotkey_kwargs.get('value', "PRESS"),
kmi.ctrl == hotkey_kwargs.get('ctrl', False),
@@ -61,8 +61,6 @@ class OUTLINER_MT_relationship_pie(Menu):
remap = pie.operator(
'outliner.remap_users_ui', icon='FILE_REFRESH', text="Remap Users"
)
remap.id_type = id.id_type
remap.id_name_source = id.name
if id.library:
remap.library_path_source = id.library.filepath
else:
@@ -290,7 +288,7 @@ class RemapTarget(bpy.types.PropertyGroup):
class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
"""Remap users of a selected ID to any other ID of the same type"""
"""Remap users of selected IDs to any other ID of the same type"""
bl_idname = "outliner.remap_users_ui"
bl_label = "Remap Users"
@@ -300,9 +298,9 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
# Prepare the ID selector.
remap_targets = context.scene.remap_targets
remap_targets.clear()
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
for id in get_id_storage_by_type_str(self.id_type)[0]:
if id == source_id:
source_ids = get_selected_ids_of_active_type(context)
for id in get_id_storage_by_type_str(source_ids[0].id_type)[0]:
if id in source_ids:
continue
if (self.library_path == 'Local Data' and not id.library) or (
id.library and (self.library_path == id.library.filepath)
@@ -315,44 +313,50 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
description="Library path, if we want to remap to a linked ID",
update=update_library_path,
)
id_type: StringProperty(description="ID type, eg. 'OBJECT' or 'MESH'")
library_path_source: StringProperty()
id_name_source: StringProperty(
name="Source ID Name", description="Name of the ID we're remapping the users of"
)
id_name_target: StringProperty(
name="Target ID Name", description="Name of the ID we're remapping users to"
)
@classmethod
def poll(cls, context):
source_ids = get_selected_ids_of_active_type(context)
if not source_ids:
cls.poll_message_set("No selected IDs.")
return False
return True
def invoke(self, context, _event):
# Populate the remap_targets string list with possible options based on
# what was passed to the operator.
assert (
self.id_type and self.id_name_source
), "Error: UI must provide ID and ID type to this operator."
# selection context.
# Prepare the library selector.
remap_target_libraries = context.scene.remap_target_libraries
remap_target_libraries.clear()
local = remap_target_libraries.add()
local.name = "Local Data"
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
source_ids = get_selected_ids_of_active_type(context)
for lib in bpy.data.libraries:
for id in lib.users_id:
if type(id) == type(source_id):
if type(id) == type(source_ids[0]):
lib_entry = remap_target_libraries.add()
lib_entry.name = lib.filepath
break
container = get_id_storage_by_type_str(source_ids[0].id_type)[0]
self.library_path = "Local Data"
if source_id.name[-4] == ".":
storage = get_id_storage_by_type_str(self.id_type)[0]
suggestion = storage.get(source_id.name[:-4])
if suggestion:
self.id_name_target = suggestion.name
if suggestion.library:
self.library_path = suggestion.library.filepath
suffixed_id = next((id for id in source_ids if id.name[-4] == "."), None)
if suffixed_id:
default_target = container.get(suffixed_id.name[:-4])
if default_target:
self.id_name_target = default_target.name
if default_target.library:
self.library_path = default_target.library.filepath
else:
self.id_name_target = ""
self.library_path = 'Local Data'
return context.window_manager.invoke_props_dialog(self, width=600)
@@ -362,14 +366,18 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
layout.use_property_decorate = False
scene = context.scene
row = layout.row()
id = get_id(self.id_name_source, self.id_type, self.library_path_source)
id_icon = get_datablock_icon(id)
split = row.split()
split.row().label(text="Anything that was referencing this:")
row = split.row()
row.prop(self, 'id_name_source', text="", icon=id_icon)
row.enabled = False
source_ids = get_selected_ids_of_active_type(context)
id_icon = get_datablock_icon(source_ids[0])
for i, source_id in enumerate(source_ids):
row = layout.row()
split = row.split()
if i==0:
split.row().label(text="Anything that was referencing these:")
else:
split.row()
row = split.row()
row.prop(source_id, 'name', text="", icon=id_icon)
row.enabled = False
layout.separator()
col = layout.column()
@@ -392,11 +400,12 @@ class OUTLINER_OT_remap_users_ui(bpy.types.Operator):
)
def execute(self, context):
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)
target_id = get_id(self.id_name_target, self.id_type, self.library_path)
assert source_id and target_id, "Error: Failed to find source or target."
source_ids = get_selected_ids_of_active_type(context)
target_id = get_id(self.id_name_target, source_ids[0].id_type, self.library_path)
assert source_ids and target_id, "Error: Failed to find source or target."
source_id.user_remap(target_id)
for source_id in source_ids:
source_id.user_remap(target_id)
return {'FINISHED'}
@@ -409,9 +418,7 @@ class OBJECT_OT_instancer_empty_to_collection(Operator):
@classmethod
def poll(cls, context):
obj = context.active_object
if context.area.ui_type == 'OUTLINER':
obj = context.id
obj = get_active_id(context)
if not (
obj
@@ -429,9 +436,7 @@ class OBJECT_OT_instancer_empty_to_collection(Operator):
return True
def execute(self, context):
obj = context.active_object
if context.area.ui_type == 'OUTLINER':
obj = context.id
obj = get_active_id(context)
coll = obj.instance_collection
bpy.data.objects.remove(obj)
@@ -559,6 +564,13 @@ def get_fundamental_id_type(datablock: ID) -> tuple[Any, str]:
)
def get_selected_ids_of_active_type(context):
active_id = get_active_id(context)
return [
id for id in context.selected_ids
if type(id) == type(active_id)
]
def get_id(id_name: str, id_type: str, lib_path="") -> ID:
container = get_id_storage_by_type_str(id_type)[0]
if lib_path and lib_path != 'Local Data':
@@ -6,16 +6,14 @@ import platform, struct, urllib
import bpy
import addon_utils
from bpy.types import AddonPreferences, Operator, KeyMap, KeyMapItem
from bpy.types import AddonPreferences, KeyMap, KeyMapItem
from bpy.props import BoolProperty
from bl_ui.space_userpref import USERPREF_PT_interface_menus_pie
from .bs_utils.prefs import PrefsFileSaveLoadMixin, update_prefs_on_file, get_addon_prefs
from .bs_utils.hotkeys import HotkeyDrawMixin, get_sidebar, draw_hotkey_list
from .bs_utils.prefs import get_addon_prefs
from .bs_utils.hotkeys import get_sidebar, draw_hotkey_list
class ExtraPies_AddonPrefs(
PrefsFileSaveLoadMixin,
HotkeyDrawMixin,
AddonPreferences,
USERPREF_PT_interface_menus_pie, # We use this class's `draw_centered` function to draw built-in pie settings.
):
@@ -83,16 +81,9 @@ def button_draw_func(layout, km: KeyMap, kmi: KeyMapItem, compact=False):
sub = split.row(align=True)
sub.enabled = kmi.active
op = sub.operator(
'wm.toggle_keymap_item_property',
text=text,
icon='MOUSE_MOVE',
depress=kmi.properties.on_drag,
)
op.km_name = km.name
op.kmi_idname = kmi.idname
op.pie_name = kmi.properties.name
op.prop_name = 'on_drag'
sub.context_pointer_set("keymapitem", kmi)
sub.use_property_split=False
sub.prop(kmi.properties, 'on_drag', icon='MOUSE_MOVE', text=text)
def get_bug_report_url():
op_sys = "%s %d Bits\n" % (
@@ -123,46 +114,4 @@ def get_bug_report_url():
+ urllib.parse.quote(op_sys)
)
class WINDOW_OT_extra_pies_prefs_save(Operator):
"""Save Extra Pies add-on preferences"""
bl_idname = "window.extra_pies_prefs_save"
bl_label = "Save Pie Hotkeys"
bl_options = {'REGISTER'}
def execute(self, context):
filepath, data = update_prefs_on_file(context)
self.report({'INFO'}, f"Saved Pie Prefs to {filepath}.")
return {'FINISHED'}
class WINDOW_OT_extra_pies_prefs_load(Operator):
"""Load Extra Pies add-on preferences"""
bl_idname = "window.extra_pies_prefs_load"
bl_label = "Load Pie Hotkeys"
bl_options = {'REGISTER'}
def execute(self, context):
prefs = get_addon_prefs(context)
filepath = prefs.get_prefs_filepath()
success = prefs.load_and_apply_prefs_from_file()
if success:
self.report({'INFO'}, f"Loaded pie preferences from {filepath}.")
else:
self.report({'ERROR'}, "Failed to load Pie preferences.")
return {'FINISHED'}
registry = [
ExtraPies_AddonPrefs,
WINDOW_OT_extra_pies_prefs_save,
WINDOW_OT_extra_pies_prefs_load,
]
def register():
ExtraPies_AddonPrefs.register_autoload_from_file()
registry = [ExtraPies_AddonPrefs]