diff --git a/scripts-blender/addons/anim_cupboard/__init__.py b/scripts-blender/addons/anim_cupboard/__init__.py index 85d929f1..6f0aa375 100644 --- a/scripts-blender/addons/anim_cupboard/__init__.py +++ b/scripts-blender/addons/anim_cupboard/__init__.py @@ -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_pie 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_pie, ) diff --git a/scripts-blender/addons/anim_cupboard/operators/id_management_pie.py b/scripts-blender/addons/anim_cupboard/operators/id_management_pie.py new file mode 100644 index 00000000..72399e64 --- /dev/null +++ b/scripts-blender/addons/anim_cupboard/operators/id_management_pie.py @@ -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) + 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) diff --git a/scripts-blender/addons/anim_cupboard/utils/hotkeys.py b/scripts-blender/addons/anim_cupboard/utils/hotkeys.py new file mode 100644 index 00000000..f0673bd6 --- /dev/null +++ b/scripts-blender/addons/anim_cupboard/utils/hotkeys.py @@ -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)