Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender-addons.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSybren A. Stüvel <sybren@blender.org>2021-07-15 17:18:39 +0300
committerSybren A. Stüvel <sybren@blender.org>2021-07-15 17:18:39 +0300
commit93d3b1931cd920dfe2881dd2fbc9273c28cb093c (patch)
tree4b40116d68a91cd7bd103368fba9fc056d61150b /pose_library
parent8ee9bd5d633f2cb1006e108d47a49457e7561c84 (diff)
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.
Diffstat (limited to 'pose_library')
-rw-r--r--pose_library/__init__.py81
-rw-r--r--pose_library/asset_browser.py79
-rw-r--r--pose_library/conversion.py78
-rw-r--r--pose_library/functions.py94
-rw-r--r--pose_library/gui.py221
-rw-r--r--pose_library/keymaps.py43
-rw-r--r--pose_library/macros.py61
-rw-r--r--pose_library/operators.py439
-rw-r--r--pose_library/pose_creation.py437
-rw-r--r--pose_library/pose_usage.py185
10 files changed, 1718 insertions, 0 deletions
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()}")