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 635 additions and 1 deletions

View File

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

View File

@ -0,0 +1,444 @@
import bpy
from bpy import types
from typing import List, Tuple, Dict, Optional
from bpy.props import StringProperty, CollectionProperty
from bpy_extras import id_map_utils
import os
from ..utils import hotkeys
from .relink_overridden_asset import OUTLINER_OT_relink_overridden_asset
### Pie Menu UI
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 = 'IDMAN_MT_relationship_pie'
@staticmethod
def get_id(context) -> Optional[bpy.types.ID]:
if context.area.type == 'OUTLINER' and len(context.selected_ids) > 0:
return context.selected_ids[-1]
@classmethod
def poll(cls, context):
return cls.get_id(context)
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'
)
# V
pie.operator('outliner.better_purge', icon='TRASH')
# ^
id = self.get_id(context)
id_type = ID_CLASS_TO_IDENTIFIER.get(type(id))
if id_type:
remap = pie.operator(
'outliner.remap_users', icon='FILE_REFRESH', text="Remap Users"
)
remap.id_type = id_type
remap.id_name_source = id.name
if id.library:
remap.library_path_source = id.library.filepath
else:
pie.label(text="Cannot remap unknwon ID type: " + str(type(id)))
# ^>
id = OUTLINER_OT_relink_overridden_asset.get_id(context)
if id:
pie.operator('object.relink_overridden_asset', icon='LIBRARY_DATA_OVERRIDE')
# <^
if id and id.override_library:
pie.operator(
'outliner.liboverride_troubleshoot_operation',
icon='UV_SYNC_SELECT',
text="Resync Override Hierarchy",
).type = 'OVERRIDE_LIBRARY_RESYNC_HIERARCHY_ENFORCE'
### Relationship visualization operators
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[-1]
@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 "Listing datablocks that reference this:"
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()
datablocks = self.get_datablocks_to_display(datablock)
if not datablocks:
layout.label(text="There are none.")
return
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
name_row.prop(
user.library,
'filepath',
icon=get_library_icon(user.library),
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 "Listing datablocks that are referenced by this:"
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))
### Remap Users
class RemapTarget(bpy.types.PropertyGroup):
pass
class OUTLINER_OT_remap_users(bpy.types.Operator):
"""A wrapper around Blender's built-in Remap Users operator"""
bl_idname = "outliner.remap_users"
bl_label = "Remap Users"
bl_options = {'INTERNAL', 'UNDO'}
def update_library_path(self, context):
# 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(self.id_type):
if id == source_id:
continue
if (self.library_path == 'Local Data' and not id.library) or (
id.library and (self.library_path == id.library.filepath)
):
id_entry = remap_targets.add()
id_entry.name = id.name
library_path: StringProperty(
name="Library",
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"
)
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."
# 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)
for lib in bpy.data.libraries:
for id in lib.users_id:
if type(id) == type(source_id):
lib_entry = remap_target_libraries.add()
lib_entry.name = lib.filepath
break
self.library_path = "Local Data"
return context.window_manager.invoke_props_dialog(self, width=800)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
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
layout.separator()
col = layout.column()
col.label(text="Will now reference this instead: ")
if len(scene.remap_target_libraries) > 1:
col.prop_search(
self,
'library_path',
scene,
'remap_target_libraries',
icon=get_library_icon(get_library_by_filepath(self.library_path)),
)
col.prop_search(
self,
'id_name_target',
scene,
'remap_targets',
text="Datablock",
icon=id_icon,
)
def execute(self, context):
source_id = get_id(self.id_name_source, self.id_type, self.library_path_source)

This loop is looking for a VIEW_3D but the report says it is checking if an outliner is present. Assuming the outliner is what you are looking for wouldn't it be more explicit to do...

for area in context.screen.areas:
    if area.type == 'OUTLINER':
        break
else:
    self.report(
        {'ERROR'}, "Error: This operation requires an Outliner to be present."
    )
    return {'CANCELLED'}
This loop is looking for a `VIEW_3D` but the report says it is checking if an outliner is present. Assuming the outliner is what you are looking for wouldn't it be more explicit to do... ```python for area in context.screen.areas: if area.type == 'OUTLINER': break else: self.report( {'ERROR'}, "Error: This operation requires an Outliner to be present." ) return {'CANCELLED'} ```
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_id.user_remap(target_id)
return {'FINISHED'}
### ID utilities
# (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 datablock identifier strings to the string of their bpy.data database name.
ID_TYPE_TO_STORAGE: Dict[type, str] = {tup[1]: tup[2] for tup in ID_INFO}
# 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 'NONE'
icon = ID_TYPE_STR_TO_ICON.get(identifier_str)
if not icon:
return 'NONE'
return icon
def get_id_storage(id_type) -> "bpy.data.something":
"""Return the database of a certain ID Type, for example if you pass in an
Object, this will return bpy.data.objects."""
storage = ID_TYPE_TO_STORAGE.get(id_type)
assert storage and hasattr(bpy.data, storage), (
"Error: Storage not found for id type: " + id_type
)
return getattr(bpy.data, storage)
def get_id(id_name: str, id_type: str, lib_path="") -> bpy.types.ID:
storage = get_id_storage(id_type)
if lib_path and lib_path != 'Local Data':
return storage.get((id_name, lib_path))
return storage.get((id_name, None))
### Library utilities
def get_library_icon(library: bpy.types.Library) -> str:
"""Return the library or the broken library icon, as appropriate."""
if not library:
return 'NONE'
icon = 'LIBRARY_DATA_DIRECT'
filepath = os.path.abspath(bpy.path.abspath(library.filepath))
if not os.path.exists(filepath):
icon = 'LIBRARY_DATA_BROKEN'
return icon
def get_library_by_filepath(filepath: str):
for lib in bpy.data.libraries:
if lib.filepath == filepath:
return lib
registry = [
IDMAN_MT_relationship_pie,
OUTLINER_OT_list_users_of_datablock,
OUTLINER_OT_list_dependencies_of_datablock,
RemapTarget,
OUTLINER_OT_remap_users,
]
def register():
hotkeys.register_hotkey(
bl_idname='wm.call_menu_pie',
km_name='Outliner',
key_id='Y',
op_kwargs={'name': IDMAN_MT_relationship_pie.bl_idname},
)
bpy.types.Scene.remap_targets = CollectionProperty(type=RemapTarget)
bpy.types.Scene.remap_target_libraries = CollectionProperty(type=RemapTarget)

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)