diff --git a/source/blender/blenloader/tests/blendfile_loading_base_test.cc b/source/blender/blenloader/tests/blendfile_loading_base_test.cc index 6f190ce427e..6613c65c42a 100644 --- a/source/blender/blenloader/tests/blendfile_loading_base_test.cc +++ b/source/blender/blenloader/tests/blendfile_loading_base_test.cc @@ -103,8 +103,8 @@ void BlendfileLoadingBaseTest::TearDownTestCase() void BlendfileLoadingBaseTest::TearDown() { BKE_mball_cubeTable_free(); - depsgraph_free(); blendfile_free(); + depsgraph_free(); testing::Test::TearDown(); } diff --git a/source/blender/io/usd/CMakeLists.txt b/source/blender/io/usd/CMakeLists.txt index ebd292782c0..862bd41c087 100644 --- a/source/blender/io/usd/CMakeLists.txt +++ b/source/blender/io/usd/CMakeLists.txt @@ -163,13 +163,18 @@ target_link_libraries(bf_usd INTERFACE ${TBB_LIBRARIES}) if(WITH_GTESTS) set(TEST_SRC tests/usd_stage_creation_test.cc + tests/usd_export_test.cc tests/usd_tests_common.cc tests/usd_tests_common.h + + intern/usd_writer_material.h ) if(USD_IMAGING_HEADERS) list(APPEND TEST_SRC tests/usd_imaging_test.cc) endif() + include_directories(intern) + set(TEST_INC ) set(TEST_LIB diff --git a/source/blender/io/usd/intern/usd_capi_export.cc b/source/blender/io/usd/intern/usd_capi_export.cc index 28da9e388c5..1d33ca3a13c 100644 --- a/source/blender/io/usd/intern/usd_capi_export.cc +++ b/source/blender/io/usd/intern/usd_capi_export.cc @@ -66,7 +66,9 @@ static void export_startjob(void *customdata, data->start_time = timeit::Clock::now(); G.is_rendering = true; - WM_set_locked_interface(data->wm, true); + if (data->wm) { + WM_set_locked_interface(data->wm, true); + } G.is_break = false; /* Construct the depsgraph for exporting. */ @@ -160,7 +162,9 @@ static void export_endjob(void *customdata) } G.is_rendering = false; - WM_set_locked_interface(data->wm, false); + if (data->wm) { + WM_set_locked_interface(data->wm, false); + } report_job_duration(data); } diff --git a/source/blender/io/usd/intern/usd_capi_import.cc b/source/blender/io/usd/intern/usd_capi_import.cc index 66319a7f04e..fb870eb154c 100644 --- a/source/blender/io/usd/intern/usd_capi_import.cc +++ b/source/blender/io/usd/intern/usd_capi_import.cc @@ -207,6 +207,7 @@ static void import_startjob(void *customdata, bool *stop, bool *do_update, float if (!stage) { WM_reportf(RPT_ERROR, "USD Import: unable to open stage to read %s", data->filepath); data->import_ok = false; + data->error_code = USD_ARCHIVE_FAIL; return; } diff --git a/source/blender/io/usd/intern/usd_writer_material.cc b/source/blender/io/usd/intern/usd_writer_material.cc index 98cd4036fd0..7e744b74f61 100644 --- a/source/blender/io/usd/intern/usd_writer_material.cc +++ b/source/blender/io/usd/intern/usd_writer_material.cc @@ -748,4 +748,16 @@ static void export_texture(bNode *node, } } +const pxr::TfToken token_for_input(const char *input_name) +{ + const InputSpecMap &input_map = preview_surface_input_map(); + const InputSpecMap::const_iterator it = input_map.find(input_name); + + if (it == input_map.end()) { + return pxr::TfToken(); + } + + return it->second.input_name; +} + } // namespace blender::io::usd diff --git a/source/blender/io/usd/intern/usd_writer_material.h b/source/blender/io/usd/intern/usd_writer_material.h index fdfd13871ff..c6123b3cce2 100644 --- a/source/blender/io/usd/intern/usd_writer_material.h +++ b/source/blender/io/usd/intern/usd_writer_material.h @@ -14,6 +14,10 @@ namespace blender::io::usd { struct USDExporterContext; +/* Returns a USDPreviewSurface token name for a given Blender shader Socket name, + * or an empty TfToken if the input name is not found in the map. */ +const pxr::TfToken token_for_input(const char *input_name); + /** * Entry point to create an approximate USD Preview Surface network from a Cycles node graph. * Due to the limited nodes in the USD Preview Surface specification, only the following nodes diff --git a/source/blender/io/usd/tests/usd_export_test.cc b/source/blender/io/usd/tests/usd_export_test.cc new file mode 100644 index 00000000000..c13da695c87 --- /dev/null +++ b/source/blender/io/usd/tests/usd_export_test.cc @@ -0,0 +1,314 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ + +#include "testing/testing.h" +#include "tests/blendfile_loading_base_test.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DNA_image_types.h" +#include "DNA_material_types.h" +#include "DNA_node_types.h" + +#include "BKE_context.h" +#include "BKE_lib_id.h" +#include "BKE_main.h" +#include "BKE_mesh.h" +#include "BKE_node.h" +#include "BLI_fileops.h" +#include "BLI_math.h" +#include "BLI_path_util.h" +#include "BLI_math_vector_types.hh" +#include "BLO_readfile.h" + +#include "BKE_node_runtime.hh" + +#include "DEG_depsgraph.h" + +#include "WM_api.h" + +#include "usd.h" +#include "usd_tests_common.h" +#include "usd_writer_material.h" + +namespace blender::io::usd { + +const StringRefNull simple_scene_filename = "usd/usd_simple_scene.blend"; +const StringRefNull materials_filename = "usd/usd_materials_export.blend"; +const StringRefNull output_filename = "output.usd"; + + +static const bNode *find_node_for_type_in_graph(const bNodeTree *nodetree, + const blender::StringRefNull type_idname); + + +class UsdExportTest : public BlendfileLoadingBaseTest { + protected: + struct bContext *context = nullptr; + + public: + bool load_file_and_depsgraph(const StringRefNull &filepath, + const eEvaluationMode eval_mode = DAG_EVAL_VIEWPORT) + { + if (!blendfile_load(filepath.c_str())) { + return false; + } + depsgraph_create(eval_mode); + + context = CTX_create(); + CTX_data_main_set(context, bfile->main); + CTX_data_scene_set(context, bfile->curscene); + + return true; + } + + virtual void SetUp() override + { + BlendfileLoadingBaseTest::SetUp(); + std::string usd_plugin_path = register_usd_plugins_for_tests(); + if (usd_plugin_path.empty()) { + FAIL() << "Unable to find the USD Plugins path."; + } + } + + virtual void TearDown() override + { + BlendfileLoadingBaseTest::TearDown(); + CTX_free(context); + context = nullptr; + + if (BLI_exists(output_filename.c_str())) { + BLI_delete(output_filename.c_str(), false, false); + } + } + + const pxr::UsdPrim get_first_child_mesh(const pxr::UsdPrim prim) + { + for (auto child : prim.GetChildren()) { + if (child.IsA()) { + return child; + } + } + return pxr::UsdPrim(); + } + + /* + * Loop the sockets on the Blender bNode, and fail if any of their values do + * not match the equivalent Attribtue values on the UsdPrim. + */ + const void compare_blender_node_to_usd_prim(const bNode *bsdf_node, const pxr::UsdPrim& bsdf_prim) { + ASSERT_NE(bsdf_node, nullptr); + ASSERT_TRUE(bool(bsdf_prim)); + + for (auto socket : bsdf_node->input_sockets()) { + const pxr::TfToken attribute_token = blender::io::usd::token_for_input(socket->name); + if (attribute_token.IsEmpty()) { + /* This socket is not translated between Blender and USD. */ + continue; + } + + const pxr::UsdAttribute bsdf_attribute = bsdf_prim.GetAttribute(attribute_token); + pxr::SdfPathVector paths; + bsdf_attribute.GetConnections(&paths); + if (!paths.empty() || !bsdf_attribute.IsValid()) { + /* Skip if the attribute is connected or has an error. */ + continue; + } + + const float socket_value_f = *socket->default_value_typed(); + const float3 socket_value_3f = *socket->default_value_typed(); + float attribute_value_f; + pxr::GfVec3f attribute_value_3f; + + switch (socket->type) { + case SOCK_FLOAT: + bsdf_attribute.Get(&attribute_value_f, 0.0); + EXPECT_FLOAT_EQ(socket_value_f, attribute_value_f); + break; + + case SOCK_VECTOR: + bsdf_attribute.Get(&attribute_value_3f, 0.0); + EXPECT_FLOAT_EQ(socket_value_3f[0], attribute_value_3f[0]); + EXPECT_FLOAT_EQ(socket_value_3f[1], attribute_value_3f[1]); + EXPECT_FLOAT_EQ(socket_value_3f[2], attribute_value_3f[2]); + break; + + case SOCK_RGBA: + bsdf_attribute.Get(&attribute_value_3f, 0.0); + EXPECT_FLOAT_EQ(socket_value_3f[0], attribute_value_3f[0]); + EXPECT_FLOAT_EQ(socket_value_3f[1], attribute_value_3f[1]); + EXPECT_FLOAT_EQ(socket_value_3f[2], attribute_value_3f[2]); + break; + + default: + FAIL() << "Socket " << socket->name << " has unsupported type " << socket->type; + break; + } + } + } + + const void compare_blender_image_to_usd_image_shader(const bNode *image_node, const pxr::UsdPrim& image_prim) { + const Image *image = reinterpret_cast(image_node->id); + + const pxr::UsdShadeShader image_shader(image_prim); + const pxr::UsdShadeInput file_input = image_shader.GetInput(pxr::TfToken("file")); + EXPECT_TRUE(bool(file_input)); + + pxr::VtValue file_val; + EXPECT_TRUE(file_input.Get(&file_val)); + EXPECT_TRUE(file_val.IsHolding()); + + pxr::SdfAssetPath image_prim_asset = file_val.Get(); + + /* The path is expected to be relative, but that means in Blender the + * path will start with //. + */ + EXPECT_EQ(BLI_path_cmp_normalized(image->filepath+2, image_prim_asset.GetAssetPath().c_str()), 0); + } + + /* + * Determine if a Blender Mesh matches a UsdGeomMesh prim by checking counts + * on vertices, faces, face indices, and normals. + */ + const void compare_blender_mesh_to_usd_prim(const Mesh *mesh, const pxr::UsdGeomMesh& mesh_prim) { + pxr::VtIntArray face_indices; + pxr::VtIntArray face_counts; + pxr::VtVec3fArray positions; + pxr::VtVec3fArray normals; + + /* Our export doesn't use 'primvars:normals' so we're not + * looking for that to be written here. */ + mesh_prim.GetFaceVertexIndicesAttr().Get(&face_indices, 0.0); + mesh_prim.GetFaceVertexCountsAttr().Get(&face_counts, 0.0); + mesh_prim.GetPointsAttr().Get(&positions, 0.0); + mesh_prim.GetNormalsAttr().Get(&normals, 0.0); + + EXPECT_EQ(mesh->totvert, positions.size()); + EXPECT_EQ(mesh->totpoly, face_counts.size()); + EXPECT_EQ(mesh->totloop, face_indices.size()); + EXPECT_EQ(mesh->totloop, normals.size()); + } + +}; + + +TEST_F(UsdExportTest, usd_export_rain_mesh) +{ + if (!load_file_and_depsgraph(simple_scene_filename)) { + FAIL() << "Unable to load file: " << simple_scene_filename; + return; + } + + /* File sanity check. */ + EXPECT_EQ(BLI_listbase_count(&bfile->main->objects), 3); + + USDExportParams params{}; + params.export_normals = true; + params.visible_objects_only = true; + params.evaluation_mode = eEvaluationMode::DAG_EVAL_VIEWPORT; + + bool result = USD_export(context, output_filename.c_str(), ¶ms, false); + ASSERT_TRUE(result) << "Writing to " << output_filename << " failed!"; + + pxr::UsdStageRefPtr stage = pxr::UsdStage::Open(output_filename); + ASSERT_TRUE(bool(stage)) << "Unable to load Stage from " << output_filename; + + /* + * Run the mesh comparison for all Meshes in the original scene. + */ + LISTBASE_FOREACH (Object *, object, &bfile->main->objects) { + const Mesh *mesh = static_cast(object->data); + const StringRefNull object_name(object->id.name + 2); + + const pxr::SdfPath sdf_path("/" + pxr::TfMakeValidIdentifier(object_name.c_str())); + pxr::UsdPrim prim = stage->GetPrimAtPath(sdf_path); + EXPECT_TRUE(bool(prim)); + + const pxr::UsdGeomMesh mesh_prim(get_first_child_mesh(prim)); + EXPECT_TRUE(bool(mesh_prim)); + + compare_blender_mesh_to_usd_prim(mesh, mesh_prim); + } +} + + +static const bNode *find_node_for_type_in_graph(const bNodeTree *nodetree, const blender::StringRefNull type_idname) +{ + auto found_nodes = nodetree->nodes_by_type(type_idname); + if (found_nodes.size() == 1) { + return found_nodes[0]; + } + + return nullptr; +} + + +/* + * Export Material test-- export a scene with a material, then read it back + * in and check that the BSDF and Image Texture nodes translated correctly + * by comparing values between the exported USD stage and the objects in + * memory. + */ +TEST_F(UsdExportTest, usd_export_material) +{ + if (!load_file_and_depsgraph(materials_filename)) { + FAIL() << "Unable to load file: " << materials_filename; + return; + } + + /* File sanity checks. */ + EXPECT_EQ(BLI_listbase_count(&bfile->main->objects), 1); + /* There are two materials because of the Dots Stroke. */ + EXPECT_EQ(BLI_listbase_count(&bfile->main->materials), 2); + + Material *material = reinterpret_cast(BKE_libblock_find_name(bfile->main, ID_MA, "Material")); + + EXPECT_TRUE(bool(material)); + + USDExportParams params{}; + params.export_normals = true; + params.export_materials = true; + params.generate_preview_surface = true; + params.export_uvmaps = true; + params.evaluation_mode = eEvaluationMode::DAG_EVAL_VIEWPORT; + + const bool result = USD_export(context, output_filename.c_str(), ¶ms, false); + ASSERT_TRUE(result) << "Unable to export stage to " << output_filename; + + pxr::UsdStageRefPtr stage = pxr::UsdStage::Open(output_filename); + ASSERT_NE(stage, nullptr) << "Unable to open exported stage: " << output_filename; + + material->nodetree->ensure_topology_cache(); + const bNode *bsdf_node = find_node_for_type_in_graph(material->nodetree, + "ShaderNodeBsdfPrincipled"); + + const std::string prim_name = pxr::TfMakeValidIdentifier(bsdf_node->name); + const pxr::UsdPrim bsdf_prim = stage->GetPrimAtPath( + pxr::SdfPath("/_materials/Material/preview/" + prim_name)); + + compare_blender_node_to_usd_prim(bsdf_node, bsdf_prim); + + const bNode *image_node = find_node_for_type_in_graph(material->nodetree, "ShaderNodeTexImage"); + ASSERT_NE(image_node, nullptr); + ASSERT_NE(image_node->storage, nullptr); + + + const std::string image_prim_name = pxr::TfMakeValidIdentifier(image_node->name); + + const pxr::UsdPrim image_prim = stage->GetPrimAtPath( + pxr::SdfPath("/_materials/Material/preview/" + image_prim_name)); + + ASSERT_TRUE(bool(image_prim)) << "Unable to find Material prim from exported stage " << output_filename; + + compare_blender_image_to_usd_image_shader(image_node, image_prim); +} + +} // namespace blender::io::usd diff --git a/tests/python/bl_usd_import_test.py b/tests/python/bl_usd_import_test.py index 95b2328b2aa..8639cbd61a2 100644 --- a/tests/python/bl_usd_import_test.py +++ b/tests/python/bl_usd_import_test.py @@ -24,24 +24,132 @@ class AbstractUSDTest(unittest.TestCase): class USDImportTest(AbstractUSDTest): + def test_import_operator(self): + """Test running the import operator on valid and invalid files.""" + + infile = str(self.testdir / "usd_mesh_polygon_types.usda") + res = bpy.ops.wm.usd_import(filepath=infile) + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + + infile = str(self.testdir / "this_file_doesn't_exist.usda") + res = bpy.ops.wm.usd_import(filepath=infile) + self.assertEqual({'CANCELLED'}, res, "Was somehow able to import a non-existent USD file!") + def test_import_prim_hierarchy(self): """Test importing a simple object hierarchy from a USDA file.""" infile = str(self.testdir / "prim-hierarchy.usda") res = bpy.ops.wm.usd_import(filepath=infile) - self.assertEqual({'FINISHED'}, res) + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") objects = bpy.context.scene.collection.objects - self.assertEqual(5, len(objects)) + self.assertEqual(5, len(objects), f"Test scene {infile} should have five objects; found {len(objects)}") # Test the hierarchy. - self.assertIsNone(objects['World'].parent) - self.assertEqual(objects['World'], objects['Plane'].parent) - self.assertEqual(objects['World'], objects['Plane_001'].parent) - self.assertEqual(objects['World'], objects['Empty'].parent) - self.assertEqual(objects['Empty'], objects['Plane_002'].parent) + self.assertIsNone(objects['World'].parent, "/World should not be parented.") + self.assertEqual(objects['World'], objects['Plane'].parent, "Plane should be child of /World") + self.assertEqual(objects['World'], objects['Plane_001'].parent, "Plane_001 should be a child of /World") + self.assertEqual(objects['World'], objects['Empty'].parent, "Empty should be a child of /World") + self.assertEqual(objects['Empty'], objects['Plane_002'].parent, "Plane_002 should be a child of /World") + def test_import_mesh_topology(self): + """Test importing meshes with different polygon types.""" + + infile = str(self.testdir / "usd_mesh_polygon_types.usda") + res = bpy.ops.wm.usd_import(filepath=infile) + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + + objects = bpy.context.scene.collection.objects + self.assertEqual(5, len(objects), f"Test scene {infile} should have five objects; found {len(objects)}") + + # Test topology counts. + self.assertIn("m_degenerate", objects, "Scene does not contain object m_degenerate") + mesh = objects["m_degenerate"].data + self.assertEqual(len(mesh.polygons), 2) + self.assertEqual(len(mesh.edges), 7) + self.assertEqual(len(mesh.vertices), 6) + + self.assertIn("m_triangles", objects, "Scene does not contain object m_triangles") + mesh = objects["m_triangles"].data + self.assertEqual(len(mesh.polygons), 2) + self.assertEqual(len(mesh.edges), 5) + self.assertEqual(len(mesh.vertices), 4) + self.assertEqual(len(mesh.polygons[0].vertices), 3) + + self.assertIn("m_quad", objects, "Scene does not contain object m_quad") + mesh = objects["m_quad"].data + self.assertEqual(len(mesh.polygons), 1) + self.assertEqual(len(mesh.edges), 4) + self.assertEqual(len(mesh.vertices), 4) + self.assertEqual(len(mesh.polygons[0].vertices), 4) + + self.assertIn("m_ngon_concave", objects, "Scene does not contain object m_ngon_concave") + mesh = objects["m_ngon_concave"].data + self.assertEqual(len(mesh.polygons), 1) + self.assertEqual(len(mesh.edges), 5) + self.assertEqual(len(mesh.vertices), 5) + self.assertEqual(len(mesh.polygons[0].vertices), 5) + + self.assertIn("m_ngon_convex", objects, "Scene does not contain object m_ngon_convex") + mesh = objects["m_ngon_convex"].data + self.assertEqual(len(mesh.polygons), 1) + self.assertEqual(len(mesh.edges), 5) + self.assertEqual(len(mesh.vertices), 5) + self.assertEqual(len(mesh.polygons[0].vertices), 5) + + def test_import_mesh_uv_maps(self): + """Test importing meshes with udim UVs and multiple UV sets.""" + + infile = str(self.testdir / "usd_mesh_udim.usda") + res = bpy.ops.wm.usd_import(filepath=infile) + self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {infile}") + + objects = bpy.context.scene.collection.objects + if "preview" in bpy.data.objects: + bpy.data.objects.remove(bpy.data.objects["preview"]) + self.assertEqual(1, len(objects), f"File {infile} should contain one object, found {len(objects)}") + + mesh = bpy.data.objects["uvmap_plane"].data + self.assertEqual(len(mesh.uv_layers), 2, f"Object uvmap_plane should have two uv layers, found {len(mesh.uv_layers)}") + + expected_layer_names = {"udim_map", "uvmap"} + imported_layer_names = set(mesh.uv_layers.keys()) + self.assertEqual(expected_layer_names, imported_layer_names, f"Expected layer names ({expected_layer_names}) not found on uvmap_plane.") + + def get_coords(data): + coords = [x.uv for x in uvmap] + return coords + + def uv_min_max(data): + coords = get_coords(data) + uv_min_x = min([uv[0] for uv in coords]) + uv_max_x = max([uv[0] for uv in coords]) + uv_min_y = min([uv[1] for uv in coords]) + uv_max_y = max([uv[1] for uv in coords]) + return uv_min_x, uv_max_x, uv_min_y, uv_max_y + + ## Quick tests for point range. + uvmap = mesh.uv_layers["uvmap"].data + self.assertEqual(len(uvmap), 128) + min_x, max_x, min_y, max_y = uv_min_max(uvmap) + self.assertGreaterEqual(min_x, 0.0) + self.assertGreaterEqual(min_y, 0.0) + self.assertLessEqual(max_x, 1.0) + self.assertLessEqual(max_y, 1.0) + + uvmap = mesh.uv_layers["udim_map"].data + self.assertEqual(len(uvmap), 128) + min_x, max_x, min_y, max_y = uv_min_max(uvmap) + self.assertGreaterEqual(min_x, 0.0) + self.assertGreaterEqual(min_y, 0.0) + self.assertLessEqual(max_x, 2.0) + self.assertLessEqual(max_y, 1.0) + + ## Make sure at least some points are in a udim tile. + coords = get_coords(uvmap) + coords = list(filter(lambda x: x[0] > 1.0, coords)) + self.assertGreater(len(coords), 16) def main(): global args