AnimCupboard: ID Management Pie #127

Merged
Demeter Dzadik merged 11 commits from Mets/blender-studio-pipeline:AnimCupboard-relationship-viewer into main 2023-07-19 14:43:14 +02:00
3 changed files with 459 additions and 1 deletions
Showing only changes of commit 1750818ef0 - Show all commits

View File

@ -7,6 +7,7 @@ from .operators import select_similar_curves
from .operators import lock_curves
from .operators import bake_anim_across_armatures
from .operators import relink_overridden_asset
from .operators import id_management_ops
from . import easy_constraints
from . import warn_about_broken_libraries
from . import bone_selection_sets
@ -29,7 +30,8 @@ modules = (
easy_constraints,
warn_about_broken_libraries,
bone_selection_sets,
relink_overridden_asset
relink_overridden_asset,
id_management_ops,
)

View File

@ -0,0 +1,268 @@
import bpy
from bpy import types
from typing import List, Tuple, Dict, Optional
from bpy.props import StringProperty
from bpy_extras import id_map_utils
import os
from ..utils import hotkeys
class RelationshipOperatorMixin:
datablock_name: StringProperty()
datablock_storage: StringProperty()
library_filepath: StringProperty()
def get_datablock(self, context) -> Optional[bpy.types.ID]:
if self.datablock_name and self.datablock_storage:
storage = getattr(bpy.data, self.datablock_storage)
lib_path = self.library_filepath or None
return storage.get((self.datablock_name, lib_path))
elif context.area.type == 'OUTLINER' and len(context.selected_ids) > 0:
return context.selected_ids[0]
@classmethod
def poll(cls, context):
return context.area.type == 'OUTLINER' and len(context.selected_ids) > 0
def invoke(self, context, _event):
return context.window_manager.invoke_props_dialog(self, width=600)
def get_datablocks_to_display(self, id: bpy.types.ID) -> List[bpy.types.ID]:
raise NotImplementedError
def get_label(self):
return "Showing users of datablock:"
def draw(self, context):
layout = self.layout
layout.use_property_decorate = False
layout.use_property_split = True
datablock = self.get_datablock(context)
if not datablock:
layout.alert = True
layout.label(
text=f"Failed to find datablock: {self.datablock_storage}, {self.datablock_name}, {self.library_filepath}")
return
row = layout.row()
split = row.split()
row = split.row()
row.alignment = 'RIGHT'
row.label(text=self.get_label())
id_row = split.row(align=True)
name_row = id_row.row()
name_row.enabled = False
name_row.prop(datablock, 'name',
icon=get_datablock_icon(datablock), text="")
fake_user_row = id_row.row()
fake_user_row.prop(datablock, 'use_fake_user', text="")
layout.separator()
for user in self.get_datablocks_to_display(datablock):
if user == datablock:
# Scenes are users of themself for technical reasons,
# I think it's confusing to display that.
continue
row = layout.row()
name_row = row.row()
name_row.enabled = False
name_row.prop(user, 'name', icon=get_datablock_icon(user), text="")
op_row = row.row()
op = op_row.operator(type(self).bl_idname,
text="", icon='LOOP_FORWARDS')
op.datablock_name = user.name
storage = ID_CLASS_TO_STORAGE.get(type(user))
if not storage:
print("Error: Can't find storage: ", type(user))
op.datablock_storage = storage
if user.library:
op.library_filepath = user.library.filepath
icon = 'LIBRARY_DATA_DIRECT'
filepath = os.path.abspath(
bpy.path.abspath(user.library.filepath))
if not os.path.exists(filepath):
icon = 'LIBRARY_DATA_BROKEN'
name_row.prop(user.library, 'filepath', icon=icon, text="")
def execute(self, context):
return {'FINISHED'}
class OUTLINER_OT_list_users_of_datablock(RelationshipOperatorMixin, bpy.types.Operator):
"""Show list of users of this datablock"""
bl_idname = "object.list_datablock_users"
bl_label = "List Datablock Users"
datablock_name: StringProperty()
datablock_storage: StringProperty()
library_filepath: StringProperty()
def get_datablocks_to_display(self, datablock: bpy.types.ID) -> List[bpy.types.ID]:
user_map = bpy.data.user_map()
users = user_map[datablock]
return sorted(users, key=lambda u: (str(type(u)), u.name))
class OUTLINER_OT_list_dependencies_of_datablock(RelationshipOperatorMixin, bpy.types.Operator):
"""Show list of dependencies of this datablock"""
bl_idname = "object.list_datablock_dependencies"
bl_label = "List Datablock Dependencies"
def get_label(self):
return "Showing dependencies of datablock:"
def get_datablocks_to_display(self, datablock: bpy.types.ID) -> List[bpy.types.ID]:
dependencies = id_map_utils.get_id_reference_map().get(datablock)
if not dependencies:
return []
return sorted(dependencies, key=lambda u: (str(type(u)), u.name))
# (ID Python type, identifier string, database name)
ID_INFO = [
(types.WindowManager, 'WINDOWMANAGER', 'window_managers'),
(types.Scene, 'SCENE', 'scenes'),
(types.World, 'WORLD', 'worlds'),
(types.Collection, 'COLLECTION', 'collections'),
(types.Armature, 'ARMATURE', 'armatures'),
(types.Mesh, 'MESH', 'meshes'),
(types.Camera, 'CAMERA', 'cameras'),
(types.Lattice, 'LATTICE', 'lattices'),
(types.Light, 'LIGHT', 'lights'),
(types.Speaker, 'SPEAKER', 'speakers'),
(types.Volume, 'VOLUME', 'volumes'),
(types.GreasePencil, 'GREASEPENCIL', 'grease_pencils'),
(types.Curve, 'CURVE', 'curves'),
(types.LightProbe, 'LIGHT_PROBE', 'lightprobes'),
(types.MetaBall, 'METABALL', 'metaballs'),
(types.Object, 'OBJECT', 'objects'),
(types.Action, 'ACTION', 'actions'),
(types.Key, 'KEY', 'shape_keys'),
(types.Sound, 'SOUND', 'sounds'),
(types.Material, 'MATERIAL', 'materials'),
(types.NodeTree, 'NODETREE', 'node_groups'),
(types.GeometryNodeTree, 'GEOMETRY', 'node_groups'),
(types.ShaderNodeTree, 'SHADER', 'node_groups'),
(types.Image, 'IMAGE', 'images'),
(types.Mask, 'MASK', 'masks'),
(types.FreestyleLineStyle, 'LINESTYLE', 'linestyles'),
(types.Library, 'LIBRARY', 'libraries'),
(types.VectorFont, 'FONT', 'fonts'),
(types.CacheFile, 'CACHE_FILE', 'cache_files'),
(types.PointCloud, 'POINT_CLOUD', 'pointclouds'),
(types.Curves, 'HAIR_CURVES', 'hair_curves'),
(types.Text, 'TEXT', 'texts'),
(types.ParticleSettings, 'PARTICLE', 'particles'),
(types.Palette, 'PALETTE', 'palettes'),
(types.PaintCurve, 'PAINT_CURVE', 'paint_curves'),
(types.MovieClip, 'MOVIECLIP', 'movieclips'),
(types.WorkSpace, 'WORKSPACE', 'workspaces'),
(types.Screen, 'SCREEN', 'screens'),
(types.Brush, 'BRUSH', 'brushes'),
(types.Texture, 'TEXTURE', 'textures'),
]
def get_datablock_icon_map() -> Dict[str, str]:
"""Create a mapping from datablock type identifiers to their icon.
We can get most of the icons from the Driver Type selector enum,
the rest we have to enter manually.
"""
enum_items = types.DriverTarget.bl_rna.properties['id_type'].enum_items
icon_map = {typ.identifier: typ.icon for typ in enum_items}
icon_map.update({
'SCREEN': 'RESTRICT_VIEW_OFF',
'METABALL': 'OUTLINER_OB_META',
'CACHE_FILE': 'MOD_MESHDEFORM',
'POINT_CLOUD': 'OUTLINER_OB_POINTCLOUD',
'HAIR_CURVES': 'OUTLINER_OB_CURVES',
'PAINT_CURVE': 'FORCE_CURVE',
'MOVIE_CLIP': 'FILE_MOVIE',
'GEOMETRY': 'GEOMETRY_NODES',
'SHADER': 'NODETREE',
})
return icon_map
# Map datablock identifier strings to their icon.
ID_TYPE_STR_TO_ICON: Dict[str, str] = get_datablock_icon_map()
# Map Python ID classes to their string representation.
ID_CLASS_TO_IDENTIFIER: Dict[type, str] = {tup[0]: tup[1] for tup in ID_INFO}
# Map Python ID classes to the string of their bpy.data database name.
ID_CLASS_TO_STORAGE: Dict[type, str] = {tup[0]: tup[2] for tup in ID_INFO}
def get_datablock_icon(id) -> str:
identifier_str = ID_CLASS_TO_IDENTIFIER.get(type(id))
if not identifier_str:
return 'QUESTION'
icon = ID_TYPE_STR_TO_ICON.get(identifier_str)
if not icon:
return 'QUESTION'
return icon
class IDMAN_MT_relationship_pie(bpy.types.Menu):
# bl_label is displayed at the center of the pie menu
bl_label = 'Datablock Relationships'
bl_idname = 'MT_relationships'
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
# <
pie.operator(OUTLINER_OT_list_users_of_datablock.bl_idname,
icon='LOOP_BACK')
# >
pie.operator(
OUTLINER_OT_list_dependencies_of_datablock.bl_idname, icon='LOOP_FORWARDS')
# ^
pie.operator('outliner.better_purge', icon='TRASH')
# V
pie.operator('outliner.id_operation', icon='FILE_REFRESH',
text="Remap Users").type = 'REMAP'
# <V
pie.operator('object.relink_overridden_asset',
icon='LIBRARY_DATA_OVERRIDE')
pie.operator('outliner.liboverride_troubleshoot_operation', icon='UV_SYNC_SELECT',
text="Resync Override Hierarchy").type = 'OVERRIDE_LIBRARY_RESYNC_HIERARCHY_ENFORCE'
registry = [
OUTLINER_OT_list_users_of_datablock,
OUTLINER_OT_list_dependencies_of_datablock,
IDMAN_MT_relationship_pie,
]
def register():
hotkeys.register_hotkey(
bl_idname='wm.call_menu_pie',
km_name='Outliner',
key_id='Y',
event_type='PRESS',
any=False,
ctrl=False,
alt=False,
shift=False,
oskey=False,
key_modifier='NONE',
direction='ANY',
repeat=False,
op_kwargs={'name': IDMAN_MT_relationship_pie.bl_idname}
)

View File

@ -0,0 +1,188 @@
from typing import List, Dict, Tuple, Optional, Type
import bpy
from bpy.types import KeyMapItem, Operator
def get_enum_values(bpy_type, enum_prop_name: str) -> Dict[str, Tuple[str, str]]:
if isinstance(bpy_type, Operator):
try:
enum_items = bpy_type.__annotations__[
enum_prop_name].keywords['items']
return {e[0]: (e[1], e[2]) for e in enum_items}
except:
return
enum_items = bpy_type.bl_rna.properties[enum_prop_name].enum_items
return {e.identifier: (e.name, e.description) for e in enum_items}
def is_valid_key_id(key_id: str) -> bool:
all_valid_key_identifiers = get_enum_values(KeyMapItem, 'type')
is_valid = key_id in all_valid_key_identifiers
if not is_valid:
print("All valid key identifiers and names:")
print("\n".join(list(all_valid_key_identifiers.items())))
print(
f'\nShortcut error: "{key_id}" is not a valid key identifier. Must be one of the above.')
return is_valid
def is_valid_event_type(event_type: str) -> bool:
all_valid_event_types = get_enum_values(KeyMapItem, 'value')
is_valid = event_type in all_valid_event_types
if not is_valid:
print("All valid event names:")
print("\n".join(list(all_valid_event_types.keys())))
print(
f'\nShortcut Error: "{event_type}" is not a valid event type. Must be one of the above.')
return is_valid
def get_all_keymap_names() -> List[str]:
return bpy.context.window_manager.keyconfigs.default.keymaps.keys()
def is_valid_keymap_name(km_name: str) -> bool:
all_km_names = get_all_keymap_names()
is_valid = km_name in all_km_names
if not is_valid:
print("All valid keymap names:")
print("\n".join(all_km_names))
print(
f'\nShortcut Error: "{km_name}" is not a valid keymap name. Must be one of the above.')
return is_valid
def get_space_type_of_keymap(km_name: str) -> str:
return bpy.context.window_manager.keyconfigs.default.keymaps[km_name].space_type
def find_operator_class_by_bl_idname(bl_idname: str) -> Type[Operator]:
for cl in Operator.__subclasses__():
if cl.bl_idname == bl_idname:
return cl
def find_keymap_item_by_trigger(
keymap,
bl_idname: str,
key_id: str,
ctrl=False,
alt=False,
shift=False,
oskey=False
) -> Optional[KeyMapItem]:
for kmi in keymap.keymap_items:
if (
kmi.idname == bl_idname and
kmi.type == key_id and
kmi.ctrl == ctrl and
kmi.alt == alt and
kmi.shift == shift and
kmi.oskey == oskey
):
return kmi
def find_keymap_item_by_op_kwargs(
keymap,
bl_idname: str,
op_kwargs={}
) -> Optional[KeyMapItem]:
for kmi in keymap.keymap_items:
if kmi.idname != bl_idname:
continue
op_class = find_operator_class_by_bl_idname(bl_idname)
if set(kmi.properties.keys()) != set(op_kwargs.keys()):
continue
any_mismatch = False
for prop_name in kmi.properties.keys():
# Check for enum string
enum_dict = get_enum_values(op_class, prop_name)
if enum_dict:
value = enum_dict[op_kwargs[prop_name]]
else:
value = kmi.properties[prop_name]
if value != op_kwargs[prop_name]:
any_mismatch = True
break
if any_mismatch:
continue
return kmi
def register_hotkey(
*,
bl_idname: str,
km_name='Window',
key_id: str,
event_type='PRESS',
any=False,
ctrl=False,
alt=False,
shift=False,
oskey=False,
key_modifier='NONE',
direction='ANY',
repeat=False,
op_kwargs={}
):
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
if not kc:
# This happens when running Blender in background mode.
return
if not is_valid_keymap_name(km_name):
return
if not is_valid_key_id(key_id):
return
if not is_valid_event_type(event_type):
return
# If this keymap already exists, new() will return the existing one, which is confusing but ideal.
km = kc.keymaps.new(
name=km_name, space_type=get_space_type_of_keymap(km_name))
kmi_existing = find_keymap_item_by_trigger(
km,
bl_idname=bl_idname,
key_id=key_id,
ctrl=ctrl,
alt=alt,
shift=shift,
oskey=oskey
)
if kmi_existing:
return
kmi = km.keymap_items.new(
bl_idname,
type=key_id,
value=event_type,
any=any,
ctrl=ctrl,
alt=alt,
shift=shift,
oskey=oskey,
key_modifier=key_modifier,
direction=direction,
repeat=repeat,
)
for key in op_kwargs:
value = op_kwargs[key]
setattr(kmi.properties, key, value)