From 93d3b1931cd920dfe2881dd2fbc9273c28cb093c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Thu, 15 Jul 2021 16:18:39 +0200 Subject: New add-on: Pose Library v2.0 Add the Pose Library 2.0 add-on, which is based on the Asset Browser. It heavily depends on functionality of Blender itself, most notably in `pose_lib_2.c`. This add-on will be enabled by default by Blender, in order to be a proper replacement for the old, build-in pose library. It can also convert pose libraries from the old to the new one. --- pose_library/__init__.py | 81 ++++++++ pose_library/asset_browser.py | 79 ++++++++ pose_library/conversion.py | 78 ++++++++ pose_library/functions.py | 94 +++++++++ pose_library/gui.py | 221 +++++++++++++++++++++ pose_library/keymaps.py | 43 +++++ pose_library/macros.py | 61 ++++++ pose_library/operators.py | 439 ++++++++++++++++++++++++++++++++++++++++++ pose_library/pose_creation.py | 437 +++++++++++++++++++++++++++++++++++++++++ pose_library/pose_usage.py | 185 ++++++++++++++++++ 10 files changed, 1718 insertions(+) create mode 100644 pose_library/__init__.py create mode 100644 pose_library/asset_browser.py create mode 100644 pose_library/conversion.py create mode 100644 pose_library/functions.py create mode 100644 pose_library/gui.py create mode 100644 pose_library/keymaps.py create mode 100644 pose_library/macros.py create mode 100644 pose_library/operators.py create mode 100644 pose_library/pose_creation.py create mode 100644 pose_library/pose_usage.py diff --git a/pose_library/__init__.py b/pose_library/__init__.py new file mode 100644 index 00000000..1105065b --- /dev/null +++ b/pose_library/__init__.py @@ -0,0 +1,81 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library based on the Asset Browser. +""" + +bl_info = { + "name": "Pose Library", + "description": "Pose Library based on the Asset Browser.", + "author": "Sybren A. Stüvel", + "version": (2, 0), + "blender": (3, 0, 0), + "warning": "In heavily development, things may change", + "location": "Asset Browser -> Animations, and 3D Viewport -> Animation panel", + # "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/pose_library.html", + "support": "OFFICIAL", + "category": "Animation", +} + +from typing import List, Tuple + +_need_reload = "operators" in locals() +from . import gui, keymaps, macros, operators, conversion + +if _need_reload: + import importlib + + gui = importlib.reload(gui) + keymaps = importlib.reload(keymaps) + macros = importlib.reload(macros) + operators = importlib.reload(operators) + conversion = importlib.reload(conversion) + +import bpy + +addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = [] + + +def register() -> None: + bpy.types.WindowManager.poselib_flipped = bpy.props.BoolProperty( + name="Flip Pose", + default=False, + ) + bpy.types.WindowManager.poselib_previous_action = bpy.props.PointerProperty(type=bpy.types.Action) + + operators.register() + macros.register() + keymaps.register() + gui.register() + + +def unregister() -> None: + gui.unregister() + keymaps.unregister() + macros.unregister() + operators.unregister() + + try: + del bpy.types.WindowManager.poselib_flipped + except AttributeError: + pass + try: + del bpy.types.WindowManager.poselib_previous_action + except AttributeError: + pass diff --git a/pose_library/asset_browser.py b/pose_library/asset_browser.py new file mode 100644 index 00000000..3983e610 --- /dev/null +++ b/pose_library/asset_browser.py @@ -0,0 +1,79 @@ +"""Functions for finding and working with Asset Browsers.""" + +from typing import Iterable, Optional, Tuple + +import bpy +from bpy_extras import asset_utils + + +if "functions" not in locals(): + from . import functions +else: + import importlib + + functions = importlib.reload(functions) + + +def area_for_category(screen: bpy.types.Screen, category: str) -> Optional[bpy.types.Area]: + """Return the asset browser area that is most suitable for managing the category. + + :param screen: context.window.screen + :param category: asset category, see asset_category_items in rna_space.c + + :return: the area, or None if no Asset Browser area exists. + """ + + def area_sorting_key(area: bpy.types.Area) -> Tuple[bool, int]: + """Return tuple (is correct category, area size in pixels)""" + space_data = area.spaces[0] + asset_cat: str = space_data.params.asset_category + return (asset_cat == category, area.width * area.height) + + areas = list(suitable_areas(screen)) + if not areas: + return None + + return max(areas, key=area_sorting_key) + + +def suitable_areas(screen: bpy.types.Screen) -> Iterable[bpy.types.Area]: + """Generator, yield Asset Browser areas.""" + + for area in screen.areas: + space_data = area.spaces[0] + if not asset_utils.SpaceAssetInfo.is_asset_browser(space_data): + continue + yield area + + +def area_from_context(context: bpy.types.Context, category: str) -> Optional[bpy.types.Area]: + """Return an Asset Browser suitable for the given category. + + Prefers the current Asset Browser if available, otherwise the biggest. + """ + + space_data = context.space_data + if not asset_utils.SpaceAssetInfo.is_asset_browser(space_data): + return area_for_category(context.screen, category) + + if space_data.params.asset_category != category: + return area_for_category(context.screen, category) + + return context.area + + +def activate_asset( + asset: bpy.types.Action, asset_browser: bpy.types.Area, *, deferred: bool +) -> None: + """Select & focus the asset in the browser.""" + + space_data = asset_browser.spaces[0] + assert asset_utils.SpaceAssetInfo.is_asset_browser(space_data) + space_data.activate_asset_by_id(asset, deferred=deferred) + + +def tag_redraw(screen: bpy.types.Screen) -> None: + """Tag all asset browsers for redrawing.""" + + for area in suitable_areas(screen): + area.tag_redraw() diff --git a/pose_library/conversion.py b/pose_library/conversion.py new file mode 100644 index 00000000..43a5d3a4 --- /dev/null +++ b/pose_library/conversion.py @@ -0,0 +1,78 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - Conversion of old pose libraries. +""" + +from typing import Optional +from collections.abc import Collection + +if "pose_creation" not in locals(): + from . import pose_creation +else: + import importlib + + pose_creation = importlib.reload(pose_creation) + +import bpy +from bpy.types import ( + Action, + TimelineMarker, +) + + +def convert_old_poselib(old_poselib: Action) -> Collection[Action]: + """Convert an old-style pose library to a set of pose Actions. + + Old pose libraries were one Action with multiple pose markers. Each pose + marker will be converted to an Action by itself and marked as asset. + """ + + pose_assets = [ + action + for marker in old_poselib.pose_markers + if (action := convert_old_pose(old_poselib, marker)) + ] + + # Mark all Actions as assets in one go. Ideally this would be done on an + # appropriate frame in the scene (to set up things like the background + # colour), but the old-style poselib doesn't contain such information. All + # we can do is just render on the current frame. + bpy.ops.asset.mark({'selected_ids': pose_assets}) + + return pose_assets + + +def convert_old_pose(old_poselib: Action, marker: TimelineMarker) -> Optional[Action]: + """Convert an old-style pose library pose to a pose action.""" + + frame: int = marker.frame + action: Optional[Action] = None + + for fcurve in old_poselib.fcurves: + key = pose_creation.find_keyframe(fcurve, frame) + if not key: + continue + + if action is None: + action = bpy.data.actions.new(marker.name) + + pose_creation.create_single_key_fcurve(action, fcurve, key) + + return action diff --git a/pose_library/functions.py b/pose_library/functions.py new file mode 100644 index 00000000..bb32e669 --- /dev/null +++ b/pose_library/functions.py @@ -0,0 +1,94 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - functions. +""" + +from pathlib import Path +from typing import Any, List, Set, cast, Iterable + +Datablock = Any + +import bpy +from bpy.types import ( + Context, +) + + +def asset_mark(context: Context, datablock: Any) -> Set[str]: + asset_mark_ctx = { + **context.copy(), + "id": datablock, + } + return cast(Set[str], bpy.ops.asset.mark(asset_mark_ctx)) + + +def asset_clear(context: Context, datablock: Any) -> Set[str]: + asset_clear_ctx = { + **context.copy(), + "id": datablock, + } + result = bpy.ops.asset.clear(asset_clear_ctx) + assert isinstance(result, set) + if "FINISHED" in result: + datablock.use_fake_user = False + return result + + +def load_assets_from(filepath: Path) -> List[Datablock]: + if not has_assets(filepath): + # Avoid loading any datablocks when there are none marked as asset. + return [] + + # Append everything from the file. + with bpy.data.libraries.load(str(filepath)) as ( + data_from, + data_to, + ): + for attr in dir(data_to): + setattr(data_to, attr, getattr(data_from, attr)) + + # Iterate over the appended datablocks to find assets. + def loaded_datablocks() -> Iterable[Datablock]: + for attr in dir(data_to): + datablocks = getattr(data_to, attr) + for datablock in datablocks: + yield datablock + + loaded_assets = [] + for datablock in loaded_datablocks(): + if not getattr(datablock, "asset_data", None): + continue + + # Fake User is lost when appending from another file. + datablock.use_fake_user = True + loaded_assets.append(datablock) + return loaded_assets + + +def has_assets(filepath: Path) -> bool: + with bpy.data.libraries.load(str(filepath), assets_only=True) as ( + data_from, + _, + ): + for attr in dir(data_from): + data_names = getattr(data_from, attr) + if data_names: + return True + return False diff --git a/pose_library/gui.py b/pose_library/gui.py new file mode 100644 index 00000000..268c71cb --- /dev/null +++ b/pose_library/gui.py @@ -0,0 +1,221 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - GUI definition. +""" + +import bpy +from bpy.types import ( + AssetHandle, + Context, + Panel, + UIList, + WindowManager, + WorkSpace, +) + +from bpy_extras import asset_utils + + +class VIEW3D_PT_pose_library(Panel): + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Animation" + bl_label = "Pose Library" + + @classmethod + def poll(cls, context: Context) -> bool: + return context.preferences.experimental.use_asset_browser + + def draw(self, context: Context) -> None: + layout = self.layout + + row = layout.row(align=True) + row.operator("poselib.create_pose_asset").activate_new_action = False + if bpy.types.POSELIB_OT_restore_previous_action.poll(context): + row.operator("poselib.restore_previous_action", text="", icon='LOOP_BACK') + row.operator("poselib.copy_as_asset", icon="COPYDOWN", text="") + + wm = context.window_manager + layout.prop(wm, "poselib_flipped") + + if hasattr(layout, "template_asset_view"): + workspace = context.workspace + activate_op_props, drag_op_props = layout.template_asset_view( + "pose_assets", + workspace, + "active_asset_library", + wm, + "pose_assets", + workspace, + "active_pose_asset_index", + filter_id_types={"filter_action"}, + activate_operator="poselib.apply_pose_asset", + drag_operator="poselib.blend_pose_asset", + ) + drag_op_props.release_confirm = True + drag_op_props.flipped = wm.poselib_flipped + activate_op_props.flipped = wm.poselib_flipped + + +def pose_library_list_item_context_menu(self: UIList, context: Context) -> None: + def is_pose_asset_view() -> bool: + # Important: Must check context first, or the menu is added for every kind of list. + list = getattr(context, "ui_list", None) + if not list or list.bl_idname != "UI_UL_asset_view" or list.list_id != "pose_assets": + return False + if not context.asset_handle: + return False + return True + + def is_pose_library_asset_browser() -> bool: + asset_library = getattr(context, "asset_library", None) + if not asset_library: + return False + asset = getattr(context, "asset_file_handle", None) + if not asset: + return False + return bool(asset.id_type == 'ACTION') + + if not is_pose_asset_view() and not is_pose_library_asset_browser(): + return + + layout = self.layout + wm = context.window_manager + + layout.separator() + + layout.operator("poselib.apply_pose_asset", text="Apply Pose") + + old_op_ctx = layout.operator_context + layout.operator_context = 'INVOKE_DEFAULT' + props = layout.operator("poselib.blend_pose_asset", text="Blend Pose") + props.flipped = wm.poselib_flipped + layout.operator_context = old_op_ctx + + props = layout.operator("poselib.pose_asset_select_bones", text="Select Pose Bones") + props.select = True + props = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones") + props.select = False + + layout.separator() + layout.operator("asset.open_containing_blend_file") + + +class ASSETBROWSER_PT_pose_library_usage(asset_utils.AssetBrowserSpecificCategoryPanel, Panel): + bl_region_type = "TOOLS" + bl_label = "Pose Library" + asset_categories = {'ANIMATIONS'} + + def draw(self, context: Context) -> None: + layout = self.layout + wm = context.window_manager + + col = layout.column(align=True) + col.label(text="Use Pose Asset") + col.prop(wm, "poselib_flipped") + props = col.operator("poselib.apply_pose_asset") + props.flipped = wm.poselib_flipped + props = col.operator("poselib.blend_pose_asset") + props.flipped = wm.poselib_flipped + + row = col.row(align=True) + props = row.operator("poselib.pose_asset_select_bones", text="Select", icon="BONE_DATA") + props.flipped = wm.poselib_flipped + props.select = True + props = row.operator("poselib.pose_asset_select_bones", text="Deselect") + props.flipped = wm.poselib_flipped + props.select = False + + +class ASSETBROWSER_PT_pose_library_editing(asset_utils.AssetBrowserSpecificCategoryPanel, Panel): + bl_region_type = "TOOL_PROPS" + bl_label = "Pose Library" + asset_categories = {'ANIMATIONS'} + + def draw(self, context: Context) -> None: + layout = self.layout + + col = layout.column(align=True) + col.enabled = bpy.types.ASSET_OT_assign_action.poll(context) + col.label(text="Activate & Edit") + col.operator("asset.assign_action") + + # Creation + col = layout.column(align=True) + col.enabled = bpy.types.POSELIB_OT_paste_asset.poll(context) + col.label(text="Create Pose Asset") + col.operator("poselib.paste_asset", icon="PASTEDOWN") + + +class DOPESHEET_PT_asset_panel(Panel): + bl_space_type = "DOPESHEET_EDITOR" + bl_region_type = "UI" + bl_label = "Create Pose Asset" + + @classmethod + def poll(cls, context: Context) -> bool: + return context.preferences.experimental.use_asset_browser + + def draw(self, context: Context) -> None: + layout = self.layout + col = layout.column(align=True) + row = col.row(align=True) + row.operator("poselib.create_pose_asset").activate_new_action = True + if bpy.types.POSELIB_OT_restore_previous_action.poll(context): + row.operator("poselib.restore_previous_action", text="", icon='LOOP_BACK') + col.operator("poselib.copy_as_asset", icon="COPYDOWN") + + layout.operator("poselib.convert_old_poselib") + + +classes = ( + ASSETBROWSER_PT_pose_library_editing, + ASSETBROWSER_PT_pose_library_usage, + DOPESHEET_PT_asset_panel, + VIEW3D_PT_pose_library, +) + +_register, _unregister = bpy.utils.register_classes_factory(classes) + + +def register() -> None: + _register() + + WorkSpace.active_pose_asset_index = bpy.props.IntProperty( + name="Active Pose Asset", + # TODO explain which list the index belongs to, or how it can be used to get the pose. + description="Per workspace index of the active pose asset" + ) + # Register for window-manager. This is a global property that shouldn't be + # written to files. + WindowManager.pose_assets = bpy.props.CollectionProperty(type=AssetHandle) + + bpy.types.UI_MT_list_item_context_menu.prepend(pose_library_list_item_context_menu) + bpy.types.FILEBROWSER_MT_context_menu.prepend(pose_library_list_item_context_menu) + + +def unregister() -> None: + _unregister() + + del WorkSpace.active_pose_asset_index + del WindowManager.pose_assets + + bpy.types.UI_MT_list_item_context_menu.remove(pose_library_list_item_context_menu) + bpy.types.FILEBROWSER_MT_context_menu.remove(pose_library_list_item_context_menu) diff --git a/pose_library/keymaps.py b/pose_library/keymaps.py new file mode 100644 index 00000000..87ccf572 --- /dev/null +++ b/pose_library/keymaps.py @@ -0,0 +1,43 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +from typing import List, Tuple + +import bpy + +addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = [] + + +def register() -> None: + wm = bpy.context.window_manager + if wm.keyconfigs.addon is None: + # This happens when Blender is running in the background. + return + + km = wm.keyconfigs.addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER") + + # DblClick to apply pose. + kmi = km.keymap_items.new("poselib.apply_pose_asset_for_keymap", "LEFTMOUSE", "DOUBLE_CLICK") + addon_keymaps.append((km, kmi)) + + +def unregister() -> None: + # Clear shortcuts from the keymap. + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() diff --git a/pose_library/macros.py b/pose_library/macros.py new file mode 100644 index 00000000..35f33308 --- /dev/null +++ b/pose_library/macros.py @@ -0,0 +1,61 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - macros. +""" + +import bpy + + +class POSELIB_OT_select_asset_and_select_bones(bpy.types.Macro): + bl_idname = "poselib.select_asset_and_select_bones" + bl_label = "Select Pose & Select Bones" + + +class POSELIB_OT_select_asset_and_deselect_bones(bpy.types.Macro): + bl_idname = "poselib.select_asset_and_deselect_bones" + bl_label = "Select Pose & Deselect Bones" + + +classes = ( + POSELIB_OT_select_asset_and_select_bones, + POSELIB_OT_select_asset_and_deselect_bones, +) + +_register, _unregister = bpy.utils.register_classes_factory(classes) + + +def register() -> None: + _register() + + step = POSELIB_OT_select_asset_and_select_bones.define("FILE_OT_select") + step.properties.open = False + step.properties.deselect_all = True + step = POSELIB_OT_select_asset_and_select_bones.define("POSELIB_OT_pose_asset_select_bones") + step.properties.select = True + + step = POSELIB_OT_select_asset_and_deselect_bones.define("FILE_OT_select") + step.properties.open = False + step.properties.deselect_all = True + step = POSELIB_OT_select_asset_and_deselect_bones.define("POSELIB_OT_pose_asset_select_bones") + step.properties.select = False + + +def unregister() -> None: + _unregister() diff --git a/pose_library/operators.py b/pose_library/operators.py new file mode 100644 index 00000000..f06241d7 --- /dev/null +++ b/pose_library/operators.py @@ -0,0 +1,439 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - operators. +""" + +from pathlib import Path +from typing import Optional, Set + +_need_reload = "functions" in locals() +from . import asset_browser, functions, pose_creation, pose_usage + +if _need_reload: + import importlib + + asset_browser = importlib.reload(asset_browser) + functions = importlib.reload(functions) + pose_creation = importlib.reload(pose_creation) + pose_usage = importlib.reload(pose_usage) + + +import bpy +from bpy.props import BoolProperty, StringProperty +from bpy.types import ( + Action, + Context, + Event, + FileSelectEntry, + Object, + Operator, +) + + +class PoseAssetCreator: + @classmethod + def poll(cls, context: Context) -> bool: + return bool( + # There must be an object. + context.object + # It must be in pose mode with selected bones. + and context.object.mode == "POSE" + and context.object.pose + and context.selected_pose_bones_from_active_object + ) + + +class LocalPoseAssetUser: + @classmethod + def poll(cls, context: Context) -> bool: + return bool( + isinstance(getattr(context, "id", None), Action) + and context.object + and context.object.mode == "POSE" # This condition may not be desired. + ) + + +class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator): + bl_idname = "poselib.create_pose_asset" + bl_label = "Create Pose Asset" + bl_description = ( + "Create a new Action that contains the pose of the selected bones, and mark it as Asset" + ) + bl_options = {"REGISTER", "UNDO"} + + pose_name: StringProperty(name="Pose Name") # type: ignore + activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore + + def execute(self, context: Context) -> Set[str]: + pose_name = self.pose_name or context.object.name + asset = pose_creation.create_pose_asset_from_context(context, pose_name) + if not asset: + self.report({"WARNING"}, "No keyframes were found for this pose") + return {"CANCELLED"} + + if self.activate_new_action: + self._set_active_action(context, asset) + self._activate_asset_in_browser(context, asset) + return {'FINISHED'} + + def _set_active_action(self, context: Context, asset: Action) -> None: + self._prevent_action_loss(context.object) + + anim_data = context.object.animation_data_create() + context.window_manager.poselib_previous_action = anim_data.action + anim_data.action = asset + + def _activate_asset_in_browser(self, context: Context, asset: Action) -> None: + """Activate the new asset in the appropriate Asset Browser. + + This makes it possible to immediately check & edit the created pose asset. + """ + + asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_for_category( + context.screen, "ANIMATION" + ) + if not asset_browse_area: + return + + # After creating an asset, the window manager has to process the + # notifiers before editors should be manipulated. + pose_creation.assign_tags_from_asset_browser(asset, asset_browse_area) + + # Pass deferred=True, because we just created a new asset that isn't + # known to the Asset Browser space yet. That requires the processing of + # notifiers, which will only happen after this code has finished + # running. + asset_browser.activate_asset(asset, asset_browse_area, deferred=True) + + def _prevent_action_loss(self, object: Object) -> None: + """Mark the action with Fake User if necessary. + + This is to prevent action loss when we reduce its reference counter by one. + """ + + if not object.animation_data: + return + + action = object.animation_data.action + if not action: + return + + if action.use_fake_user or action.users > 1: + # Removing one user won't GC it. + return + + action.use_fake_user = True + self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name) + + +class POSELIB_OT_restore_previous_action(Operator): + bl_idname = "poselib.restore_previous_action" + bl_label = "Restore Previous Action" + bl_description = "Switch back to the previous Action, after creating a pose asset" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context: Context) -> bool: + return bool( + context.window_manager.poselib_previous_action + and context.object + and context.object.animation_data + and context.object.animation_data.action + and context.object.animation_data.action.asset_data is not None + ) + + def execute(self, context: Context) -> Set[str]: + # This is the Action that was just created with "Create Pose Asset". + # It has to be re-applied after switching to the previous action, + # to ensure the character keeps the same pose. + self.pose_action = context.object.animation_data.action + + prev_action = context.window_manager.poselib_previous_action + context.object.animation_data.action = prev_action + context.window_manager.poselib_previous_action = None + + # Wait a bit for the action assignment to be handled, before applying the pose. + wm = context.window_manager + self._timer = wm.event_timer_add(0.001, window=context.window) + wm.modal_handler_add(self) + + return {'RUNNING_MODAL'} + + def modal(self, context, event): + if event.type != 'TIMER': + return {'RUNNING_MODAL'} + + wm = context.window_manager + wm.event_timer_remove(self._timer) + + context.object.pose.apply_pose_from_action(self.pose_action) + return {'FINISHED'} + + +class ASSET_OT_assign_action(LocalPoseAssetUser, Operator): + bl_idname = "asset.assign_action" + bl_label = "Assign Action" + bl_description = "Set this pose Action as active Action on the active Object" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context: Context) -> Set[str]: + context.object.animation_data_create().action = context.id + return {"FINISHED"} + + +class POSELIB_OT_copy_as_asset(PoseAssetCreator, Operator): + bl_idname = "poselib.copy_as_asset" + bl_label = "Copy Pose As Asset" + bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser" + bl_options = {"REGISTER"} + + CLIPBOARD_ASSET_MARKER = "ASSET-BLEND=" + + def execute(self, context: Context) -> Set[str]: + asset = pose_creation.create_pose_asset_from_context( + context, new_asset_name=context.object.name + ) + if asset is None: + self.report({"WARNING"}, "No animation data found to create asset from") + return {"CANCELLED"} + + filepath = self.save_datablock(asset) + + functions.asset_clear(context, asset) + if asset.users > 0: + self.report({"ERROR"}, "Whaaaat who is using our brand new asset?") + return {"FINISHED"} + + bpy.data.actions.remove(asset) + + context.window_manager.clipboard = "%s%s" % ( + self.CLIPBOARD_ASSET_MARKER, + filepath, + ) + + asset_browser.tag_redraw(context.screen) + self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste") + return {"FINISHED"} + + def save_datablock(self, action: Action) -> Path: + tempdir = Path(bpy.app.tempdir) + filepath = tempdir / "copied_asset.blend" + bpy.data.libraries.write( + str(filepath), + datablocks={action}, + path_remap="NONE", + fake_user=True, + compress=True, # Single-datablock blend file, likely little need to diff. + ) + return filepath + + +class POSELIB_OT_paste_asset(Operator): + bl_idname = "poselib.paste_asset" + bl_label = "Paste As New Asset" + bl_description = "Paste the Asset that was previously copied using Copy As Asset" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context: Context) -> bool: + clipboard: str = context.window_manager.clipboard + if not clipboard: + return False + marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER + return clipboard.startswith(marker) + + def execute(self, context: Context) -> Set[str]: + clipboard = context.window_manager.clipboard + marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER) + filepath = Path(clipboard[marker_len:]) + + assets = functions.load_assets_from(filepath) + if not assets: + self.report({"ERROR"}, "Did not find any assets on clipboard") + return {"CANCELLED"} + + self.report({"INFO"}, "Pasted %d assets" % len(assets)) + + bpy.ops.file.refresh() + asset_browser_area = asset_browser.area_from_context(context, 'ANIMATIONS') + if asset_browser_area: + asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True) + + return {"FINISHED"} + + +class PoseAssetUser: + @classmethod + def poll(cls, context: Context) -> bool: + if not ( + context.object + and context.object.mode == "POSE" # This condition may not be desired. + and context.asset_library + and context.asset_file_handle + ): + return False + return context.asset_file_handle.id_type == 'ACTION' + + def execute(self, context: Context) -> Set[str]: + asset: FileSelectEntry = context.asset_file_handle + if asset.local_id: + return self.use_pose(context, asset.local_id) + return self._load_and_use_pose(context) + + def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]: + # Implement in subclass. + pass + + def _load_and_use_pose(self, context: Context) -> Set[str]: + asset_library = context.asset_library + asset = context.asset_file_handle + asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library) + + if not asset_lib_path: + self.report( # type: ignore + {"ERROR"}, + # TODO: Add some way to get the library name from the library reference (just asset_library.name?). + f"Selected asset {asset.name} could not be located inside the asset library", + ) + return {"CANCELLED"} + if asset.id_type != 'ACTION': + self.report( # type: ignore + {"ERROR"}, + f"Selected asset {asset.name} is not an Action", + ) + return {"CANCELLED"} + + with bpy.types.BlendData.temp_data() as temp_data: + with temp_data.libraries.load(asset_lib_path) as (data_from, data_to): + data_to.actions = [asset.name] + + action: Action = data_to.actions[0] + return self.use_pose(context, action) + + +class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator): + bl_idname = "poselib.pose_asset_select_bones" + bl_label = "Select Bones" + bl_description = "Select those bones that are used in this pose" + bl_options = {"REGISTER", "UNDO"} + + select: BoolProperty(name="Select", default=True) # type: ignore + flipped: BoolProperty(name="Flipped", default=False) # type: ignore + + def use_pose(self, context: Context, pose_asset: Action) -> Set[str]: + arm_object: Object = context.object + pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped) + verb = "Selected" if self.select else "Deselected" + self.report({"INFO"}, f"{verb} bones from {pose_asset.name}") + return {"FINISHED"} + + @classmethod + def description( + cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones' + ) -> str: + if properties.select: + return cls.bl_description + return cls.bl_description.replace("Select", "Deselect") + + +class POSELIB_OT_blend_pose_asset_for_keymap(Operator): + bl_idname = "poselib.blend_pose_asset_for_keymap" + bl_options = {"REGISTER", "UNDO"} + + _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type() + bl_label = _rna.name + bl_description = _rna.description + del _rna + + @classmethod + def poll(cls, context: Context) -> bool: + return bpy.ops.poselib.blend_pose_asset.poll(context.copy()) + + def execute(self, context: Context) -> Set[str]: + flipped = context.window_manager.poselib_flipped + return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped) + + def invoke(self, context: Context, event: Event) -> Set[str]: + flipped = context.window_manager.poselib_flipped + return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped) + + +class POSELIB_OT_apply_pose_asset_for_keymap(Operator): + bl_idname = "poselib.apply_pose_asset_for_keymap" + bl_options = {"REGISTER", "UNDO"} + + _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type() + bl_label = _rna.name + bl_description = _rna.description + del _rna + + @classmethod + def poll(cls, context: Context) -> bool: + return bpy.ops.poselib.apply_pose_asset.poll(context.copy()) + + def execute(self, context: Context) -> Set[str]: + flipped = context.window_manager.poselib_flipped + return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped) + + +class POSELIB_OT_convert_old_poselib(Operator): + bl_idname = "poselib.convert_old_poselib" + bl_label = "Convert old-style pose library" + bl_description = "Create a pose asset for each pose marker in the current action" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context: Context) -> bool: + action = context.object and context.object.animation_data and context.object.animation_data.action + if not action: + cls.poll_message_set("Active object has no Action") + return False + if not action.pose_markers: + cls.poll_message_set("Action %r is not a old-style pose library" % action.name) + return False + return True + + def execute(self, context: Context) -> Set[str]: + from . import conversion + + old_poselib = context.object.animation_data.action + new_actions = conversion.convert_old_poselib(old_poselib) + + if not new_actions: + self.report({'ERROR'}, "Unable to convert to pose assets") + return {'CANCELLED'} + + self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions)) + return {'FINISHED'} + + +classes = ( + ASSET_OT_assign_action, + POSELIB_OT_apply_pose_asset_for_keymap, + POSELIB_OT_blend_pose_asset_for_keymap, + POSELIB_OT_convert_old_poselib, + POSELIB_OT_copy_as_asset, + POSELIB_OT_create_pose_asset, + POSELIB_OT_paste_asset, + POSELIB_OT_pose_asset_select_bones, + POSELIB_OT_restore_previous_action, +) + +register, unregister = bpy.utils.register_classes_factory(classes) diff --git a/pose_library/pose_creation.py b/pose_library/pose_creation.py new file mode 100644 index 00000000..79efcae4 --- /dev/null +++ b/pose_library/pose_creation.py @@ -0,0 +1,437 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - creation functions. +""" + +import dataclasses +import functools +import re +from typing import Optional, FrozenSet, Set, Union, Iterable, cast + +if "functions" not in locals(): + from . import functions +else: + import importlib + + functions = importlib.reload(functions) + +import bpy +from bpy.types import ( + Action, + Bone, + Context, + FCurve, + Keyframe, +) + +FCurveValue = Union[float, int] + +pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]') +"""RegExp for matching FCurve data paths.""" + + +@dataclasses.dataclass(unsafe_hash=True, frozen=True) +class PoseCreationParams: + armature_ob: bpy.types.Object + src_action: Optional[Action] + src_frame_nr: float + bone_names: FrozenSet[str] + new_asset_name: str + + +class UnresolvablePathError(ValueError): + """Raised when a data_path cannot be resolved to a current value.""" + + +@dataclasses.dataclass(unsafe_hash=True) +class PoseActionCreator: + """Create an Action that's suitable for marking as Asset. + + Does not mark as asset yet, nor does it add asset metadata. + """ + + params: PoseCreationParams + + # These were taken from Blender's Action baking code in `anim_utils.py`. + # Items are (name, array_length) tuples. + _bbone_props = [ + ("bbone_curveinx", None), + ("bbone_curveoutx", None), + ("bbone_curveinz", None), + ("bbone_curveoutz", None), + ("bbone_rollin", None), + ("bbone_rollout", None), + ("bbone_scalein", 3), + ("bbone_scaleout", 3), + ("bbone_easein", None), + ("bbone_easeout", None), + ] + + def create(self) -> Optional[Action]: + """Create a single-frame Action containing only the given bones, or None if no anim data was found.""" + + try: + dst_action = self._create_new_action() + self._store_pose(dst_action) + finally: + # Prevent next instantiations of this class from reusing pointers to + # bones. They may not be valid by then any more. + self._find_bone.cache_clear() + + if len(dst_action.fcurves) == 0: + bpy.data.actions.remove(dst_action) + return None + + return dst_action + + def _create_new_action(self) -> Action: + dst_action = bpy.data.actions.new(self.params.new_asset_name) + if self.params.src_action: + dst_action.id_root = self.params.src_action.id_root + dst_action.user_clear() # actions.new() sets users=1, but marking as asset also increments user count. + return dst_action + + def _store_pose(self, dst_action: Action) -> None: + """Store the current pose into the given action.""" + self._store_bone_pose_parameters(dst_action) + self._store_animated_parameters(dst_action) + self._store_parameters_from_callback(dst_action) + + def _store_bone_pose_parameters(self, dst_action: Action) -> None: + """Store loc/rot/scale/bbone values in the Action.""" + + for bone_name in sorted(self.params.bone_names): + self._store_location(dst_action, bone_name) + self._store_rotation(dst_action, bone_name) + self._store_scale(dst_action, bone_name) + self._store_bbone(dst_action, bone_name) + + def _store_animated_parameters(self, dst_action: Action) -> None: + """Store the current value of any animated bone properties.""" + if self.params.src_action is None: + return + + armature_ob = self.params.armature_ob + for fcurve in self.params.src_action.fcurves: + match = pose_bone_re.match(fcurve.data_path) + if not match: + # Not animating a bone property. + continue + + bone_name = match.group(1) + if bone_name not in self.params.bone_names: + # Bone is not our export set. + continue + + if dst_action.fcurves.find(fcurve.data_path, index=fcurve.array_index): + # This property is already handled by a previous _store_xxx() call. + continue + + # Only include in the pose if there is a key on this frame. + if not self._has_key_on_frame(fcurve): + continue + + try: + value = self._current_value(armature_ob, fcurve.data_path, fcurve.array_index) + except UnresolvablePathError: + # A once-animated property no longer exists. + continue + + dst_fcurve = dst_action.fcurves.new( + fcurve.data_path, index=fcurve.array_index, action_group=bone_name + ) + dst_fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value) + dst_fcurve.update() + + def _store_parameters_from_callback(self, dst_action: Action) -> None: + """Store extra parameters in the pose based on arbitrary callbacks. + + Not implemented yet, needs a proper design & some user stories. + """ + pass + + def _store_location(self, dst_action: Action, bone_name: str) -> None: + """Store bone location.""" + self._store_bone_array(dst_action, bone_name, "location", 3) + + def _store_rotation(self, dst_action: Action, bone_name: str) -> None: + """Store bone rotation given current rotation mode.""" + bone = self._find_bone(bone_name) + if bone.rotation_mode == "QUATERNION": + self._store_bone_array(dst_action, bone_name, "rotation_quaternion", 4) + elif bone.rotation_mode == "AXIS_ANGLE": + self._store_bone_array(dst_action, bone_name, "rotation_axis_angle", 4) + else: + self._store_bone_array(dst_action, bone_name, "rotation_euler", 3) + + def _store_scale(self, dst_action: Action, bone_name: str) -> None: + """Store bone scale.""" + self._store_bone_array(dst_action, bone_name, "scale", 3) + + def _store_bbone(self, dst_action: Action, bone_name: str) -> None: + """Store bendy-bone parameters.""" + for prop_name, array_length in self._bbone_props: + if array_length: + self._store_bone_array(dst_action, bone_name, prop_name, array_length) + else: + self._store_bone_property(dst_action, bone_name, prop_name) + + def _store_bone_array( + self, dst_action: Action, bone_name: str, property_name: str, array_length: int + ) -> None: + """Store all elements of an array property.""" + for array_index in range(array_length): + self._store_bone_property(dst_action, bone_name, property_name, array_index) + + def _store_bone_property( + self, + dst_action: Action, + bone_name: str, + property_path: str, + array_index: int = -1, + ) -> None: + """Store the current value of a single bone property.""" + + bone = self._find_bone(bone_name) + value = self._current_value(bone, property_path, array_index) + + # Get the full 'pose.bones["bone_name"].blablabla' path suitable for FCurves. + rna_path = bone.path_from_id(property_path) + + fcurve: Optional[FCurve] = dst_action.fcurves.find(rna_path, index=array_index) + if fcurve is None: + fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name) + + fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value) + fcurve.update() + + @classmethod + def _current_value( + cls, datablock: bpy.types.ID, data_path: str, array_index: int + ) -> FCurveValue: + """Resolve an RNA path + array index to an actual value.""" + value_or_array = cls._path_resolve(datablock, data_path) + + # Both indices -1 and 0 are used for non-array properties. + # -1 cannot be used in arrays, whereas 0 can be used in both arrays and non-arrays. + + if array_index == -1: + return cast(FCurveValue, value_or_array) + + if array_index == 0: + value_or_array = cls._path_resolve(datablock, data_path) + try: + # MyPy doesn't understand this try/except is to determine the type. + value = value_or_array[array_index] # type: ignore + except TypeError: + # Not an array after all. + return cast(FCurveValue, value_or_array) + return cast(FCurveValue, value) + + # MyPy doesn't understand that array_index>0 implies this is indexable. + return cast(FCurveValue, value_or_array[array_index]) # type: ignore + + @staticmethod + def _path_resolve( + datablock: bpy.types.ID, data_path: str + ) -> Union[FCurveValue, Iterable[FCurveValue]]: + """Wrapper for datablock.path_resolve(data_path). + + Raise UnresolvablePathError when the path cannot be resolved. + This is easier to deal with upstream than the generic ValueError raised + by Blender. + """ + try: + return datablock.path_resolve(data_path) # type: ignore + except ValueError as ex: + raise UnresolvablePathError(str(ex)) from ex + + @functools.lru_cache(maxsize=1024) + def _find_bone(self, bone_name: str) -> Bone: + """Find a bone by name. + + Assumes the named bone exists, as the bones this class handles comes + from the user's selection, and you can't select a non-existent bone. + """ + + bone: Bone = self.params.armature_ob.pose.bones[bone_name] + return bone + + def _has_key_on_frame(self, fcurve: FCurve) -> bool: + """Return True iff the FCurve has a key on the source frame.""" + + points = fcurve.keyframe_points + if not points: + return False + + frame_to_find = self.params.src_frame_nr + margin = 0.001 + high = len(points) - 1 + low = 0 + while low <= high: + mid = (high + low) // 2 + diff = points[mid].co.x - frame_to_find + if abs(diff) < margin: + return True + if diff < 0: + # Frame to find is bigger than the current middle. + low = mid + 1 + else: + # Frame to find is smaller than the current middle + high = mid - 1 + return False + + +def create_pose_asset( + context: Context, + params: PoseCreationParams, +) -> Optional[Action]: + """Create a single-frame Action containing only the pose of the given bones. + + DOES mark as asset, DOES NOT add asset metadata. + """ + + creator = PoseActionCreator(params) + pose_action = creator.create() + if pose_action is None: + return None + + functions.asset_mark(context, pose_action) + return pose_action + + +def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]: + """Create Action asset from active object & selected bones.""" + + bones = context.selected_pose_bones_from_active_object + bone_names = {bone.name for bone in bones} + + params = PoseCreationParams( + context.object, + getattr(context.object.animation_data, "action", None), + context.scene.frame_current, + frozenset(bone_names), + new_asset_name, + ) + + return create_pose_asset(context, params) + + +def copy_fcurves( + dst_action: Action, + src_action: Action, + src_frame_nr: float, + bone_names: Set[str], +) -> int: + """Copy FCurves, returning number of curves copied.""" + num_fcurves_copied = 0 + for fcurve in src_action.fcurves: + match = pose_bone_re.match(fcurve.data_path) + if not match: + continue + + bone_name = match.group(1) + if bone_name not in bone_names: + continue + + # Check if there is a keyframe on this frame. + keyframe = find_keyframe(fcurve, src_frame_nr) + if keyframe is None: + continue + create_single_key_fcurve(dst_action, fcurve, keyframe) + num_fcurves_copied += 1 + return num_fcurves_copied + + +def create_single_key_fcurve( + dst_action: Action, src_fcurve: FCurve, src_keyframe: Keyframe +) -> FCurve: + """Create a copy of the source FCurve, but only for the given keyframe. + + Returns a new FCurve with just one keyframe. + """ + + dst_fcurve = copy_fcurve_without_keys(dst_action, src_fcurve) + copy_keyframe(dst_fcurve, src_keyframe) + return dst_fcurve + + +def copy_fcurve_without_keys(dst_action: Action, src_fcurve: FCurve) -> FCurve: + """Create a new FCurve and copy some properties.""" + + src_group_name = src_fcurve.group.name if src_fcurve.group else "" + dst_fcurve = dst_action.fcurves.new( + src_fcurve.data_path, index=src_fcurve.array_index, action_group=src_group_name + ) + for propname in {"auto_smoothing", "color", "color_mode", "extrapolation"}: + setattr(dst_fcurve, propname, getattr(src_fcurve, propname)) + return dst_fcurve + + +def copy_keyframe(dst_fcurve: FCurve, src_keyframe: Keyframe) -> Keyframe: + """Copy a keyframe from one FCurve to the other.""" + + dst_keyframe = dst_fcurve.keyframe_points.insert( + src_keyframe.co.x, src_keyframe.co.y, options={'FAST'}, keyframe_type=src_keyframe.type + ) + + for propname in { + "amplitude", + "back", + "easing", + "handle_left", + "handle_left_type", + "handle_right", + "handle_right_type", + "interpolation", + "period", + }: + setattr(dst_keyframe, propname, getattr(src_keyframe, propname)) + dst_fcurve.update() + return dst_keyframe + + +def find_keyframe(fcurve: FCurve, frame: float) -> Optional[Keyframe]: + # Binary search adapted from https://pythonguides.com/python-binary-search/ + keyframes = fcurve.keyframe_points + low = 0 + high = len(keyframes) - 1 + mid = 0 + + # Accept any keyframe that's within 'epsilon' of the requested frame. + # This should account for rounding errors and the likes. + epsilon = 1e-4 + frame_lowerbound = frame - epsilon + frame_upperbound = frame + epsilon + while low <= high: + mid = (high + low) // 2 + keyframe = keyframes[mid] + if keyframe.co.x < frame_lowerbound: + low = mid + 1 + elif keyframe.co.x > frame_upperbound: + high = mid - 1 + else: + return keyframe + return None + + +def assign_tags_from_asset_browser(asset: Action, asset_browser: bpy.types.Area) -> None: + # TODO(Sybren): implement + return diff --git a/pose_library/pose_usage.py b/pose_library/pose_usage.py new file mode 100644 index 00000000..dc496d9c --- /dev/null +++ b/pose_library/pose_usage.py @@ -0,0 +1,185 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +""" +Pose Library - usage functions. +""" + +from typing import Set +import re + +from bpy.types import ( + Action, + Object, +) + + +def select_bones(arm_object: Object, action: Action, *, select: bool, flipped: bool) -> None: + pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]') + pose = arm_object.pose + + seen_bone_names: Set[str] = set() + + for fcurve in action.fcurves: + data_path: str = fcurve.data_path + match = pose_bone_re.match(data_path) + if not match: + continue + + bone_name = match.group(1) + + if bone_name in seen_bone_names: + continue + seen_bone_names.add(bone_name) + + if flipped: + bone_name = flip_side_name(bone_name) + + try: + pose_bone = pose.bones[bone_name] + except KeyError: + continue + + pose_bone.bone.select = select + + +_FLIP_SEPARATORS = set(". -_") + +# These are single-character replacements, others are handled differently. +_FLIP_REPLACEMENTS = { + "l": "r", + "L": "R", + "r": "l", + "R": "L", +} + + +def flip_side_name(to_flip: str) -> str: + """Flip left and right indicators in the name. + + Basically a Python implementation of BLI_string_flip_side_name. + + >>> flip_side_name('bone_L.004') + 'bone_R.004' + >>> flip_side_name('left_bone') + 'right_bone' + >>> flip_side_name('Left_bone') + 'Right_bone' + >>> flip_side_name('LEFT_bone') + 'RIGHT_bone' + >>> flip_side_name('some.bone-RIGHT.004') + 'some.bone-LEFT.004' + >>> flip_side_name('some.bone-right.004') + 'some.bone-left.004' + >>> flip_side_name('some.bone-Right.004') + 'some.bone-Left.004' + >>> flip_side_name('some.bone-LEFT.004') + 'some.bone-RIGHT.004' + >>> flip_side_name('some.bone-left.004') + 'some.bone-right.004' + >>> flip_side_name('some.bone-Left.004') + 'some.bone-Right.004' + >>> flip_side_name('.004') + '.004' + >>> flip_side_name('L.004') + 'R.004' + """ + import string + + if len(to_flip) < 3: + # we don't flip names like .R or .L + return to_flip + + # We first check the case with a .### extension, let's find the last period. + number = "" + replace = to_flip + if to_flip[-1] in string.digits: + try: + index = to_flip.rindex(".") + except ValueError: + pass + else: + if to_flip[index + 1] in string.digits: + # TODO(Sybren): this doesn't handle "bone.1abc2" correctly. + number = to_flip[index:] + replace = to_flip[:index] + + if not replace: + # Nothing left after the number, so no flips necessary. + return replace + number + + if len(replace) == 1: + replace = _FLIP_REPLACEMENTS.get(replace, replace) + return replace + number + + # First case; separator . - _ with extensions r R l L. + if replace[-2] in _FLIP_SEPARATORS and replace[-1] in _FLIP_REPLACEMENTS: + replace = replace[:-1] + _FLIP_REPLACEMENTS[replace[-1]] + return replace + number + + # Second case; beginning with r R l L, with separator after it. + if replace[1] in _FLIP_SEPARATORS and replace[0] in _FLIP_REPLACEMENTS: + replace = _FLIP_REPLACEMENTS[replace[0]] + replace[1:] + return replace + number + + lower = replace.lower() + prefix = suffix = "" + if lower.startswith("right"): + bit = replace[0:2] + if bit == "Ri": + prefix = "Left" + elif bit == "RI": + prefix = "LEFT" + else: + prefix = "left" + replace = replace[5:] + elif lower.startswith("left"): + bit = replace[0:2] + if bit == "Le": + prefix = "Right" + elif bit == "LE": + prefix = "RIGHT" + else: + prefix = "right" + replace = replace[4:] + elif lower.endswith("right"): + bit = replace[-5:-3] + if bit == "Ri": + suffix = "Left" + elif bit == "RI": + suffix = "LEFT" + else: + suffix = "left" + replace = replace[:-5] + elif lower.endswith("left"): + bit = replace[-4:-2] + if bit == "Le": + suffix = "Right" + elif bit == "LE": + suffix = "RIGHT" + else: + suffix = "right" + replace = replace[:-4] + + return prefix + replace + suffix + number + + +if __name__ == '__main__': + import doctest + + print(f"Test result: {doctest.testmod()}") -- cgit v1.2.3