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:
committed by
Christoph Lendenfeld
parent
007d46ef6d
commit
358a0479e8
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
803
source/blender/editors/animation/anim_asset_ops.cc
Normal file
803
source/blender/editors/animation/anim_asset_ops.cc
Normal 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
|
||||
@@ -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
|
||||
|
||||
/** \} */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "BLI_function_ref.hh"
|
||||
#include "BLI_string_ref.hh"
|
||||
#include "DNA_asset_types.h"
|
||||
|
||||
struct bUserAssetLibrary;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,8 @@ struct RNAPath {
|
||||
*/
|
||||
std::optional<std::string> key = std::nullopt;
|
||||
std::optional<int> index = std::nullopt;
|
||||
|
||||
int64_t hash() const;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
172
tests/python/bl_pose_assets.py
Normal file
172
tests/python/bl_pose_assets.py
Normal 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()
|
||||
Reference in New Issue
Block a user