From 409abccadd83b7b70acdf5cfc21abe565bf302f5 Mon Sep 17 00:00:00 2001 From: Jesse Yurkovich Date: Sat, 24 Aug 2024 06:58:27 +0200 Subject: [PATCH] USD: Test coverage for custom properties, xform ops, and orientation Additional coverage for the following scenarios: - Ensures custom properties are exported and imported correctly - Ensures that xform op modes and scene orientation options are properly respected during export Pull Request: https://projects.blender.org/blender/blender/pulls/126723 --- tests/python/bl_usd_export_test.py | 104 ++++++++++++++++++++++++-- tests/python/bl_usd_import_test.py | 114 +++++++++++++++++++++++++++-- 2 files changed, 206 insertions(+), 12 deletions(-) diff --git a/tests/python/bl_usd_export_test.py b/tests/python/bl_usd_export_test.py index ec630e7d999..145e73ecd0d 100644 --- a/tests/python/bl_usd_export_test.py +++ b/tests/python/bl_usd_export_test.py @@ -2,17 +2,13 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import math import pathlib import pprint import sys import tempfile import unittest -from pxr import Usd -from pxr import UsdUtils -from pxr import UsdGeom -from pxr import UsdShade -from pxr import UsdSkel -from pxr import Gf +from pxr import Gf, Sdf, Usd, UsdGeom, UsdShade, UsdSkel, UsdUtils import bpy @@ -79,6 +75,13 @@ class USDExportTest(AbstractUSDTest): self.assertFalse(failed_checks, pprint.pformat(failed_checks)) + # Utility function to round each component of a vector to a few digits. The "+ 0" is to + # ensure that any negative zeros (-0.0) are converted to positive zeros (0.0). + @staticmethod + def round_vector(vector): + return [round(c, 4) + 0 for c in vector] + + # Utility function to compare two Gf.Vec3d's def compareVec3d(self, first, second): places = 5 self.assertAlmostEqual(first[0], second[0], places) @@ -444,7 +447,7 @@ class USDExportTest(AbstractUSDTest): self.assertEqual(len(indices2), 15) self.assertNotEqual(indices1, indices2) - def test_animation(self): + def test_export_animation(self): bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "usd_anim_test.blend")) export_path = self.tempdir / "usd_anim_test.usda" res = bpy.ops.wm.usd_export( @@ -494,6 +497,93 @@ class USDExportTest(AbstractUSDTest): weight_samples = anim.GetBlendShapeWeightsAttr().GetTimeSamples() self.assertEqual(weight_samples, [1.0, 2.0, 3.0, 4.0, 5.0]) + def test_export_xform_ops(self): + """Test exporting different xform operation modes.""" + + # Create a simple scene and export using each of our xform op modes + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + loc = [1, 2, 3] + rot = [math.pi / 4, 0, math.pi / 8] + scale = [1, 2, 3] + + bpy.ops.mesh.primitive_plane_add(location=loc, rotation=rot) + bpy.data.objects[0].scale = scale + + test_path1 = self.tempdir / "temp_xform_trs_test.usda" + res = bpy.ops.wm.usd_export(filepath=str(test_path1), xform_op_mode='TRS') + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path1}") + + test_path2 = self.tempdir / "temp_xform_tos_test.usda" + res = bpy.ops.wm.usd_export(filepath=str(test_path2), xform_op_mode='TOS') + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path2}") + + test_path3 = self.tempdir / "temp_xform_mat_test.usda" + res = bpy.ops.wm.usd_export(filepath=str(test_path3), xform_op_mode='MAT') + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path3}") + + # Validate relevant details for each case + stage = Usd.Stage.Open(str(test_path1)) + xf = UsdGeom.Xformable(stage.GetPrimAtPath("/root/Plane")) + rot_degs = [math.degrees(rot[0]), math.degrees(rot[1]), math.degrees(rot[2])] + self.assertEqual(xf.GetXformOpOrderAttr().Get(), ['xformOp:translate', 'xformOp:rotateXYZ', 'xformOp:scale']) + self.assertEqual(self.round_vector(xf.GetTranslateOp().Get()), loc) + self.assertEqual(self.round_vector(xf.GetRotateXYZOp().Get()), rot_degs) + self.assertEqual(self.round_vector(xf.GetScaleOp().Get()), scale) + + stage = Usd.Stage.Open(str(test_path2)) + xf = UsdGeom.Xformable(stage.GetPrimAtPath("/root/Plane")) + orient_quat = xf.GetOrientOp().Get() + self.assertEqual(xf.GetXformOpOrderAttr().Get(), ['xformOp:translate', 'xformOp:orient', 'xformOp:scale']) + self.assertEqual(self.round_vector(xf.GetTranslateOp().Get()), loc) + self.assertEqual(round(orient_quat.GetReal(), 4), 0.9061) + self.assertEqual(self.round_vector(orient_quat.GetImaginary()), [0.3753, 0.0747, 0.1802]) + self.assertEqual(self.round_vector(xf.GetScaleOp().Get()), scale) + + stage = Usd.Stage.Open(str(test_path3)) + xf = UsdGeom.Xformable(stage.GetPrimAtPath("/root/Plane")) + mat = xf.GetTransformOp().Get() + mat = [ + self.round_vector(mat[0]), self.round_vector(mat[1]), self.round_vector(mat[2]), self.round_vector(mat[3]) + ] + expected = [ + [0.9239, 0.3827, 0.0, 0.0], + [-0.5412, 1.3066, 1.4142, 0.0], + [0.8118, -1.9598, 2.1213, 0.0], + [1.0, 2.0, 3.0, 1.0] + ] + self.assertEqual(xf.GetXformOpOrderAttr().Get(), ['xformOp:transform']) + self.assertEqual(mat, expected) + + def test_export_orientation(self): + """Test exporting different orientation configurations.""" + + # Using the empty scene is fine for this + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + + test_path1 = self.tempdir / "temp_orientation_yup.usda" + res = bpy.ops.wm.usd_export( + filepath=str(test_path1), + convert_orientation=True, + export_global_forward_selection='NEGATIVE_Z', + export_global_up_selection='Y') + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path1}") + + test_path2 = self.tempdir / "temp_orientation_zup_rev.usda" + res = bpy.ops.wm.usd_export( + filepath=str(test_path2), + convert_orientation=True, + export_global_forward_selection='NEGATIVE_Y', + export_global_up_selection='Z') + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path2}") + + stage = Usd.Stage.Open(str(test_path1)) + xf = UsdGeom.Xformable(stage.GetPrimAtPath("/root")) + self.assertEqual(self.round_vector(xf.GetRotateXYZOp().Get()), [-90, 0, 0]) + + stage = Usd.Stage.Open(str(test_path2)) + xf = UsdGeom.Xformable(stage.GetPrimAtPath("/root")) + self.assertEqual(self.round_vector(xf.GetRotateXYZOp().Get()), [0, 0, 180]) + def test_materialx_network(self): """Test exporting that a MaterialX export makes it out alright""" bpy.ops.wm.open_mainfile( diff --git a/tests/python/bl_usd_import_test.py b/tests/python/bl_usd_import_test.py index 471f069f4de..778605df355 100644 --- a/tests/python/bl_usd_import_test.py +++ b/tests/python/bl_usd_import_test.py @@ -5,12 +5,9 @@ import math import pathlib import sys -import unittest import tempfile -from pxr import Usd -from pxr import UsdShade -from pxr import UsdGeom -from pxr import Sdf +import unittest +from pxr import Gf, Sdf, Usd, UsdGeom, UsdShade import bpy @@ -920,6 +917,113 @@ class USDImportTest(AbstractUSDTest): self.assertTrue(len(ob.modifiers) == 1 and ob.modifiers[0].type == 'MESH_SEQUENCE_CACHE', f"{ob.name} has incorrect modifiers") + def test_import_id_props(self): + """Test importing object and data IDProperties.""" + + # Create our set of ID's with all relevant IDProperty types/values that we support + bpy.ops.object.empty_add() + bpy.ops.object.light_add() + bpy.ops.object.camera_add() + bpy.ops.mesh.primitive_plane_add() + + ids = [ob if ob.type == 'EMPTY' else ob.data for ob in bpy.data.objects] + properties = [ + True, "string", 1, 2.0, [1, 2], [1, 2, 3], [1, 2, 3, 4], [1.0, 2.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0, 4.0] + ] + for id in ids: + for i, p in enumerate(properties): + prop_name = "prop" + str(i) + id[prop_name] = p + + # Export out this scene twice so we can test both the default "userProperties" namespace as + # well as a custom namespace + test_path1 = self.tempdir / "temp_idprops_userProperties_test.usda" + res = bpy.ops.wm.usd_export(filepath=str(test_path1), evaluation_mode="RENDER") + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path1}") + + custom_namespace = "customns" + test_path2 = self.tempdir / "temp_idprops_customns_test.usda" + res = bpy.ops.wm.usd_export( + filepath=str(test_path2), + custom_properties_namespace=custom_namespace, + evaluation_mode="RENDER") + self.assertEqual({'FINISHED'}, res, f"Unable to export to {test_path2}") + + # Also write out another file using attribute types not natively writable by Blender + test_path3 = self.tempdir / "temp_idprops_extended_test.usda" + stage = Usd.Stage.CreateNew(str(test_path3)) + xform = UsdGeom.Xform.Define(stage, '/empty') + xform.GetPrim().CreateAttribute("prop0", Sdf.ValueTypeNames.Half).Set(0.5) + xform.GetPrim().CreateAttribute("prop1", Sdf.ValueTypeNames.Float).Set(1.5) + xform.GetPrim().CreateAttribute("prop2", Sdf.ValueTypeNames.Token).Set("tokenstring") + xform.GetPrim().CreateAttribute("prop3", Sdf.ValueTypeNames.Asset).Set("assetstring") + xform.GetPrim().CreateAttribute("prop4", Sdf.ValueTypeNames.Half2).Set(Gf.Vec2h(0, 1)) + xform.GetPrim().CreateAttribute("prop5", Sdf.ValueTypeNames.Half3).Set(Gf.Vec3h(0, 1, 2)) + xform.GetPrim().CreateAttribute("prop6", Sdf.ValueTypeNames.Half4).Set(Gf.Vec4h(0, 1, 2, 3)) + xform.GetPrim().CreateAttribute("prop7", Sdf.ValueTypeNames.Float2).Set(Gf.Vec2f(0, 1)) + xform.GetPrim().CreateAttribute("prop8", Sdf.ValueTypeNames.Float3).Set(Gf.Vec3f(0, 1, 2)) + xform.GetPrim().CreateAttribute("prop9", Sdf.ValueTypeNames.Float4).Set(Gf.Vec4f(0, 1, 2, 3)) + stage.GetRootLayer().Save() + + # Helper functions to check IDProperty validity + import idprop + + def assert_all_props_present(properties, ns): + ids = [ob if ob.type == 'EMPTY' else ob.data for ob in bpy.data.objects] + for id in ids: + for i, p in enumerate(properties): + prop_name = (ns + ":" if ns != "" else "") + "prop" + str(i) + prop = id[prop_name] + value = prop.to_list() if type(prop) is idprop.types.IDPropertyArray else prop + self.assertEqual(p, value, f"Property {prop_name} is incorrect") + + def assert_no_props_present(properties, ns): + ids = [ob if ob.type == 'EMPTY' else ob.data for ob in bpy.data.objects] + for id in ids: + for i, p in enumerate(properties): + prop_name = (ns + ":" if ns != "" else "") + "prop" + str(i) + self.assertTrue(id.get(prop_name) is None, f"Property {prop_name} should not be present") + + # Reload the empty file and test the relevant combinations of namespaces and import modes + + infile = str(test_path1) + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + res = bpy.ops.wm.usd_import(filepath=infile, attr_import_mode='USER') + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + self.assertEqual(len(bpy.data.objects), 4) + assert_all_props_present(properties, "") + + infile = str(test_path1) + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + res = bpy.ops.wm.usd_import(filepath=infile, attr_import_mode='NONE') + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + self.assertEqual(len(bpy.data.objects), 4) + assert_no_props_present(properties, "") + + infile = str(test_path2) + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + res = bpy.ops.wm.usd_import(filepath=infile, attr_import_mode='ALL') + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + self.assertEqual(len(bpy.data.objects), 4) + assert_all_props_present(properties, custom_namespace) + + infile = str(test_path2) + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + res = bpy.ops.wm.usd_import(filepath=infile, attr_import_mode='USER') + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + self.assertEqual(len(bpy.data.objects), 4) + assert_no_props_present(properties, custom_namespace) + + infile = str(test_path3) + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend")) + res = bpy.ops.wm.usd_import(filepath=infile, attr_import_mode='ALL') + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + self.assertEqual(len(bpy.data.objects), 1) + properties = [ + 0.5, 1.5, "tokenstring", "assetstring", [0, 1], [0, 1, 2], [0, 1, 2, 3], [0, 1], [0, 1, 2], [0, 1, 2, 3] + ] + assert_all_props_present(properties, "") + def main(): global args