USD: Skeleton and blend shape import

Added support for UsdSkel animation import.

This addresses #110076.

Added USDSkeletonReader class which imports UsdSkelSkeleton primitives
as armatures.

Extended USDMeshReader to import UsdSkelBlendShape as shape keys.

Extended USDMeshReader to import USD skinning data as as deform groups
and an armature modifier on the mesh object.

Added USDMeshReader::get_local_usd_xform() to override the transform
computation to account for the binding transformation for skinned meshes.

Pull Request: https://projects.blender.org/blender/blender/pulls/110912
This commit is contained in:
Michael Kowalski 2023-08-17 20:11:51 +02:00 committed by Michael Kowalski
parent 1b25849ea6
commit 6f4cb9bc56
15 changed files with 1498 additions and 24 deletions

View File

@ -429,6 +429,8 @@ static int wm_usd_import_exec(bContext *C, wmOperator *op)
const bool import_meshes = RNA_boolean_get(op->ptr, "import_meshes");
const bool import_volumes = RNA_boolean_get(op->ptr, "import_volumes");
const bool import_shapes = RNA_boolean_get(op->ptr, "import_shapes");
const bool import_skeletons = RNA_boolean_get(op->ptr, "import_skeletons");
const bool import_blendshapes = RNA_boolean_get(op->ptr, "import_blendshapes");
const bool import_subdiv = RNA_boolean_get(op->ptr, "import_subdiv");
@ -492,6 +494,8 @@ static int wm_usd_import_exec(bContext *C, wmOperator *op)
params.import_meshes = import_meshes;
params.import_volumes = import_volumes;
params.import_shapes = import_shapes;
params.import_skeletons = import_skeletons;
params.import_blendshapes = import_blendshapes;
params.prim_path_mask = prim_path_mask;
params.import_subdiv = import_subdiv;
params.import_instance_proxies = import_instance_proxies;
@ -538,6 +542,8 @@ static void wm_usd_import_draw(bContext * /*C*/, wmOperator *op)
uiItemR(col, ptr, "import_meshes", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "import_volumes", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "import_shapes", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "import_skeletons", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "import_blendshapes", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(box, ptr, "prim_path_mask", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(box, ptr, "scale", UI_ITEM_NONE, nullptr, ICON_NONE);
@ -632,6 +638,8 @@ void WM_OT_usd_import(wmOperatorType *ot)
RNA_def_boolean(ot->srna, "import_meshes", true, "Meshes", "");
RNA_def_boolean(ot->srna, "import_volumes", true, "Volumes", "");
RNA_def_boolean(ot->srna, "import_shapes", true, "Shapes", "");
RNA_def_boolean(ot->srna, "import_skeletons", true, "Skeletons", "");
RNA_def_boolean(ot->srna, "import_blendshapes", true, "Blend Shapes", "");
RNA_def_boolean(ot->srna,
"import_subdiv",

View File

@ -110,6 +110,8 @@ set(SRC
intern/usd_reader_stage.cc
intern/usd_reader_volume.cc
intern/usd_reader_xform.cc
intern/usd_reader_skeleton.cc
intern/usd_skel_convert.cc
usd.hh
usd.h
@ -142,6 +144,8 @@ set(SRC
intern/usd_reader_stage.h
intern/usd_reader_volume.h
intern/usd_reader_xform.h
intern/usd_reader_skeleton.h
intern/usd_skel_convert.h
)
if(WITH_HYDRA)

View File

@ -303,6 +303,10 @@ static void import_startjob(void *customdata, bool *stop, bool *do_update, float
}
}
if (data->params.import_skeletons) {
archive->process_armature_modifiers();
}
data->import_ok = !data->was_canceled;
*progress = 1.0f;

View File

@ -7,6 +7,7 @@
#include "usd_reader_mesh.h"
#include "usd_reader_material.h"
#include "usd_skel_convert.h"
#include "BKE_attribute.hh"
#include "BKE_customdata.h"
@ -43,6 +44,7 @@
#include <pxr/usd/usdGeom/primvarsAPI.h>
#include <pxr/usd/usdGeom/subset.h>
#include <pxr/usd/usdShade/materialBindingAPI.h>
#include <pxr/usd/usdSkel/bindingAPI.h>
#include <iostream>
@ -268,6 +270,14 @@ void USDMeshReader::read_object_data(Main *bmain, const double motionSampleTime)
}
}
if (import_params_.import_blendshapes) {
import_blendshapes(bmain, object_, prim_);
}
if (import_params_.import_skeletons) {
import_mesh_skel_bindings(bmain, object_, prim_);
}
USDXformReader::read_object_data(bmain, motionSampleTime);
} // namespace blender::io::usd
@ -1099,4 +1109,57 @@ Mesh *USDMeshReader::read_mesh(Mesh *existing_mesh,
return active_mesh;
}
std::string USDMeshReader::get_skeleton_path() const
{
/* Make sure we can apply UsdSkelBindingAPI to the prim.
* Attempting to apply the API to instance proxies generates
* a USD error. */
if (!prim_ || prim_.IsInstanceProxy()) {
return "";
}
pxr::UsdSkelBindingAPI skel_api = pxr::UsdSkelBindingAPI::Apply(prim_);
if (!skel_api) {
return "";
}
if (pxr::UsdSkelSkeleton skel = skel_api.GetInheritedSkeleton()) {
return skel.GetPath().GetAsString();
}
return "";
}
std::optional<XformResult> USDMeshReader::get_local_usd_xform(const float time) const
{
if (!import_params_.import_skeletons || prim_.IsInstanceProxy()) {
/* Use the standard transform computation, since we are ignoring
* skinning data. Note that applying the UsdSkelBinding API to an
* instance proxy generates a USD error. */
return USDXformReader::get_local_usd_xform(time);
}
if (pxr::UsdSkelBindingAPI skel_api = pxr::UsdSkelBindingAPI::Apply(prim_)) {
if (skel_api.GetGeomBindTransformAttr().HasAuthoredValue()) {
pxr::GfMatrix4d bind_xf;
if (skel_api.GetGeomBindTransformAttr().Get(&bind_xf)) {
/* The USD bind transform is a matrix of doubles,
* but we cast it to GfMatrix4f because Blender expects
* a matrix of floats. Also, we assume the transform
* is constant over time. */
return XformResult(pxr::GfMatrix4f(bind_xf), true);
}
else {
WM_reportf(RPT_WARNING,
"%s: Couldn't compute geom bind transform for %s",
__func__,
prim_.GetPath().GetAsString().c_str());
}
}
}
return USDXformReader::get_local_usd_xform(time);
}
} // namespace blender::io::usd

View File

@ -56,6 +56,16 @@ class USDMeshReader : public USDGeomReader {
bool topology_changed(const Mesh *existing_mesh, double motionSampleTime) override;
/**
* If the USD mesh prim has a valid UsdSkel schema defined, return the USD path
* string to the bound skeleton, if any. Returns the empty string if no skeleton
* binding is defined.
*
* The returned path is currently used to match armature modifiers with armature
* objects during import.
*/
std::string get_skeleton_path() const;
private:
void process_normals_vertex_varying(Mesh *mesh);
void process_normals_face_varying(Mesh *mesh);
@ -82,6 +92,7 @@ class USDMeshReader : public USDGeomReader {
void read_color_data_primvar(Mesh *mesh,
const pxr::UsdGeomPrimvar &color_primvar,
const double motionSampleTime);
void read_uv_data_primvar(Mesh *mesh,
const pxr::UsdGeomPrimvar &primvar,
const double motionSampleTime);
@ -94,6 +105,12 @@ class USDMeshReader : public USDGeomReader {
const pxr::UsdGeomPrimvar &primvar,
const double motionSampleTime,
MutableSpan<BlenderT> attribute);
/**
* Override transform computation to account for the binding
* transformation for skinned meshes.
*/
std::optional<XformResult> get_local_usd_xform(float time) const override;
};
} // namespace blender::io::usd

View File

@ -0,0 +1,47 @@
/* SPDX-FileCopyrightText: 2021 NVIDIA Corporation. All rights reserved.
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_reader_skeleton.h"
#include "usd_skel_convert.h"
#include "BKE_armature.h"
#include "BKE_idprop.h"
#include "BKE_object.h"
#include "DNA_armature_types.h"
#include "DNA_object_types.h"
#include "MEM_guardedalloc.h"
#include "WM_api.hh"
#include <iostream>
namespace blender::io::usd {
bool USDSkeletonReader::valid() const
{
return skel_ && USDXformReader::valid();
}
void USDSkeletonReader::create_object(Main *bmain, const double /* motionSampleTime */)
{
object_ = BKE_object_add_only_object(bmain, OB_ARMATURE, name_.c_str());
bArmature *arm = BKE_armature_add(bmain, name_.c_str());
object_->data = arm;
}
void USDSkeletonReader::read_object_data(Main *bmain, const double motionSampleTime)
{
if (!object_ || !object_->data || !skel_) {
return;
}
import_skeleton(bmain, object_, skel_);
USDXformReader::read_object_data(bmain, motionSampleTime);
}
} // namespace blender::io::usd

View File

@ -0,0 +1,30 @@
/* SPDX-FileCopyrightText: 2023 NVIDIA Corporation. All rights reserved.
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#pragma once
#include "usd.h"
#include "usd_reader_xform.h"
#include <pxr/usd/usdSkel/skeleton.h>
namespace blender::io::usd {
class USDSkeletonReader : public USDXformReader {
private:
pxr::UsdSkelSkeleton skel_;
public:
USDSkeletonReader(const pxr::UsdPrim &prim,
const USDImportParams &import_params,
const ImportSettings &settings)
: USDXformReader(prim, import_params, settings), skel_(prim)
{
}
bool valid() const override;
void create_object(Main *bmain, double motionSampleTime) override;
void read_object_data(Main *bmain, double motionSampleTime) override;
};
} // namespace blender::io::usd

View File

@ -11,6 +11,7 @@
#include "usd_reader_nurbs.h"
#include "usd_reader_prim.h"
#include "usd_reader_shape.h"
#include "usd_reader_skeleton.h"
#include "usd_reader_volume.h"
#include "usd_reader_xform.h"
@ -42,9 +43,12 @@
#include "BLI_string.h"
#include "BKE_lib_id.h"
#include "BKE_modifier.h"
#include "DNA_material_types.h"
#include "WM_api.hh"
namespace blender::io::usd {
USDStageReader::USDStageReader(pxr::UsdStageRefPtr stage,
@ -100,6 +104,9 @@ USDPrimReader *USDStageReader::create_reader_if_allowed(const pxr::UsdPrim &prim
if (params_.import_volumes && prim.IsA<pxr::UsdVolVolume>()) {
return new USDVolumeReader(prim, params_, settings_);
}
if (params_.import_skeletons && prim.IsA<pxr::UsdSkelSkeleton>()) {
return new USDSkeletonReader(prim, params_, settings_);
}
if (prim.IsA<pxr::UsdGeomImageable>()) {
return new USDXformReader(prim, params_, settings_);
}
@ -134,6 +141,9 @@ USDPrimReader *USDStageReader::create_reader(const pxr::UsdPrim &prim)
if (prim.IsA<pxr::UsdVolVolume>()) {
return new USDVolumeReader(prim, params_, settings_);
}
if (prim.IsA<pxr::UsdSkelSkeleton>()) {
return new USDSkeletonReader(prim, params_, settings_);
}
if (prim.IsA<pxr::UsdGeomImageable>()) {
return new USDXformReader(prim, params_, settings_);
}
@ -167,6 +177,11 @@ bool USDStageReader::include_by_visibility(const pxr::UsdGeomImageable &imageabl
bool USDStageReader::include_by_purpose(const pxr::UsdGeomImageable &imageable) const
{
if (params_.import_skeletons && imageable.GetPrim().IsA<pxr::UsdSkelSkeleton>()) {
/* Always include skeletons, if requested by the user, regardless of purpose. */
return true;
}
if (params_.import_guide && params_.import_proxy && params_.import_render) {
/* The options allow any purpose, so we trivially include the prim. */
return true;
@ -318,6 +333,48 @@ void USDStageReader::collect_readers(Main *bmain)
collect_readers(bmain, root);
}
void USDStageReader::process_armature_modifiers() const
{
/* Iteratate over the skeleton readers to create the
* armature object map, which maps a USD skeleton prim
* path to the corresponding armature object. */
std::map<std::string, Object *> usd_path_to_armature;
for (const USDPrimReader *reader : readers_) {
if (dynamic_cast<const USDSkeletonReader *>(reader) && reader->object()) {
usd_path_to_armature.insert(std::make_pair(reader->prim_path(), reader->object()));
}
}
/* Iterate over the mesh readers and set armature objects on armature modifiers. */
for (const USDPrimReader *reader : readers_) {
if (!reader->object()) {
continue;
}
const USDMeshReader *mesh_reader = dynamic_cast<const USDMeshReader *>(reader);
if (!mesh_reader) {
continue;
}
/* Check if the mesh object has an armature modifier. */
ModifierData *md = BKE_modifiers_findby_type(reader->object(), eModifierType_Armature);
if (!md) {
continue;
}
ArmatureModifierData *amd = reinterpret_cast<ArmatureModifierData *>(md);
/* Assign the armature based on the bound USD skeleton path of the skinned mesh. */
std::string skel_path = mesh_reader->get_skeleton_path();
std::map<std::string, Object *>::const_iterator it = usd_path_to_armature.find(skel_path);
if (it == usd_path_to_armature.end()) {
WM_reportf(RPT_WARNING,
"%s: Couldn't find armature object corresponding to USD skeleton %s",
__func__,
skel_path.c_str());
}
amd->object = it->second;
}
}
void USDStageReader::import_all_materials(Main *bmain)
{
BLI_assert(valid());

View File

@ -45,6 +45,13 @@ class USDStageReader {
void collect_readers(struct Main *bmain);
/**
* Complete setting up the armature modifiers that
* were created for skinned meshes by setting the
* modifier object on the corresponding modifier.
*/
void process_armature_modifiers() const;
/* Convert every material prim on the stage to a Blender
* material, including materials not used by any geometry.
* Note that collect_readers() must be called before calling

View File

@ -67,37 +67,20 @@ void USDXformReader::read_matrix(float r_mat[4][4] /* local matrix */,
const float scale,
bool *r_is_constant)
{
if (r_is_constant) {
*r_is_constant = true;
}
BLI_assert(r_mat);
BLI_assert(r_is_constant);
*r_is_constant = true;
unit_m4(r_mat);
pxr::UsdGeomXformable xformable;
std::optional<XformResult> xf_result = get_local_usd_xform(time);
if (use_parent_xform_) {
xformable = pxr::UsdGeomXformable(prim_.GetParent());
}
else {
xformable = pxr::UsdGeomXformable(prim_);
}
if (!xformable) {
/* This might happen if the prim is a Scope. */
if (!xf_result) {
return;
}
if (r_is_constant) {
*r_is_constant = !xformable.TransformMightBeTimeVarying();
}
pxr::GfMatrix4d usd_local_xf;
bool reset_xform_stack;
xformable.GetLocalTransformation(&usd_local_xf, &reset_xform_stack, time);
/* Convert the result to a float matrix. */
pxr::GfMatrix4f mat4f = pxr::GfMatrix4f(usd_local_xf);
mat4f.Get(r_mat);
std::get<0>(*xf_result).Get(r_mat);
*r_is_constant = std::get<1>(*xf_result);
/* Apply global scaling and rotation only to root objects, parenting
* will propagate it. */
@ -168,4 +151,28 @@ bool USDXformReader::is_root_xform_prim() const
return false;
}
std::optional<XformResult> USDXformReader::get_local_usd_xform(const float time) const
{
pxr::UsdGeomXformable xformable = use_parent_xform_ ? pxr::UsdGeomXformable(prim_.GetParent()) :
pxr::UsdGeomXformable(prim_);
if (!xformable) {
/* This might happen if the prim is a Scope. */
return std::nullopt;
}
bool is_constant = !xformable.TransformMightBeTimeVarying();
bool reset_xform_stack;
pxr::GfMatrix4d xform;
if (!xformable.GetLocalTransformation(&xform, &reset_xform_stack, time)) {
return std::nullopt;
}
/* The USD bind transform is a matrix of doubles,
* but we cast it to GfMatrix4f because Blender expects
* a matrix of floats. */
return XformResult(pxr::GfMatrix4f(xform), is_constant);
}
} // namespace blender::io::usd

View File

@ -12,6 +12,10 @@
namespace blender::io::usd {
/** A transformation matrix and a boolean indicating
* whether the matrix is constant over time. */
using XformResult = std::tuple<pxr::GfMatrix4f, bool>;
class USDXformReader : public USDPrimReader {
private:
bool use_parent_xform_;
@ -50,6 +54,18 @@ class USDXformReader : public USDPrimReader {
protected:
/* Returns true if the contained USD prim is the root of a transform hierarchy. */
bool is_root_xform_prim() const;
/**
* Return the USD prim's local transformation.
*
* \param time: Time code for evaluating the transform.
*
* \return: Optional tuple with the following elements:
* - The transform matrix.
* - A boolean flag indicating whether the matrix
* is constant over time.
*/
virtual std::optional<XformResult> get_local_usd_xform(float time) const;
};
} // namespace blender::io::usd

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
/* SPDX-FileCopyrightText: 2023 NVIDIA Corporation. All rights reserved.
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#pragma once
#include <map>
#include <pxr/usd/usd/prim.h>
#include <pxr/usd/usdSkel/skeletonQuery.h>
struct Main;
struct Object;
struct Scene;
struct USDExportParams;
struct USDImportParams;
namespace blender::io::usd {
struct ImportSettings;
/**
* This file contains utilities for converting between UsdSkel data and
* Blender armatures and shape keys. The following is a reference on the
* UsdSkel API:
*
* https://openusd.org/23.05/api/usd_skel_page_front.html
*/
/**
* Import USD blend shapes from a USD primitive as shape keys on a mesh
* object. Optionally, if the blend shapes have animating weights, the
* time-sampled weights will be imported as shape key animation curves.
* If the USD primitive does not have blend shape targets defined, this
* function is a no-op.
*
* \param bmain: Main pointer
* \param mesh_obj: Mesh object to which imported shape keys will be added
* \param prim: The USD primitive from which blendshapes will be imported
* \param import_anim: Whether to import time-sampled weights as shape key
* animation curves
*/
void import_blendshapes(Main *bmain,
Object *mesh_obj,
const pxr::UsdPrim &prim,
bool import_anim = true);
/**
* Import the given USD skeleton as an armature object. Optionally, if the
* skeleton has an animation defined, the time sampled joint transforms will be
* imported as bone animation curves.
*
* \param bmain: Main pointer
* \param arm_obj: Armature object to which the bone hierachy will be added
* \param skel: The USD skeleton from which bones and animation will be imported
* \param import_anim: Whether to import time-sampled joint transforms as bone
* animation curves
*/
void import_skeleton(Main *bmain,
Object *arm_obj,
const pxr::UsdSkelSkeleton &skel,
bool import_anim = true);
/**
* Import skinning data from a source USD prim as deform groups and an armature
* modifier on the given mesh object. If the USD prim does not have a skeleton
* binding defined, this function is a no-op.
*
* \param bmain: Main pointer
* \param obj: Mesh object to which an armature modifier will be added
* \param prim: The USD primitive from which skinning data will be imported
*/
void import_mesh_skel_bindings(Main *bmain, Object *mesh_obj, const pxr::UsdPrim &prim);
} // namespace blender::io::usd

View File

@ -72,6 +72,8 @@ struct USDImportParams {
bool import_meshes;
bool import_volumes;
bool import_shapes;
bool import_skeletons;
bool import_blendshapes;
char *prim_path_mask;
bool import_subdiv;
bool import_instance_proxies;

View File

@ -13,6 +13,8 @@ from pxr import Sdf
import bpy
from mathutils import Matrix, Vector, Quaternion, Euler
args = None
@ -302,6 +304,106 @@ class USDImportTest(AbstractUSDTest):
self.assertEqual(4, num_uvmaps_found, "One or more test materials failed to import")
def test_import_usd_blend_shapes(self):
"""Test importing USD blend shapes with animated weights."""
infile = str(self.testdir / "usd_blend_shape_test.usda")
res = bpy.ops.wm.usd_import(filepath=infile)
self.assertEqual({'FINISHED'}, res)
obj = bpy.data.objects["Plane"]
obj.active_shape_key_index = 1
key = obj.active_shape_key
self.assertEqual(key.name, "Key_1", "Unexpected shape key name")
# Verify the number of shape key points.
self.assertEqual(len(key.data), 4, "Unexpected number of shape key point")
# Verify shape key point coordinates
# Reference point values.
refs = ((-2.51, -1.92, 0.20), (0.86, -1.46, -0.1),
(-1.33, 1.29, .84), (1.32, 2.20, -0.42))
for i in range(4):
co = key.data[i].co
ref = refs[i]
# Compare coordinates.
for j in range(3):
self.assertAlmostEqual(co[j], ref[j], 2)
# Verify the shape key values.
bpy.context.scene.frame_set(1)
self.assertAlmostEqual(key.value, .002, 1)
bpy.context.scene.frame_set(30)
self.assertAlmostEqual(key.value, .900, 3)
bpy.context.scene.frame_set(60)
self.assertAlmostEqual(key.value, .100, 3)
def test_import_usd_skel_joints(self):
"""Test importing USD animated skeleton joints."""
infile = str(self.testdir / "arm.usda")
res = bpy.ops.wm.usd_import(filepath=infile)
self.assertEqual({'FINISHED'}, res)
# Verify armature was imported.
arm_obj = bpy.data.objects["Skel"]
self.assertEqual(arm_obj.type, "ARMATURE", "'Skel' object is not an armature")
arm = arm_obj.data
bones = arm.bones
# Verify bone parenting.
self.assertIsNone(bones['Shoulder'].parent, "Shoulder bone should not be parented")
self.assertEqual(bones['Shoulder'], bones['Elbow'].parent, "Elbow bone should be child of Shoulder bone")
self.assertEqual(bones['Elbow'], bones['Hand'].parent, "Hand bone should be child of Elbow bone")
# Verify armature modifier was created on the mesh.
mesh_obj = bpy.data.objects['Arm']
# Get all the armature modifiers on the mesh.
arm_mods = [m for m in mesh_obj.modifiers if m.type == "ARMATURE"]
self.assertEqual(len(arm_mods), 1, "Didn't get expected armatrue modifier")
self.assertEqual(arm_mods[0].object, arm_obj, "Armature modifier does not reference the imported armature")
# Verify expected deform groups.
# There are 4 points in each group.
for i in range(4):
self.assertAlmostEqual(mesh_obj.vertex_groups['Hand'].weight(i), 1.0, 2, "Unexpected weight for Hand deform vert")
self.assertAlmostEqual(mesh_obj.vertex_groups['Shoulder'].weight(4 + i), 1.0, 2, "Unexpected weight for Shoulder deform vert")
self.assertAlmostEqual(mesh_obj.vertex_groups['Elbow'].weight(8 + i), 1.0, 2, "Unexpected weight for Elbow deform vert")
action = bpy.data.actions['SkelAction']
# Verify the Elbow joint rotation animation.
curve_path = 'pose.bones["Elbow"].rotation_quaternion'
# Quat W
f = action.fcurves.find(curve_path, index=0)
self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion W curve")
self.assertAlmostEqual(f.evaluate(0), 1.0, 2, "Unexpected value for rotation quaternion W curve at frame 0")
self.assertAlmostEqual(f.evaluate(10), 0.707, 2, "Unexpected value for rotation quaternion W curve at frame 10")
# Quat X
f = action.fcurves.find(curve_path, index=1)
self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion X curve")
self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion X curve at frame 0")
self.assertAlmostEqual(f.evaluate(10), 0.707, 2, "Unexpected value for rotation quaternion X curve at frame 10")
# Quat Y
f = action.fcurves.find(curve_path, index=2)
self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion Y curve")
self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion Y curve at frame 0")
self.assertAlmostEqual(f.evaluate(10), 0.0, 2, "Unexpected value for rotation quaternion Y curve at frame 10")
# Quat Z
f = action.fcurves.find(curve_path, index=3)
self.assertIsNotNone(f, "Couldn't find Elbow rotation quaternion Z curve")
self.assertAlmostEqual(f.evaluate(0), 0.0, 2, "Unexpected value for rotation quaternion Z curve at frame 0")
self.assertAlmostEqual(f.evaluate(10), 0.0, 2, "Unexpected value for rotation quaternion Z curve at frame 10")
def main():
global args