Anim: create pose assets to different libraries

Similar to how brush assets are created and managed this
PR allows to export pose assets into a different library.
Because of this there is a limitation to this where each
asset is stored in a separate blend file.
This may be lifted in the future as there are planned changes in
the design phase: #122061

### Create Asset

Now available in the 3D viewport in the "Pose" menu: "Create Pose Asset".
The button in the Dope Sheet will now call this new operator as well.

Clicking either of those will open a popup in which you can:

* Choose the name of the asset, which library and catalog it goes into.
* Clicking "Create" will create a pose asset on disk in the given library.

It is possible to create files into an outside library or add it in the current file.
The latter option does a lot less since it basically just creates the
action and tags it as an asset.

If no Asset Shelf **AND** no Asset Browser is visible anywhere in Blender,
the Asset Shelf will be shown on the 3D viewport from which
the operator was called.

### Adjust Pose Asset

Right clicking a pose asset that has been created in the way described
before will have options to overwrite it.
Only the active object will be considered for updating a pose asset

Available Options (the latter 3 under the "Modify Pose Asset" submenu):
* Adjust Pose Asset: From the selected bones, update ONLY channels that
are also present in the asset. This is the default.
* Replace: Will completely replace the data in the Pose Asset from
the current selection
* Add: Adds the current selection to the Pose Asset. Any already existing
channels have their values updated
* Remove: Remove selected bones from the pose asset

Currently this refreshes the thumbnail. In the case of custom
thumbnails it might not be something want

### Deleting an existing Pose Asset

Right click on a Pose Asset and hit "Delete Pose Asset". Works in the shelf
and in the asset library. Doing so will pop up a confirmation dialog,
if confirming, the asset is gone forever. Deleting a local asset is basically the
same as clearing the asset. This is a bit confusing because you get
two options that basically do the same thing sometimes,
but "Delete" works in other cases as well.
I currently don't see a way around that.

Part of design #131840

Pull Request: https://projects.blender.org/blender/blender/pulls/132747
This commit is contained in:
Christoph Lendenfeld
2025-02-04 11:29:05 +01:00
committed by Christoph Lendenfeld
parent 007d46ef6d
commit 358a0479e8
16 changed files with 1151 additions and 173 deletions

View File

@@ -18,6 +18,16 @@ from bpy.types import (
from bl_ui_utils.layout import operator_context
class VIEW3D_MT_pose_modify(Menu):
bl_label = "Modify Pose Asset"
def draw(self, _context):
layout = self.layout
layout.operator("poselib.asset_modify", text="Replace").mode = "REPLACE"
layout.operator("poselib.asset_modify", text="Add Selected Bones").mode = "ADD"
layout.operator("poselib.asset_modify", text="Remove Selected Bones").mode = "REMOVE"
class PoseLibraryPanel:
@classmethod
def pose_library_panel_poll(cls, context: Context) -> bool:
@@ -57,6 +67,11 @@ class VIEW3D_AST_pose_library(bpy.types.AssetShelf):
props = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones")
props.select = False
layout.separator()
layout.operator("poselib.asset_modify", text="Adjust Pose Asset").mode = 'ADJUST'
layout.menu("VIEW3D_MT_pose_modify")
layout.operator("poselib.asset_delete")
layout.separator()
layout.operator("asset.open_containing_blend_file")
@@ -91,6 +106,12 @@ def pose_library_asset_browser_context_menu(self: UIList, context: Context) -> N
props = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones")
props.select = False
layout.separator()
layout.operator("poselib.asset_modify", text="Adjust Pose Asset").mode = 'ADJUST'
layout.menu("VIEW3D_MT_pose_modify")
with operator_context(layout, 'INVOKE_DEFAULT'):
layout.operator("poselib.asset_delete")
layout.separator()
layout.operator("asset.assign_action")
@@ -107,7 +128,7 @@ class DOPESHEET_PT_asset_panel(PoseLibraryPanel, Panel):
layout = self.layout
col = layout.column(align=True)
row = col.row(align=True)
row.operator("poselib.create_pose_asset").activate_new_action = True
row.operator("poselib.create_pose_asset")
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")
@@ -134,7 +155,7 @@ class ASSETBROWSER_MT_asset(Menu):
layout.operator("poselib.paste_asset", icon='PASTEDOWN')
layout.separator()
layout.operator("poselib.create_pose_asset").activate_new_action = False
layout.operator("poselib.create_pose_asset")
# Messagebus subscription to monitor asset library changes.
@@ -181,6 +202,7 @@ def _on_blendfile_load_post(none, other_none) -> None:
classes = (
DOPESHEET_PT_asset_panel,
ASSETBROWSER_MT_asset,
VIEW3D_MT_pose_modify,
VIEW3D_AST_pose_library,
)

View File

@@ -7,7 +7,7 @@ Pose Library - operators.
"""
from pathlib import Path
from typing import Optional, Set
from typing import Set
_need_reload = "functions" in locals()
from . import asset_browser, functions, pose_creation, pose_usage
@@ -22,7 +22,7 @@ if _need_reload:
import bpy
from bpy.props import BoolProperty, StringProperty
from bpy.props import BoolProperty
from bpy.types import (
Action,
AssetRepresentation,
@@ -46,109 +46,6 @@ class PoseAssetCreator:
)
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. "
"The asset will be stored in the current blend file"
)
bl_options = {'REGISTER', 'UNDO'}
pose_name: StringProperty(name="Pose Name") # type: ignore
activate_new_action: BoolProperty(name="Activate New Action", default=True) # type: ignore
@classmethod
def poll(cls, context: Context) -> bool:
if context.object is None or context.object.mode != "POSE":
# The operator assumes pose mode, so that bone selection is visible.
cls.poll_message_set("An active armature object in pose mode is needed")
return False
# Make sure that if there is an asset browser open, the artist can see the newly created pose asset.
asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_from_context(context)
if not asset_browse_area:
# No asset browser is visible, so there also aren't any expectations
# that this asset will be visible.
return True
asset_space_params = asset_browser.params(asset_browse_area)
if asset_space_params.asset_library_reference != 'LOCAL':
cls.poll_message_set("Asset Browser must be set to the Current File library")
return False
return True
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
# The `pass` on `AttributeError` and `IndexError` is just for while
# slotted actions are still behind an experimental flag, and thus the
# `slots` attribute may not exist when Blender is built without
# experimental features or it might be empty when the flag is simply
# disabled. The `try/except` here can be removed when slotted actions
# are taken out of experimental.
try:
anim_data.action_slot = asset.slots[0]
except (AttributeError, IndexError):
pass
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_from_context(context)
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_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'}, tip_("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"
@@ -461,7 +358,6 @@ classes = (
POSELIB_OT_convert_old_poselib,
POSELIB_OT_convert_old_object_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,

View File

@@ -4151,6 +4151,9 @@ class VIEW3D_MT_pose(Menu):
layout.menu("VIEW3D_MT_pose_showhide")
layout.menu("VIEW3D_MT_bone_options_toggle", text="Bone Settings")
layout.separator()
layout.operator("POSELIB.create_pose_asset")
class VIEW3D_MT_pose_transform(Menu):
bl_label = "Clear Transform"

View File

@@ -13,6 +13,11 @@
#include "DNA_action_types.h"
#include "RNA_types.hh"
#include "RNA_path.hh"
struct PointerRNA;
struct PropertyRNA;
namespace blender::animrig {
/** Get the values of the given property. Casts non-float properties to float. */
@@ -21,4 +26,10 @@ Vector<float> get_rna_values(PointerRNA *ptr, PropertyRNA *prop);
/** Get the rna path for the given rotation mode. */
StringRef get_rotation_mode_path(eRotationModes rotation_mode);
/**
* Returns a Vector of ID properties on the given pointer that can be animated. Not all pointer
* types are supported. Unsupported pointer types will return an empty vector.
*/
Vector<RNAPath> get_keyable_id_property_paths(const PointerRNA &ptr);
} // namespace blender::animrig

View File

@@ -6,10 +6,19 @@
* \ingroup animrig
*/
#include <fmt/format.h>
#include "ANIM_rna.hh"
#include "BLI_listbase.h"
#include "BLI_string.h"
#include "BLI_vector.hh"
#include "DNA_action_types.h"
#include "DNA_object_types.h"
#include "RNA_access.hh"
#include "RNA_path.hh"
#include "RNA_prototypes.hh"
namespace blender::animrig {
@@ -81,4 +90,85 @@ StringRef get_rotation_mode_path(const eRotationModes rotation_mode)
return "rotation_euler";
}
}
static bool is_idproperty_keyable(const IDProperty *id_prop, PointerRNA *ptr, PropertyRNA *prop)
{
/* While you can cast the IDProperty* to a PropertyRNA* and pass it to the RNA_* functions, this
* does not work because it will not have the right flags set. Instead the resolved
* PointerRNA and PropertyRNA need to be passed. */
if (!RNA_property_anim_editable(ptr, prop)) {
return false;
}
if (ELEM(id_prop->type,
eIDPropertyType::IDP_BOOLEAN,
eIDPropertyType::IDP_INT,
eIDPropertyType::IDP_FLOAT,
eIDPropertyType::IDP_DOUBLE))
{
return true;
}
if (id_prop->type == eIDPropertyType::IDP_ARRAY) {
if (ELEM(id_prop->subtype,
eIDPropertyType::IDP_BOOLEAN,
eIDPropertyType::IDP_INT,
eIDPropertyType::IDP_FLOAT,
eIDPropertyType::IDP_DOUBLE))
{
return true;
}
}
return false;
}
Vector<RNAPath> get_keyable_id_property_paths(const PointerRNA &ptr)
{
IDProperty *properties;
if (ptr.type == &RNA_PoseBone) {
const bPoseChannel *pchan = static_cast<bPoseChannel *>(ptr.data);
properties = pchan->prop;
}
else if (ptr.type == &RNA_Object) {
const Object *ob = static_cast<Object *>(ptr.data);
properties = ob->id.properties;
}
else {
/* Pointer type not supported. */
return {};
}
if (!properties) {
return {};
}
blender::Vector<RNAPath> paths;
LISTBASE_FOREACH (const IDProperty *, id_prop, &properties->data.group) {
PointerRNA resolved_ptr;
PropertyRNA *resolved_prop;
std::string path = id_prop->name;
/* Resolving the path twice, once as RNA property (without brackets, `"propname"`),
* and once as ID property (with brackets, `["propname"]`).
* This is required to support IDProperties that have been defined as part of an add-on.
* Those need to be animated through an RNA path without the brackets. */
bool is_resolved = RNA_path_resolve_property(
&ptr, path.c_str(), &resolved_ptr, &resolved_prop);
if (!is_resolved) {
char name_escaped[MAX_IDPROP_NAME * 2];
BLI_str_escape(name_escaped, id_prop->name, sizeof(name_escaped));
path = fmt::format("[\"{}\"]", name_escaped);
is_resolved = RNA_path_resolve_property(&ptr, path.c_str(), &resolved_ptr, &resolved_prop);
}
if (!is_resolved) {
continue;
}
if (is_idproperty_keyable(id_prop, &resolved_ptr, resolved_prop)) {
paths.append({path});
}
}
return paths;
}
} // namespace blender::animrig

View File

@@ -3,8 +3,10 @@
# SPDX-License-Identifier: GPL-2.0-or-later
set(INC
../asset
../include
../../makesrna
../../asset_system
../../../../extern/fmtlib/include
# RNA_prototypes.hh
${CMAKE_BINARY_DIR}/source/blender/makesrna
@@ -14,6 +16,7 @@ set(INC_SYS
)
set(SRC
anim_asset_ops.cc
anim_channels_defines.cc
anim_channels_edit.cc
anim_deps.cc

View File

@@ -0,0 +1,803 @@
/* SPDX-FileCopyrightText: 2025 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "BKE_asset.hh"
#include "BKE_asset_edit.hh"
#include "BKE_context.hh"
#include "BKE_fcurve.hh"
#include "BKE_global.hh"
#include "BKE_icons.h"
#include "BKE_lib_id.hh"
#include "BKE_preferences.h"
#include "BKE_report.hh"
#include "BKE_screen.hh"
#include "WM_api.hh"
#include "RNA_access.hh"
#include "RNA_define.hh"
#include "RNA_prototypes.hh"
#include "ED_asset.hh"
#include "ED_asset_library.hh"
#include "ED_asset_list.hh"
#include "ED_asset_mark_clear.hh"
#include "ED_asset_menu_utils.hh"
#include "ED_asset_shelf.hh"
#include "ED_fileselect.hh"
#include "ED_screen.hh"
#include "UI_interface_icons.hh"
#include "UI_resources.hh"
#include "BLT_translation.hh"
#include "ANIM_action.hh"
#include "ANIM_action_iterators.hh"
#include "ANIM_bone_collections.hh"
#include "ANIM_keyframing.hh"
#include "ANIM_pose.hh"
#include "ANIM_rna.hh"
#include "AS_asset_catalog.hh"
#include "AS_asset_catalog_tree.hh"
#include "AS_asset_library.hh"
#include "AS_asset_representation.hh"
#include "anim_intern.hh"
namespace blender::ed::animrig {
static const EnumPropertyItem *rna_asset_library_reference_itemf(bContext * /*C*/,
PointerRNA * /*ptr*/,
PropertyRNA * /*prop*/,
bool *r_free)
{
const EnumPropertyItem *items = blender::ed::asset::library_reference_to_rna_enum_itemf(false,
true);
*r_free = true;
BLI_assert(items != nullptr);
return items;
}
static Vector<RNAPath> construct_pose_rna_paths(const PointerRNA &bone_pointer)
{
BLI_assert(bone_pointer.type == &RNA_PoseBone);
blender::Vector<RNAPath> paths;
paths.append({"location"});
paths.append({"scale"});
bPoseChannel *pose_bone = static_cast<bPoseChannel *>(bone_pointer.data);
switch (pose_bone->rotmode) {
case ROT_MODE_QUAT:
paths.append({"rotation_quaternion"});
break;
case ROT_MODE_AXISANGLE:
paths.append({"rotation_axis_angle"});
break;
case ROT_MODE_XYZ:
case ROT_MODE_XZY:
case ROT_MODE_YXZ:
case ROT_MODE_YZX:
case ROT_MODE_ZXY:
case ROT_MODE_ZYX:
paths.append({"rotation_euler"});
default:
break;
}
paths.extend({{"bbone_curveinx"},
{"bbone_curveoutx"},
{"bbone_curveinz"},
{"bbone_curveoutz"},
{"bbone_rollin"},
{"bbone_rollout"},
{"bbone_scalein"},
{"bbone_scaleout"},
{"bbone_easein"},
{"bbone_easeout"}});
paths.extend(blender::animrig::get_keyable_id_property_paths(bone_pointer));
return paths;
}
static blender::animrig::Action &extract_pose(Main &bmain,
const blender::Span<Object *> pose_objects)
{
/* This currently only looks at the pose and not other things that could go onto different
* slots on the same action. */
using namespace blender::animrig;
Action &action = action_add(bmain, "pose_create");
Layer &layer = action.layer_add("pose");
Strip &strip = layer.strip_add(action, Strip::Type::Keyframe);
StripKeyframeData &strip_data = strip.data<StripKeyframeData>(action);
const KeyframeSettings key_settings = {BEZT_KEYTYPE_KEYFRAME, HD_AUTO, BEZT_IPO_BEZ};
for (Object *pose_object : pose_objects) {
BLI_assert(pose_object->pose);
Slot &slot = action.slot_add_for_id(pose_object->id);
const bArmature *armature = static_cast<bArmature *>(pose_object->data);
LISTBASE_FOREACH (bPoseChannel *, pose_bone, &pose_object->pose->chanbase) {
if (!(pose_bone->bone->flag & BONE_SELECTED) ||
!ANIM_bone_is_visible(armature, pose_bone->bone))
{
continue;
}
PointerRNA bone_pointer = RNA_pointer_create_discrete(
&pose_object->id, &RNA_PoseBone, pose_bone);
Vector<RNAPath> rna_paths = construct_pose_rna_paths(bone_pointer);
for (const RNAPath &rna_path : rna_paths) {
PointerRNA resolved_pointer;
PropertyRNA *resolved_property;
if (!RNA_path_resolve(
&bone_pointer, rna_path.path.c_str(), &resolved_pointer, &resolved_property))
{
continue;
}
const Vector<float> values = blender::animrig::get_rna_values(&resolved_pointer,
resolved_property);
const std::optional<std::string> rna_path_id_to_prop = RNA_path_from_ID_to_property(
&resolved_pointer, resolved_property);
if (!rna_path_id_to_prop.has_value()) {
continue;
}
for (const int i : values.index_range()) {
strip_data.keyframe_insert(
&bmain, slot, {rna_path_id_to_prop.value(), i}, {1, values[i]}, key_settings);
}
}
}
}
return action;
}
/* Check that the newly created asset is visible SOMEWHERE in Blender. If not already visible,
* open the asset shelf on the current 3D view. The reason for not always doing that is that it
* might be annoying in case you have 2 3D viewports open, but you want the asset shelf on only one
* of them, or you work out of the asset browser.*/
static void ensure_asset_ui_visible(bContext &C)
{
ScrArea *current_area = CTX_wm_area(&C);
if (!current_area || current_area->type->spaceid != SPACE_VIEW3D) {
/* Opening the asset shelf will only work from the 3D viewport. */
return;
}
wmWindowManager *wm = CTX_wm_manager(&C);
LISTBASE_FOREACH (wmWindow *, win, &wm->windows) {
const bScreen *screen = WM_window_get_active_screen(win);
LISTBASE_FOREACH (ScrArea *, area, &screen->areabase) {
if (area->type->spaceid == SPACE_FILE) {
SpaceFile *sfile = reinterpret_cast<SpaceFile *>(area->spacedata.first);
if (sfile->browse_mode == FILE_BROWSE_MODE_ASSETS) {
/* Asset Browser is open. */
return;
}
continue;
}
const ARegion *shelf_region = BKE_area_find_region_type(area, RGN_TYPE_ASSET_SHELF);
if (!shelf_region) {
continue;
}
if (shelf_region->runtime->visible) {
/* A visible asset shelf was found. */
return;
}
}
}
/* At this point, no asset shelf or asset browser was visible anywhere. */
ARegion *shelf_region = BKE_area_find_region_type(current_area, RGN_TYPE_ASSET_SHELF);
if (!shelf_region) {
return;
}
shelf_region->flag &= ~RGN_FLAG_HIDDEN;
ED_region_visibility_change_update(&C, CTX_wm_area(&C), shelf_region);
}
static blender::Vector<Object *> get_selected_pose_objects(bContext *C)
{
blender::Vector<PointerRNA> selected_objects;
CTX_data_selected_objects(C, &selected_objects);
blender::Vector<Object *> selected_pose_objects;
for (const PointerRNA &ptr : selected_objects) {
Object *object = reinterpret_cast<Object *>(ptr.owner_id);
if (!object->pose) {
continue;
}
selected_pose_objects.append(object);
}
Object *active_object = CTX_data_active_object(C);
/* The active object may not be selected, it should be added because you can still switch to pose
* mode. */
if (active_object && active_object->pose && !selected_pose_objects.contains(active_object)) {
selected_pose_objects.append(active_object);
}
return selected_pose_objects;
}
static int create_pose_asset_local(bContext *C,
wmOperator *op,
const StringRefNull name,
const AssetLibraryReference lib_ref)
{
blender::Vector<Object *> selected_pose_objects = get_selected_pose_objects(C);
if (selected_pose_objects.is_empty()) {
return OPERATOR_CANCELLED;
}
Main *bmain = CTX_data_main(C);
/* Extract the pose into a new action. */
blender::animrig::Action &pose_action = extract_pose(*bmain, selected_pose_objects);
asset::mark_id(&pose_action.id);
if (!G.background) {
asset::generate_preview(C, &pose_action.id);
}
BKE_id_rename(*bmain, pose_action.id, name);
/* Add asset to catalog. */
char catalog_path[MAX_NAME];
RNA_string_get(op->ptr, "catalog_path", catalog_path);
AssetMetaData &meta_data = *pose_action.id.asset_data;
asset_system::AssetLibrary *library = AS_asset_library_load(bmain, lib_ref);
/* I (christoph) don't know if a local library can fail to load. Just being defensive here */
BLI_assert(library);
if (catalog_path[0] && library) {
const asset_system::AssetCatalog &catalog = asset::library_ensure_catalogs_in_path(
*library, catalog_path);
BKE_asset_metadata_catalog_id_set(&meta_data, catalog.catalog_id, catalog.simple_name.c_str());
}
ensure_asset_ui_visible(*C);
asset::shelf::show_catalog_in_visible_shelves(*C, catalog_path);
asset::refresh_asset_library(C, lib_ref);
WM_main_add_notifier(NC_ASSET | ND_ASSET_LIST | NA_ADDED, nullptr);
return OPERATOR_FINISHED;
}
static int create_pose_asset_user_library(bContext *C,
wmOperator *op,
const char name[MAX_NAME],
const AssetLibraryReference lib_ref)
{
BLI_assert(lib_ref.type == ASSET_LIBRARY_CUSTOM);
Main *bmain = CTX_data_main(C);
const bUserAssetLibrary *user_library = BKE_preferences_asset_library_find_index(
&U, lib_ref.custom_library_index);
BLI_assert_msg(user_library, "The passed lib_ref is expected to be a user library");
if (!user_library) {
return OPERATOR_CANCELLED;
}
asset_system::AssetLibrary *library = AS_asset_library_load(bmain, lib_ref);
if (!library) {
BKE_report(op->reports, RPT_ERROR, "Failed to load asset library");
return OPERATOR_CANCELLED;
}
blender::Vector<Object *> selected_pose_objects = get_selected_pose_objects(C);
if (selected_pose_objects.is_empty()) {
return OPERATOR_CANCELLED;
}
/* Temporary action in current main that will be exported and later deleted. */
blender::animrig::Action &pose_action = extract_pose(*bmain, selected_pose_objects);
asset::mark_id(&pose_action.id);
if (!G.background) {
asset::generate_preview(C, &pose_action.id);
}
/* Add asset to catalog. */
char catalog_path[MAX_NAME];
RNA_string_get(op->ptr, "catalog_path", catalog_path);
AssetMetaData &meta_data = *pose_action.id.asset_data;
if (catalog_path[0]) {
const asset_system::AssetCatalog &catalog = asset::library_ensure_catalogs_in_path(
*library, catalog_path);
BKE_asset_metadata_catalog_id_set(&meta_data, catalog.catalog_id, catalog.simple_name.c_str());
}
AssetWeakReference pose_asset_reference;
const std::optional<std::string> final_full_asset_filepath = bke::asset_edit_id_save_as(
*bmain, pose_action.id, name, *user_library, pose_asset_reference, *op->reports);
library->catalog_service().write_to_disk(*final_full_asset_filepath);
ensure_asset_ui_visible(*C);
asset::shelf::show_catalog_in_visible_shelves(*C, catalog_path);
BKE_id_free(bmain, &pose_action.id);
asset::refresh_asset_library(C, lib_ref);
WM_main_add_notifier(NC_ASSET | ND_ASSET_LIST | NA_ADDED, nullptr);
return OPERATOR_FINISHED;
}
static int pose_asset_create_exec(bContext *C, wmOperator *op)
{
char name[MAX_NAME] = "";
PropertyRNA *name_prop = RNA_struct_find_property(op->ptr, "pose_name");
if (RNA_property_is_set(op->ptr, name_prop)) {
RNA_property_string_get(op->ptr, name_prop, name);
}
if (name[0] == '\0') {
BKE_report(op->reports, RPT_ERROR, "No name set");
return OPERATOR_CANCELLED;
}
const int enum_value = RNA_enum_get(op->ptr, "asset_library_reference");
const AssetLibraryReference lib_ref = asset::library_reference_from_enum_value(enum_value);
switch (lib_ref.type) {
case ASSET_LIBRARY_LOCAL:
return create_pose_asset_local(C, op, name, lib_ref);
case ASSET_LIBRARY_CUSTOM:
return create_pose_asset_user_library(C, op, name, lib_ref);
default:
/* Only local and custom libraries should be exposed in the enum. */
BLI_assert_unreachable();
break;
}
BKE_report(op->reports, RPT_ERROR, "Unexpected library type. Failed to create pose asset");
return OPERATOR_FINISHED;
}
static int pose_asset_create_invoke(bContext *C, wmOperator *op, const wmEvent * /*event*/)
{
/* If the library isn't saved from the operator's last execution, use the first library. */
if (!RNA_struct_property_is_set_ex(op->ptr, "asset_library_reference", false)) {
const AssetLibraryReference first_library = asset::user_library_to_library_ref(
*static_cast<const bUserAssetLibrary *>(U.asset_libraries.first));
RNA_enum_set(op->ptr,
"asset_library_reference",
asset::library_reference_to_enum_value(&first_library));
}
return WM_operator_props_dialog_popup(C, op, 400, std::nullopt, IFACE_("Create"));
}
static bool pose_asset_create_poll(bContext *C)
{
if (!ED_operator_posemode_context(C)) {
return false;
}
return true;
}
static void visit_library_prop_catalogs_catalog_for_search_fn(
const bContext *C,
PointerRNA *ptr,
PropertyRNA * /*prop*/,
const char *edit_text,
FunctionRef<void(StringPropertySearchVisitParams)> visit_fn)
{
const int enum_value = RNA_enum_get(ptr, "asset_library_reference");
const AssetLibraryReference lib_ref = asset::library_reference_from_enum_value(enum_value);
asset::visit_library_catalogs_catalog_for_search(
*CTX_data_main(C), lib_ref, edit_text, visit_fn);
}
void POSELIB_OT_create_pose_asset(wmOperatorType *ot)
{
ot->name = "Create Pose Asset";
ot->description = "Create a new asset from the selection in the scene";
ot->idname = "POSELIB_OT_create_pose_asset";
ot->exec = pose_asset_create_exec;
ot->invoke = pose_asset_create_invoke;
ot->poll = pose_asset_create_poll;
ot->prop = RNA_def_string(
ot->srna, "pose_name", nullptr, MAX_NAME, "Pose Name", "Name for the new pose asset");
PropertyRNA *prop = RNA_def_property(ot->srna, "asset_library_reference", PROP_ENUM, PROP_NONE);
RNA_def_enum_funcs(prop, rna_asset_library_reference_itemf);
RNA_def_property_ui_text(prop, "Library", "Asset library used to store the new pose");
prop = RNA_def_string(
ot->srna, "catalog_path", nullptr, MAX_NAME, "Catalog", "Catalog to use for the new asset");
RNA_def_property_string_search_func_runtime(
prop, visit_library_prop_catalogs_catalog_for_search_fn, PROP_STRING_SEARCH_SUGGESTION);
/* This property is just kept to have backwards compatibility and has no functionality. It should
* be removed in the 5.0 release. */
prop = RNA_def_boolean(ot->srna,
"activate_new_action",
false,
"Activate New Action",
"This property is deprecated and will be removed in the future");
RNA_def_property_flag(prop, PropertyFlag(PROP_HIDDEN | PROP_SKIP_SAVE));
}
enum AssetModifyMode {
MODIFY_ADJUST = 0,
MODIFY_REPLACE,
MODIFY_ADD,
MODIFY_REMOVE,
};
static const EnumPropertyItem prop_asset_overwrite_modes[] = {
{MODIFY_ADJUST,
"ADJUST",
0,
"Adjust",
"Update existing channels in the pose asset but don't remove or add any channels"},
{MODIFY_REPLACE,
"REPLACE",
0,
"Replace with Selection",
"Completely replace all channels in the pose asset with the current selection"},
{MODIFY_ADD,
"ADD",
0,
"Add Selected Bones",
"Add channels of the selection to the pose asset. Existing channels will be updated"},
{MODIFY_REMOVE,
"REMOVE",
0,
"Remove Selected Bones",
"Remove channels of the selection from the pose asset"},
{0, nullptr, 0, nullptr, nullptr},
};
/* Gets the selected asset from the given `bContext`. If the asset is an action, returns a pointer
* to that action, else returns a nullptr. */
static bAction *get_action_of_selected_asset(bContext *C)
{
const blender::asset_system::AssetRepresentation *asset = CTX_wm_asset(C);
if (!asset) {
return nullptr;
}
if (asset->get_id_type() != ID_AC) {
return nullptr;
}
AssetWeakReference asset_reference = asset->make_weak_reference();
Main *bmain = CTX_data_main(C);
return reinterpret_cast<bAction *>(
bke::asset_edit_id_from_weak_reference(*bmain, ID_AC, asset_reference));
}
struct PathValue {
RNAPath rna_path;
float value;
};
static Vector<PathValue> generate_path_values(Object &pose_object)
{
Vector<PathValue> path_values;
const bArmature *armature = static_cast<bArmature *>(pose_object.data);
LISTBASE_FOREACH (bPoseChannel *, pose_bone, &pose_object.pose->chanbase) {
if (!(pose_bone->bone->flag & BONE_SELECTED) ||
!ANIM_bone_is_visible(armature, pose_bone->bone))
{
continue;
}
PointerRNA bone_pointer = RNA_pointer_create_discrete(
&pose_object.id, &RNA_PoseBone, pose_bone);
Vector<RNAPath> rna_paths = construct_pose_rna_paths(bone_pointer);
for (RNAPath &rna_path : rna_paths) {
PointerRNA resolved_pointer;
PropertyRNA *resolved_property;
if (!RNA_path_resolve(
&bone_pointer, rna_path.path.c_str(), &resolved_pointer, &resolved_property))
{
continue;
}
const std::optional<std::string> rna_path_id_to_prop = RNA_path_from_ID_to_property(
&resolved_pointer, resolved_property);
if (!rna_path_id_to_prop.has_value()) {
continue;
}
Vector<float> values = blender::animrig::get_rna_values(&resolved_pointer,
resolved_property);
int i = 0;
for (const float value : values) {
RNAPath path = {rna_path_id_to_prop.value(), std::nullopt, i};
path_values.append({path, value});
i++;
}
}
}
return path_values;
}
static inline void replace_pose_key(Main &bmain,
blender::animrig::StripKeyframeData &strip_data,
const blender::animrig::Slot &slot,
const float2 time_value,
const blender::animrig::FCurveDescriptor fcurve_descriptor)
{
using namespace blender::animrig;
Channelbag &channelbag = strip_data.channelbag_for_slot_ensure(slot);
FCurve &fcurve = channelbag.fcurve_ensure(&bmain, fcurve_descriptor);
/* Clearing all keys beforehand in case the pose was not defined on frame defined in
* `time_value`. */
BKE_fcurve_delete_keys_all(&fcurve);
const KeyframeSettings key_settings = {BEZT_KEYTYPE_KEYFRAME, HD_AUTO, BEZT_IPO_BEZ};
insert_vert_fcurve(&fcurve, time_value, key_settings, INSERTKEY_NOFLAGS);
}
static void update_pose_action_from_scene(Main *bmain,
blender::animrig::Action &pose_action,
Object &pose_object,
const AssetModifyMode mode)
{
using namespace blender::animrig;
/* The frame on which an FCurve has a key to define a pose. */
constexpr int pose_frame = 1;
if (pose_action.slot_array_num < 1) {
/* All actions should have slots at this point. */
BLI_assert_unreachable();
return;
}
Slot &slot = blender::animrig::get_best_pose_slot_for_id(pose_object.id, pose_action);
BLI_assert(pose_action.strip_keyframe_data().size() == 1);
BLI_assert(pose_action.layers().size() == 1);
StripKeyframeData *strip_data = pose_action.strip_keyframe_data()[0];
Vector<PathValue> path_values = generate_path_values(pose_object);
Set<RNAPath> existing_paths;
foreach_fcurve_in_action_slot(pose_action, slot.handle, [&](FCurve &fcurve) {
existing_paths.add({fcurve.rna_path, std::nullopt, fcurve.array_index});
});
switch (mode) {
case MODIFY_ADJUST: {
for (const PathValue &path_value : path_values) {
/* Only updating existing channels. */
if (existing_paths.contains(path_value.rna_path)) {
replace_pose_key(*bmain,
*strip_data,
slot,
{pose_frame, path_value.value},
{path_value.rna_path.path, path_value.rna_path.index.value()});
}
}
break;
}
case MODIFY_ADD: {
for (const PathValue &path_value : path_values) {
replace_pose_key(*bmain,
*strip_data,
slot,
{pose_frame, path_value.value},
{path_value.rna_path.path, path_value.rna_path.index.value()});
}
break;
}
case MODIFY_REPLACE: {
Channelbag *channelbag = strip_data->channelbag_for_slot(slot.handle);
if (!channelbag) {
/* No channels to remove. */
return;
}
channelbag->fcurves_clear();
for (const PathValue &path_value : path_values) {
replace_pose_key(*bmain,
*strip_data,
slot,
{pose_frame, path_value.value},
{path_value.rna_path.path, path_value.rna_path.index.value()});
}
break;
}
case MODIFY_REMOVE: {
Channelbag *channelbag = strip_data->channelbag_for_slot(slot.handle);
if (!channelbag) {
/* No channels to remove. */
return;
}
Map<RNAPath, FCurve *> fcurve_map;
foreach_fcurve_in_action_slot(
pose_action, pose_action.slot_array[0]->handle, [&](FCurve &fcurve) {
fcurve_map.add({fcurve.rna_path, std::nullopt, fcurve.array_index}, &fcurve);
});
for (const PathValue &path_value : path_values) {
if (existing_paths.contains(path_value.rna_path)) {
FCurve *fcurve = fcurve_map.lookup(path_value.rna_path);
channelbag->fcurve_remove(*fcurve);
}
}
break;
}
}
}
static void refresh_asset_library(bContext *C)
{
const blender::asset_system::AssetRepresentation *asset = CTX_wm_asset(C);
AssetWeakReference asset_reference = asset->make_weak_reference();
bUserAssetLibrary *library = BKE_preferences_asset_library_find_by_name(
&U, asset_reference.asset_library_identifier);
asset::refresh_asset_library(C, *library);
}
static int pose_asset_modify_exec(bContext *C, wmOperator *op)
{
bAction *action = get_action_of_selected_asset(C);
BLI_assert_msg(action, "Poll should have checked action exists");
Main *bmain = CTX_data_main(C);
Object *pose_object = CTX_data_active_object(C);
if (!pose_object || !pose_object->pose) {
return OPERATOR_CANCELLED;
}
AssetModifyMode mode = AssetModifyMode(RNA_enum_get(op->ptr, "mode"));
update_pose_action_from_scene(bmain, action->wrap(), *pose_object, mode);
if (!G.background) {
asset::generate_preview(C, &action->id);
}
if (ID_IS_LINKED(action)) {
/* Not needed for local assets. */
bke::asset_edit_id_save(*bmain, action->id, *op->reports);
}
refresh_asset_library(C);
WM_main_add_notifier(NC_ASSET | ND_ASSET_LIST | NA_EDITED, nullptr);
return OPERATOR_FINISHED;
}
static bool pose_asset_modify_poll(bContext *C)
{
if (!ED_operator_posemode_context(C)) {
CTX_wm_operator_poll_msg_set(C, "Pose assets can only be modified from Pose Mode");
return false;
}
bAction *action = get_action_of_selected_asset(C);
if (!action) {
return false;
}
if (!ID_IS_LINKED(action)) {
return true;
}
if (!bke::asset_edit_id_is_editable(action->id)) {
CTX_wm_operator_poll_msg_set(C, "Action is not editable");
return false;
}
if (!bke::asset_edit_id_is_writable(action->id)) {
CTX_wm_operator_poll_msg_set(C, "Asset blend file is not editable");
return false;
}
return true;
}
static std::string pose_asset_modify_description(bContext * /* C */,
wmOperatorType * /* ot */,
PointerRNA *ptr)
{
const int mode = RNA_enum_get(ptr, "mode");
return std::string(prop_asset_overwrite_modes[mode].description);
}
/* Calling it overwrite instead of save because we aren't actually saving an opened asset. */
void POSELIB_OT_asset_modify(wmOperatorType *ot)
{
ot->name = "Modify Pose Asset";
ot->description =
"Update the selected pose asset in the asset library from the currently selected bones. The "
"mode defines how the asset is updated";
ot->idname = "POSELIB_OT_asset_modify";
ot->exec = pose_asset_modify_exec;
ot->poll = pose_asset_modify_poll;
ot->get_description = pose_asset_modify_description;
RNA_def_enum(ot->srna,
"mode",
prop_asset_overwrite_modes,
MODIFY_ADJUST,
"Overwrite Mode",
"Specify which parts of the pose asset are overwritten");
}
static bool pose_asset_delete_poll(bContext *C)
{
bAction *action = get_action_of_selected_asset(C);
if (!action) {
return false;
}
if (!ID_IS_LINKED(action)) {
return true;
}
if (!bke::asset_edit_id_is_editable(action->id)) {
CTX_wm_operator_poll_msg_set(C, "Action is not editable");
return false;
}
if (!bke::asset_edit_id_is_writable(action->id)) {
CTX_wm_operator_poll_msg_set(C, "Asset blend file is not editable");
return false;
}
return true;
}
static int pose_asset_delete_exec(bContext *C, wmOperator *op)
{
bAction *action = get_action_of_selected_asset(C);
if (!action) {
return OPERATOR_CANCELLED;
}
const blender::asset_system::AssetRepresentation *asset = CTX_wm_asset(C);
AssetWeakReference asset_reference = asset->make_weak_reference();
bUserAssetLibrary *library = BKE_preferences_asset_library_find_by_name(
&U, asset_reference.asset_library_identifier);
if (ID_IS_LINKED(action)) {
bke::asset_edit_id_delete(*CTX_data_main(C), action->id, *op->reports);
}
else {
asset::clear_id(&action->id);
}
asset::refresh_asset_library(C, *library);
WM_main_add_notifier(NC_ASSET | ND_ASSET_LIST | NA_REMOVED, nullptr);
return OPERATOR_FINISHED;
}
static int pose_asset_delete_invoke(bContext *C, wmOperator *op, const wmEvent * /*event*/)
{
bAction *action = get_action_of_selected_asset(C);
return WM_operator_confirm_ex(
C,
op,
IFACE_("Delete Pose Asset"),
ID_IS_LINKED(action) ?
IFACE_("Permanently delete pose asset blend file? This cannot be undone.") :
IFACE_("The asset is local to the file. Deleting it will just clear the asset status."),
IFACE_("Delete"),
ALERT_ICON_WARNING,
false);
}
void POSELIB_OT_asset_delete(wmOperatorType *ot)
{
ot->name = "Delete Pose Asset";
ot->description = "Delete the selected Pose Asset";
ot->idname = "POSELIB_OT_asset_delete";
ot->poll = pose_asset_delete_poll;
ot->invoke = pose_asset_delete_invoke;
ot->exec = pose_asset_delete_exec;
}
} // namespace blender::ed::animrig

View File

@@ -98,3 +98,17 @@ void ANIM_OT_copy_driver_button(wmOperatorType *ot);
void ANIM_OT_paste_driver_button(wmOperatorType *ot);
/** \} */
/* -------------------------------------------------------------------- */
/** \name Pose Asset operators
* \{ */
namespace blender::ed::animrig {
void POSELIB_OT_create_pose_asset(wmOperatorType *ot);
void POSELIB_OT_asset_modify(wmOperatorType *ot);
void POSELIB_OT_asset_delete(wmOperatorType *ot);
void POSELIB_OT_screenshot_preview(wmOperatorType *ot);
} // namespace blender::ed::animrig
/** \} */

View File

@@ -957,6 +957,10 @@ void ED_operatortypes_anim()
WM_operatortype_append(ANIM_OT_convert_legacy_action);
WM_operatortype_append(ANIM_OT_merge_animation);
WM_operatortype_append(blender::ed::animrig::POSELIB_OT_create_pose_asset);
WM_operatortype_append(blender::ed::animrig::POSELIB_OT_asset_modify);
WM_operatortype_append(blender::ed::animrig::POSELIB_OT_asset_delete);
}
void ED_keymap_anim(wmKeyConfig *keyconf)

View File

@@ -17,10 +17,8 @@
#include "BLT_translation.hh"
#include "DNA_ID.h"
#include "DNA_action_types.h"
#include "DNA_anim_types.h"
#include "DNA_armature_types.h"
#include "DNA_object_types.h"
#include "DNA_scene_types.h"
#include "BKE_action.hh"
@@ -63,7 +61,6 @@
#include "RNA_access.hh"
#include "RNA_define.hh"
#include "RNA_enum_types.hh"
#include "RNA_path.hh"
#include "RNA_prototypes.hh"
#include "anim_intern.hh"
@@ -227,53 +224,18 @@ static int insert_key_with_keyingset(bContext *C, wmOperator *op, KeyingSet *ks)
return OPERATOR_FINISHED;
}
static bool is_idproperty_keyable(IDProperty *id_prop, PointerRNA *ptr, PropertyRNA *prop)
{
/* While you can cast the IDProperty* to a PropertyRNA* and pass it to the functions, this
* does not work because it will not have the right flags set. Instead the resolved
* PointerRNA and PropertyRNA need to be passed. */
if (!RNA_property_anim_editable(ptr, prop)) {
return false;
}
if (ELEM(id_prop->type,
eIDPropertyType::IDP_BOOLEAN,
eIDPropertyType::IDP_INT,
eIDPropertyType::IDP_FLOAT,
eIDPropertyType::IDP_DOUBLE))
{
return true;
}
if (id_prop->type == eIDPropertyType::IDP_ARRAY) {
if (ELEM(id_prop->subtype,
eIDPropertyType::IDP_BOOLEAN,
eIDPropertyType::IDP_INT,
eIDPropertyType::IDP_FLOAT,
eIDPropertyType::IDP_DOUBLE))
{
return true;
}
}
return false;
}
static blender::Vector<RNAPath> construct_rna_paths(PointerRNA *ptr)
{
eRotationModes rotation_mode;
IDProperty *properties;
blender::Vector<RNAPath> paths;
if (ptr->type == &RNA_PoseBone) {
bPoseChannel *pchan = static_cast<bPoseChannel *>(ptr->data);
rotation_mode = eRotationModes(pchan->rotmode);
properties = pchan->prop;
}
else if (ptr->type == &RNA_Object) {
Object *ob = static_cast<Object *>(ptr->data);
rotation_mode = eRotationModes(ob->rotmode);
properties = ob->id.properties;
}
else {
/* Pointer type not supported. */
@@ -309,33 +271,9 @@ static blender::Vector<RNAPath> construct_rna_paths(PointerRNA *ptr)
if (insert_channel_flags & USER_ANIM_KEY_CHANNEL_ROTATION_MODE) {
paths.append({"rotation_mode"});
}
if (insert_channel_flags & USER_ANIM_KEY_CHANNEL_CUSTOM_PROPERTIES) {
if (properties) {
LISTBASE_FOREACH (IDProperty *, id_prop, &properties->data.group) {
PointerRNA resolved_ptr;
PropertyRNA *resolved_prop;
std::string path = id_prop->name;
/* Resolving the path twice, once as RNA property (without brackets, `"propname"`),
* and once as ID property (with brackets, `["propname"]`).
* This is required to support IDProperties that have been defined as part of an add-on.
* Those need to be animated through an RNA path without the brackets. */
bool is_resolved = RNA_path_resolve_property(
ptr, path.c_str(), &resolved_ptr, &resolved_prop);
if (!is_resolved) {
char name_escaped[MAX_IDPROP_NAME * 2];
BLI_str_escape(name_escaped, id_prop->name, sizeof(name_escaped));
path = fmt::format("[\"{}\"]", name_escaped);
is_resolved = RNA_path_resolve_property(
ptr, path.c_str(), &resolved_ptr, &resolved_prop);
}
if (!is_resolved) {
continue;
}
if (is_idproperty_keyable(id_prop, &resolved_ptr, resolved_prop)) {
paths.append({path});
}
}
}
paths.extend(blender::animrig::get_keyable_id_property_paths(*ptr));
}
return paths;
}

View File

@@ -8,6 +8,8 @@
#pragma once
#include "BLI_function_ref.hh"
#include "BLI_string_ref.hh"
#include "DNA_asset_types.h"
struct bUserAssetLibrary;

View File

@@ -47,6 +47,8 @@ const bUserAssetLibrary *get_asset_library_from_opptr(PointerRNA &ptr);
/**
* For each catalog of the given bUserAssetLibrary call `visit_fn`.
* \param edit_text If that text is not empty, and not matching an existing catalog path `visit_fn`
* will be called with that text and the icon ICON_ADD.
*/
void visit_library_catalogs_catalog_for_search(
const Main &bmain,

View File

@@ -64,6 +64,8 @@ struct RNAPath {
*/
std::optional<std::string> key = std::nullopt;
std::optional<int> index = std::nullopt;
int64_t hash() const;
};
/**

View File

@@ -13,6 +13,7 @@
#include "BLI_alloca.h"
#include "BLI_dynstr.h"
#include "BLI_hash.hh"
#include "BLI_listbase.h"
#include "BLI_string.h"
#include "BLI_string_ref.hh"
@@ -34,6 +35,14 @@
#include "rna_access_internal.hh"
#include "rna_internal.hh"
int64_t RNAPath::hash() const
{
if (key.has_value()) {
return blender::get_default_hash(path, key.value());
}
return blender::get_default_hash(path, index.value_or(0));
};
bool operator==(const RNAPath &left, const RNAPath &right)
{
if (left.path != right.path) {

View File

@@ -486,6 +486,13 @@ add_blender_test(
--testdir "${TEST_SRC_DIR}/animation"
)
add_blender_test(
bl_pose_assets
--python ${CMAKE_CURRENT_LIST_DIR}/bl_pose_assets.py
--
--testdir "${TEST_SRC_DIR}/animation"
)
add_blender_test(
bl_animation_nla_strip
--python ${CMAKE_CURRENT_LIST_DIR}/bl_animation_nla_strip.py

View File

@@ -0,0 +1,172 @@
import unittest
import bpy
import pathlib
import sys
import tempfile
import os
"""
blender -b --factory-startup --python tests/python/bl_pose_assets.py -- --testdir /path/to/tests/data/animation
"""
_BONE_NAME_1 = "bone"
_BONE_NAME_2 = "bone_2"
_LIB_NAME = "unit_test"
_BBONE_VALUES = {
f'pose.bones["{_BONE_NAME_1}"].bbone_curveinx': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_curveoutx': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_curveinz': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_curveoutz': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_rollin': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_rollout': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_scalein': (1, 1, 1),
f'pose.bones["{_BONE_NAME_1}"].bbone_scaleout': (1, 1, 1),
f'pose.bones["{_BONE_NAME_1}"].bbone_easein': (0, ),
f'pose.bones["{_BONE_NAME_1}"].bbone_easeout': (0, ),
}
def _create_armature():
armature = bpy.data.armatures.new("anim_armature")
armature_obj = bpy.data.objects.new("anim_object", armature)
bpy.context.scene.collection.objects.link(armature_obj)
bpy.context.view_layer.objects.active = armature_obj
armature_obj.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
edit_bone = armature.edit_bones.new(_BONE_NAME_1)
edit_bone.head = (1, 0, 0)
edit_bone = armature.edit_bones.new(_BONE_NAME_2)
edit_bone.head = (1, 0, 0)
return armature_obj
class CreateAssetTest(unittest.TestCase):
_library_folder = None
_library = None
_armature_object = None
def setUp(self):
super().setUp()
bpy.ops.wm.read_homefile(use_factory_startup=True)
self._armature_object = _create_armature()
self._library_folder = tempfile.TemporaryDirectory("pose_asset_test")
self._library = bpy.types.AssetLibraryCollection.new(name=_LIB_NAME, directory=self._library_folder.name)
bpy.context.view_layer.objects.active = self._armature_object
bpy.ops.object.mode_set(mode='POSE')
self._armature_object.pose.bones[_BONE_NAME_1]["bool_test"] = True
self._armature_object.pose.bones[_BONE_NAME_1]["float_test"] = 3.14
self._armature_object.pose.bones[_BONE_NAME_1]["string_test"] = "foobar"
def tearDown(self):
super().tearDown()
bpy.types.AssetLibraryCollection.remove(self._library)
self._library = None
self._library_folder.cleanup()
def test_create_local_asset(self):
self._armature_object.pose.bones[_BONE_NAME_1].location = (1, 1, 2)
self._armature_object.pose.bones[_BONE_NAME_2].location = (-1, 0, 0)
self._armature_object.pose.bones[_BONE_NAME_1].bone.select = True
self._armature_object.pose.bones[_BONE_NAME_2].bone.select = False
self.assertEqual(len(bpy.data.actions), 0)
bpy.ops.poselib.create_pose_asset(
pose_name="local_asset",
asset_library_reference='LOCAL',
catalog_path="unit_test")
self.assertEqual(len(bpy.data.actions), 1, "Local poses should be stored as actions")
pose_action = bpy.data.actions[0]
self.assertTrue(pose_action.asset_data is not None, "The created action should be marked as an asset")
expected_pose_values = {
f'pose.bones["{_BONE_NAME_1}"].location': (1, 1, 2),
f'pose.bones["{_BONE_NAME_1}"].rotation_quaternion': (1, 0, 0, 0),
f'pose.bones["{_BONE_NAME_1}"].scale': (1, 1, 1),
f'pose.bones["{_BONE_NAME_1}"]["bool_test"]': (True, ),
f'pose.bones["{_BONE_NAME_1}"]["float_test"]': (3.14, ),
# string_test is not here because it should not be keyed.
}
expected_pose_values.update(_BBONE_VALUES)
self.assertEqual(len(pose_action.fcurves), 26)
for fcurve in pose_action.fcurves:
self.assertTrue(
fcurve.data_path in expected_pose_values,
"Only the selected bone should be in the pose asset")
self.assertEqual(len(fcurve.keyframe_points), 1, "Only one key should have been created")
self.assertEqual(fcurve.keyframe_points[0].co.x, 1, "Poses should be on the first frame")
self.assertAlmostEqual(fcurve.keyframe_points[0].co.y,
expected_pose_values[fcurve.data_path][fcurve.array_index], 4)
def test_create_outside_asset(self):
self._armature_object.pose.bones[_BONE_NAME_1].location = (1, 1, 2)
self._armature_object.pose.bones[_BONE_NAME_2].location = (-1, 0, 0)
self._armature_object.pose.bones[_BONE_NAME_1].bone.select = True
self._armature_object.pose.bones[_BONE_NAME_2].bone.select = False
self.assertEqual(len(bpy.data.actions), 0)
bpy.ops.poselib.create_pose_asset(
pose_name="local_asset",
asset_library_reference=_LIB_NAME,
catalog_path="unit_test")
self.assertEqual(len(bpy.data.actions), 0, "The asset should not have been created in this file")
actions_folder = os.path.join(self._library.path, "Saved", "Actions")
asset_files = os.listdir(actions_folder)
self.assertEqual(len(asset_files),
1, "The pose asset file should have been created")
with bpy.data.libraries.load(os.path.join(actions_folder, asset_files[0])) as (data_from, data_to):
self.assertEqual(data_from.actions, ["local_asset"])
data_to.actions = data_from.actions
pose_action = data_to.actions[0]
self.assertTrue(pose_action.asset_data is not None, "The created action should be marked as an asset")
expected_pose_values = {
f'pose.bones["{_BONE_NAME_1}"].location': (1, 1, 2),
f'pose.bones["{_BONE_NAME_1}"].rotation_quaternion': (1, 0, 0, 0),
f'pose.bones["{_BONE_NAME_1}"].scale': (1, 1, 1),
f'pose.bones["{_BONE_NAME_1}"]["bool_test"]': (True, ),
f'pose.bones["{_BONE_NAME_1}"]["float_test"]': (3.14, ),
# string_test is not here because it should not be keyed.
}
expected_pose_values.update(_BBONE_VALUES)
self.assertEqual(len(pose_action.fcurves), 26)
for fcurve in pose_action.fcurves:
self.assertTrue(
fcurve.data_path in expected_pose_values,
"Only the selected bone should be in the pose asset")
self.assertEqual(len(fcurve.keyframe_points), 1, "Only one key should have been created")
self.assertEqual(fcurve.keyframe_points[0].co.x, 1, "Poses should be on the first frame")
self.assertAlmostEqual(fcurve.keyframe_points[0].co.y,
expected_pose_values[fcurve.data_path][fcurve.array_index], 4)
def main():
global args
import argparse
if '--' in sys.argv:
argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
else:
argv = sys.argv
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()