From 1ad083dabf9791136485e17e773584075dc5d2e8 Mon Sep 17 00:00:00 2001 From: Aras Pranckevicius Date: Wed, 15 Jan 2025 05:52:15 +0100 Subject: [PATCH] Tests: Add FBX import tests, switch OBJ/PLY/STL import tests to the same machinery "Expected textual data output" comparison based tests for FBX, OBJ, PLY, STL import. - There's a tests/python/modules/io_report.py that can produce a "fairly short text description of the scene" (meshes, objects, curves, cameras, lights, materials, armatures, actions, images). About each object, it lists some basic information (e.g. number of vertices in the mesh), plus a small slice of "data" (e.g. first few values of each mesh attribute). - Custom import parameters, if needed, can be provided by having a sidecar .json file next to imported file (same basename, json extension), that would have a single json object with custom arguments. - Add FBX test coverage, with 46 fairly small files (total size 3.8MB) covering various possible cases (meshes, animations, materials, hierarchies, cameras, etc. etc.). - Switch OBJ/PLY/STL import tests to the above machinery, remove C++ testing code. Pull Request: https://projects.blender.org/blender/blender/pulls/132624 --- .../io/ply/tests/io_ply_importer_test.cc | 325 +----- source/blender/io/stl/CMakeLists.txt | 1 - .../io/stl/tests/stl_importer_tests.cc | 124 --- .../wavefront_obj/tests/obj_importer_tests.cc | 944 +----------------- .../tests/obj_mtl_parser_tests.cc | 68 -- tests/data | 2 +- tests/python/CMakeLists.txt | 53 + tests/python/io_fbx_import_test.py | 60 ++ tests/python/io_obj_import_test.py | 59 ++ tests/python/io_ply_import_test.py | 60 ++ tests/python/io_stl_import_test.py | 60 ++ tests/python/modules/io_report.py | 633 ++++++++++++ 12 files changed, 998 insertions(+), 1391 deletions(-) delete mode 100644 source/blender/io/stl/tests/stl_importer_tests.cc create mode 100644 tests/python/io_fbx_import_test.py create mode 100644 tests/python/io_obj_import_test.py create mode 100644 tests/python/io_ply_import_test.py create mode 100644 tests/python/io_stl_import_test.py create mode 100644 tests/python/modules/io_report.py diff --git a/source/blender/io/ply/tests/io_ply_importer_test.cc b/source/blender/io/ply/tests/io_ply_importer_test.cc index aafd3c765b3..6807ca4e0b4 100644 --- a/source/blender/io/ply/tests/io_ply_importer_test.cc +++ b/source/blender/io/ply/tests/io_ply_importer_test.cc @@ -1,11 +1,10 @@ -/* SPDX-FileCopyrightText: 2023 Blender Authors +/* SPDX-FileCopyrightText: 2023-2025 Blender Authors * * SPDX-License-Identifier: GPL-2.0-or-later */ #include "testing/testing.h" -#include "BLI_fileops.hh" -#include "BLI_hash_mm2a.hh" +#include "BLI_path_utils.hh" #include "ply_import.hh" #include "ply_import_buffer.hh" @@ -13,291 +12,61 @@ namespace blender::io::ply { -struct Expectation { - int totvert, faces_num, totindex, totedge; - uint16_t polyhash = 0, edgehash = 0; - float3 vert_first, vert_last; - float3 normal_first = {0, 0, 0}; - float2 uv_first = {0, 0}; - float4 color_first = {-1, -1, -1, -1}; -}; +/* Extensive tests for PLY importing are in `io_ply_import_test.py`. + * The tests here are only for testing PLY reader buffer refill behavior, + * by using a very small buffer size on purpose. */ -class PLYImportTest : public testing::Test { - public: - void import_and_check(const char *path, const Expectation &exp) - { - std::string ply_path = blender::tests::flags_test_asset_dir() + - SEP_STR "io_tests" SEP_STR "ply" SEP_STR + path; +TEST(ply_import, BufferRefillTest) +{ + std::string ply_path_a = blender::tests::flags_test_asset_dir() + + SEP_STR "io_tests" SEP_STR "ply" SEP_STR + "ASCII_wireframe_cube.ply"; + std::string ply_path_b = blender::tests::flags_test_asset_dir() + + SEP_STR "io_tests" SEP_STR "ply" SEP_STR + "wireframe_cube.ply"; - /* Use a small read buffer size for better coverage of buffer refilling behavior. */ - PlyReadBuffer infile(ply_path.c_str(), 128); - PlyHeader header; - const char *header_err = read_header(infile, header); - if (header_err != nullptr) { - ADD_FAILURE(); - return; - } - std::unique_ptr data = import_ply_data(infile, header); - if (!data->error.empty()) { - fprintf(stderr, "%s\n", data->error.c_str()); - ASSERT_EQ(0, exp.totvert); - ASSERT_EQ(0, exp.faces_num); - return; - } - - /* Test expected amount of vertices, edges, and faces. */ - ASSERT_EQ(data->vertices.size(), exp.totvert); - ASSERT_EQ(data->edges.size(), exp.totedge); - ASSERT_EQ(data->face_sizes.size(), exp.faces_num); - ASSERT_EQ(data->face_vertices.size(), exp.totindex); - - /* Test hash of face and edge index data. */ - BLI_HashMurmur2A hash; - BLI_hash_mm2a_init(&hash, 0); - uint32_t offset = 0; - for (uint32_t face_size : data->face_sizes) { - BLI_hash_mm2a_add(&hash, (const uchar *)&data->face_vertices[offset], face_size * 4); - offset += face_size; - } - uint16_t face_hash = BLI_hash_mm2a_end(&hash); - if (!data->face_vertices.is_empty()) { - ASSERT_EQ(face_hash, exp.polyhash); - } - - if (!data->edges.is_empty()) { - uint16_t edge_hash = BLI_hash_mm2( - (const uchar *)data->edges.data(), data->edges.size() * sizeof(data->edges[0]), 0); - ASSERT_EQ(edge_hash, exp.edgehash); - } - - /* Test if first and last vertices match. */ - EXPECT_V3_NEAR(data->vertices.first(), exp.vert_first, 0.0001f); - EXPECT_V3_NEAR(data->vertices.last(), exp.vert_last, 0.0001f); - - /* Check if first normal matches. */ - float3 got_normal = data->vertex_normals.is_empty() ? float3(0, 0, 0) : - data->vertex_normals.first(); - EXPECT_V3_NEAR(got_normal, exp.normal_first, 0.0001f); - - /* Check if first UV matches. */ - float2 got_uv = data->uv_coordinates.is_empty() ? float2(0, 0) : data->uv_coordinates.first(); - EXPECT_V2_NEAR(got_uv, exp.uv_first, 0.0001f); - - /* Check if first color matches. */ - float4 got_color = data->vertex_colors.is_empty() ? float4(-1, -1, -1, -1) : - data->vertex_colors.first(); - EXPECT_V4_NEAR(got_color, exp.color_first, 0.0001f); + /* Use a small read buffer size to test buffer refilling behavior. */ + constexpr size_t buffer_size = 50; + PlyReadBuffer infile_a(ply_path_a.c_str(), buffer_size); + PlyReadBuffer infile_b(ply_path_b.c_str(), buffer_size); + PlyHeader header_a, header_b; + const char *header_err_a = read_header(infile_a, header_a); + const char *header_err_b = read_header(infile_b, header_b); + if (header_err_a != nullptr || header_err_b != nullptr) { + fprintf(stderr, "Failed to read PLY header\n"); + ADD_FAILURE(); + return; + } + std::unique_ptr data_a = import_ply_data(infile_a, header_a); + std::unique_ptr data_b = import_ply_data(infile_b, header_b); + if (!data_a->error.empty() || !data_b->error.empty()) { + fprintf(stderr, "Failed to read PLY data\n"); + ADD_FAILURE(); + return; } -}; -TEST_F(PLYImportTest, PLYImportCube) -{ - Expectation expect = {24, - 6, - 24, - 0, - 26429, - 0, - float3(1, 1, -1), - float3(-1, 1, 1), - float3(0, 0, -1), - float2(0.979336, 0.844958), - float4(1, 0.8470, 0, 1)}; - import_and_check("cube_ascii.ply", expect); -} - -TEST_F(PLYImportTest, PLYImportWireframeCube) -{ - Expectation expect = {8, 0, 0, 12, 0, 31435, float3(-1, -1, -1), float3(1, 1, 1)}; - import_and_check("ASCII_wireframe_cube.ply", expect); - import_and_check("wireframe_cube.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportBinaryDataStartsWithLF) -{ - Expectation expect = {4, 1, 4, 0, 37235, 0, float3(-1, -1, 0), float3(-1, 1, 0)}; - import_and_check("bin_data_starts_with_lf.ply", expect); - import_and_check("bin_data_starts_with_lf_header_crlf.ply", expect); -} - -TEST_F(PLYImportTest, PLYImportBunny) -{ - Expectation expect = {1623, - 1000, - 3000, - 0, - 62556, - 0, - float3(0.0380425, 0.109755, 0.0161689), - float3(-0.0722821, 0.143895, -0.0129091)}; - import_and_check("bunny2.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportManySmallHoles) -{ - Expectation expect = {2004, - 3524, - 10572, - 0, - 15143, - 0, - float3(-0.0131592, -0.0598382, 1.58958), - float3(-0.0177622, 0.0105153, 1.61977), - float3(0, 0, 0), - float2(0, 0), - float4(0.7215, 0.6784, 0.6627, 1)}; - import_and_check("many_small_holes.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportColorNotFull) -{ - Expectation expect = {4, 1, 4, 0, 37235, 0, float3(1, 0, 1), float3(-1, 0, 1)}; - import_and_check("color_not_full_a.ply", expect); - import_and_check("color_not_full_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportCustomDataElements) -{ - Expectation expect = {600, - 0, - 0, - 0, - 0, - 0, - float3(-0.78193f, 0.40659f, -1), - float3(-0.75537f, 1, -0.24777f), - float3(0, 0, 0), - float2(0, 0), - float4(0.31373f, 0, 0, 1)}; - import_and_check("custom_data_elements.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportDoubleXYZ) -{ - Expectation expect = {4, - 1, - 4, - 0, - 37235, - 0, - float3(1, 0, 1), - float3(-1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(1, 0, 0, 1)}; - import_and_check("double_xyz_a.ply", expect); - import_and_check("double_xyz_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportFaceIndicesNotFirstProp) -{ - Expectation expect = {4, 2, 6, 0, 4136, 0, float3(1, 0, 1), float3(-1, 0, 1)}; - import_and_check("face_indices_not_first_prop_a.ply", expect); - import_and_check("face_indices_not_first_prop_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportFaceIndicesPrecededByList) -{ - Expectation expect = {4, 2, 6, 0, 4136, 0, float3(1, 0, 1), float3(-1, 0, 1)}; - import_and_check("face_indices_preceded_by_list_a.ply", expect); - import_and_check("face_indices_preceded_by_list_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportFaceUVsColors) -{ - Expectation expect = {4, 1, 4, 0, 37235, 0, float3(1, 0, 1), float3(-1, 0, 1)}; - import_and_check("face_uvs_colors_a.ply", expect); - import_and_check("face_uvs_colors_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportFacesFirst) -{ - Expectation expect = {4, - 1, - 4, - 0, - 37235, - 0, - float3(1, 0, 1), - float3(-1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(1, 0, 0, 1)}; - import_and_check("faces_first_a.ply", expect); - import_and_check("faces_first_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportFloatFormats) -{ - Expectation expect = {4, - 1, - 4, - 0, - 37235, - 0, - float3(1, 0, 1), - float3(-1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(0.5f, 0, 0.25f, 1)}; - import_and_check("float_formats_a.ply", expect); - import_and_check("float_formats_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportPositionNotFull) -{ - Expectation expect = {0, 0, 0, 0}; - import_and_check("position_not_full_a.ply", expect); - import_and_check("position_not_full_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportTristrips) -{ - Expectation expect = {6, 4, 12, 0, 3404, 0, float3(1, 0, 1), float3(-3, 0, 1)}; - import_and_check("tristrips_a.ply", expect); - import_and_check("tristrips_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportTypeAliases) -{ - Expectation expect = {4, - 1, - 4, - 0, - 37235, - 0, - float3(1, 0, 1), - float3(-1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(220 / 255.0f, 20 / 255.0f, 20 / 255.0f, 1)}; - import_and_check("type_aliases_a.ply", expect); - import_and_check("type_aliases_b.ply", expect); - import_and_check("type_aliases_be_b.ply", expect); -} - -TEST_F(PLYImportTest, PlyImportVertexCompOrder) -{ - Expectation expect = {4, - 1, - 4, - 0, - 37235, - 0, - float3(1, 0, 1), - float3(-1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(0.8f, 0.2f, 0, 1)}; - import_and_check("vertex_comp_order_a.ply", expect); - import_and_check("vertex_comp_order_b.ply", expect); + /* Check whether the edges list matches expectations. */ + std::pair exp_edges[] = {{2, 0}, + {0, 1}, + {1, 3}, + {3, 2}, + {6, 2}, + {3, 7}, + {7, 6}, + {4, 6}, + {7, 5}, + {5, 4}, + {0, 4}, + {5, 1}}; + EXPECT_EQ(12, data_a->edges.size()); + EXPECT_EQ(12, data_b->edges.size()); + EXPECT_EQ_ARRAY(exp_edges, data_a->edges.data(), 12); + EXPECT_EQ_ARRAY(exp_edges, data_b->edges.data(), 12); } +//@TODO: now we put vertex color attribute first, maybe put position first? //@TODO: test with vertex element having list properties //@TODO: test with edges starting with non-vertex index properties //@TODO: test various malformed headers //@TODO: UVs with: s,t; u,v; texture_u,texture_v; texture_s,texture_t (from miniply) //@TODO: colors with: r,g,b in addition to red,green,blue (from miniply) -//@TODO: importing bunny2 with old importer results in smooth shading; flat shading with new one } // namespace blender::io::ply diff --git a/source/blender/io/stl/CMakeLists.txt b/source/blender/io/stl/CMakeLists.txt index 4ded38a5ed0..edca6808ea3 100644 --- a/source/blender/io/stl/CMakeLists.txt +++ b/source/blender/io/stl/CMakeLists.txt @@ -54,7 +54,6 @@ blender_add_lib(bf_io_stl "${SRC}" "${INC}" "${INC_SYS}" "${LIB}") if(WITH_GTESTS) set(TEST_SRC tests/stl_exporter_tests.cc - tests/stl_importer_tests.cc ) set(TEST_INC diff --git a/source/blender/io/stl/tests/stl_importer_tests.cc b/source/blender/io/stl/tests/stl_importer_tests.cc deleted file mode 100644 index 2182f7ae5cc..00000000000 --- a/source/blender/io/stl/tests/stl_importer_tests.cc +++ /dev/null @@ -1,124 +0,0 @@ -/* SPDX-FileCopyrightText: 2023 Blender Authors - * - * SPDX-License-Identifier: Apache-2.0 */ - -#include "tests/blendfile_loading_base_test.h" - -#include "BKE_mesh.hh" -#include "BKE_object.hh" - -#include "BLI_math_base.h" -#include "BLI_math_vector_types.hh" -#include "BLI_string.h" - -#include "BLO_readfile.hh" - -#include "DEG_depsgraph_query.hh" - -#include "stl_import.hh" - -namespace blender::io::stl { - -struct Expectation { - int verts_num, edges_num, faces_num, corners_num; - float3 vert_first, vert_last; -}; - -class stl_importer_test : public BlendfileLoadingBaseTest { - public: - stl_importer_test() - { - params.forward_axis = IO_AXIS_NEGATIVE_Z; - params.up_axis = IO_AXIS_Y; - } - - void import_and_check(const char *path, const Expectation &expect) - { - if (!blendfile_load("io_tests" SEP_STR "blend_geometry" SEP_STR "all_quads.blend")) { - ADD_FAILURE(); - return; - } - - std::string stl_path = blender::tests::flags_test_asset_dir() + - SEP_STR "io_tests" SEP_STR "stl" SEP_STR + path; - STRNCPY(params.filepath, stl_path.c_str()); - importer_main(bfile->main, bfile->curscene, bfile->cur_view_layer, params); - - depsgraph_create(DAG_EVAL_VIEWPORT); - - DEGObjectIterSettings deg_iter_settings{}; - deg_iter_settings.depsgraph = depsgraph; - deg_iter_settings.flags = DEG_ITER_OBJECT_FLAG_LINKED_DIRECTLY | - DEG_ITER_OBJECT_FLAG_LINKED_VIA_SET | DEG_ITER_OBJECT_FLAG_VISIBLE | - DEG_ITER_OBJECT_FLAG_DUPLI; - - constexpr bool print_result_scene = false; - if (print_result_scene) { - printf("Result was:\n"); - DEG_OBJECT_ITER_BEGIN (°_iter_settings, object) { - printf(" {"); - if (object->type == OB_MESH) { - Mesh *mesh = BKE_object_get_evaluated_mesh(object); - const Span positions = mesh->vert_positions(); - printf("%i, %i, %i, %i, float3(%g, %g, %g), float3(%g, %g, %g)", - mesh->verts_num, - mesh->edges_num, - mesh->faces_num, - mesh->corners_num, - positions.first().x, - positions.first().y, - positions.first().z, - positions.last().x, - positions.last().y, - positions.last().z); - } - printf("},\n"); - } - DEG_OBJECT_ITER_END; - } - - size_t object_index = 0; - DEG_OBJECT_ITER_BEGIN (°_iter_settings, object) { - ++object_index; - /* First object is from loaded scene. */ - if (object_index == 1) { - continue; - } - EXPECT_V3_NEAR(object->loc, float3(0, 0, 0), 0.0001f); - EXPECT_V3_NEAR(object->rot, float3(M_PI_2, 0, 0), 0.0001f); - EXPECT_V3_NEAR(object->scale, float3(1, 1, 1), 0.0001f); - Mesh *mesh = BKE_object_get_evaluated_mesh(object); - EXPECT_EQ(mesh->verts_num, expect.verts_num); - EXPECT_EQ(mesh->edges_num, expect.edges_num); - EXPECT_EQ(mesh->faces_num, expect.faces_num); - EXPECT_EQ(mesh->corners_num, expect.corners_num); - const Span positions = mesh->vert_positions(); - EXPECT_V3_NEAR(positions.first(), expect.vert_first, 0.0001f); - EXPECT_V3_NEAR(positions.last(), expect.vert_last, 0.0001f); - break; - } - DEG_OBJECT_ITER_END; - } - - STLImportParams params; -}; - -TEST_F(stl_importer_test, all_quads) -{ - Expectation expect = {8, 18, 12, 36, float3(1, 1, 1), float3(1, -1, 1)}; - import_and_check("all_quads.stl", expect); -} - -TEST_F(stl_importer_test, cubes_positioned) -{ - Expectation expect = {24, 54, 36, 108, float3(1, 1, 1), float3(5.49635f, 0.228398f, -1.11237f)}; - import_and_check("cubes_positioned.stl", expect); -} - -TEST_F(stl_importer_test, non_uniform_scale) -{ - Expectation expect = {140, 378, 252, 756, float3(0, 0, -0.3f), float3(-0.866025f, -1.5f, 0)}; - import_and_check("non_uniform_scale.stl", expect); -} - -} // namespace blender::io::stl diff --git a/source/blender/io/wavefront_obj/tests/obj_importer_tests.cc b/source/blender/io/wavefront_obj/tests/obj_importer_tests.cc index 5d62d1cda0c..3ab916735b5 100644 --- a/source/blender/io/wavefront_obj/tests/obj_importer_tests.cc +++ b/source/blender/io/wavefront_obj/tests/obj_importer_tests.cc @@ -1,943 +1,49 @@ -/* SPDX-FileCopyrightText: 2023 Blender Authors +/* SPDX-FileCopyrightText: 2023-2025 Blender Authors * * SPDX-License-Identifier: Apache-2.0 */ #include #include "testing/testing.h" -#include "tests/blendfile_loading_base_test.h" -#include "BKE_curve.hh" -#include "BKE_customdata.hh" -#include "BKE_main.hh" -#include "BKE_material.hh" -#include "BKE_mesh.hh" -#include "BKE_object.hh" -#include "BKE_scene.hh" - -#include "BLI_listbase.h" -#include "BLI_math_base.h" -#include "BLI_math_vector_types.hh" #include "BLI_string.h" -#include "BLO_readfile.hh" - -#include "DEG_depsgraph.hh" -#include "DEG_depsgraph_query.hh" - -#include "DNA_curve_types.h" -#include "DNA_material_types.h" -#include "DNA_mesh_types.h" - -#include "MEM_guardedalloc.h" +#include "CLG_log.h" +#include "obj_import_file_reader.hh" #include "obj_importer.hh" namespace blender::io::obj { -struct Expectation { - std::string name; - short type; /* OB_MESH, ... */ - int totvert, mesh_edges_num_or_curve_endp, mesh_faces_num_or_curve_order, - mesh_corner_num_or_curve_cyclic; - float3 vert_first, vert_last; - float3 normal_first; - float2 uv_first; - float4 color_first = {-1, -1, -1, -1}; - std::string first_mat; -}; +/* Extensive tests for OBJ importing are in `io_obj_import_test.py`. + * The tests here are only for testing OBJ reader buffer refill behavior, + * by using a very small buffer size on purpose. */ -class OBJImportTest : public BlendfileLoadingBaseTest { - public: - void import_and_check(const char *path, - const Expectation *expect, - size_t expect_count, - int expect_mat_count, - int expect_image_count = 0) - { - if (!blendfile_load("io_tests" SEP_STR "blend_geometry" SEP_STR "all_quads.blend")) { - ADD_FAILURE(); - return; - } - - std::string obj_path = blender::tests::flags_test_asset_dir() + - SEP_STR "io_tests" SEP_STR "obj" SEP_STR + path; - STRNCPY(params.filepath, obj_path.c_str()); - const size_t read_buffer_size = 650; - importer_main(bfile->main, bfile->curscene, bfile->cur_view_layer, params, read_buffer_size); - - depsgraph_create(DAG_EVAL_VIEWPORT); - - DEGObjectIterSettings deg_iter_settings{}; - deg_iter_settings.depsgraph = depsgraph; - deg_iter_settings.flags = DEG_ITER_OBJECT_FLAG_LINKED_DIRECTLY | - DEG_ITER_OBJECT_FLAG_LINKED_VIA_SET | DEG_ITER_OBJECT_FLAG_VISIBLE | - DEG_ITER_OBJECT_FLAG_DUPLI; - - constexpr bool print_result_scene = false; - if (print_result_scene) { - printf("Result was:\n"); - DEG_OBJECT_ITER_BEGIN (°_iter_settings, object) { - printf(" {\"%s\", ", object->id.name); - if (object->type == OB_MESH) { - Mesh *mesh = BKE_object_get_evaluated_mesh(object); - const Span positions = mesh->vert_positions(); - printf("OB_MESH, %i, %i, %i, %i, float3(%g, %g, %g), float3(%g, %g, %g)", - mesh->verts_num, - mesh->edges_num, - mesh->faces_num, - mesh->corners_num, - positions.first().x, - positions.first().y, - positions.first().z, - positions.last().x, - positions.last().y, - positions.last().z); - } - printf("},\n"); - } - DEG_OBJECT_ITER_END; - } - - size_t object_index = 0; - DEG_OBJECT_ITER_BEGIN (°_iter_settings, object) { - if (object_index >= expect_count) { - ADD_FAILURE(); - break; - } - const Expectation &exp = expect[object_index]; - ASSERT_STREQ(object->id.name, exp.name.c_str()); - EXPECT_EQ(object->type, exp.type); - EXPECT_V3_NEAR(object->loc, float3(0, 0, 0), 0.0001f); - if (!STREQ(object->id.name, "OBCube")) { - EXPECT_V3_NEAR(object->rot, float3(M_PI_2, 0, 0), 0.0001f); - } - EXPECT_V3_NEAR(object->scale, float3(1, 1, 1), 0.0001f); - if (object->type == OB_MESH) { - Mesh *mesh = BKE_object_get_evaluated_mesh(object); - EXPECT_EQ(mesh->verts_num, exp.totvert); - EXPECT_EQ(mesh->edges_num, exp.mesh_edges_num_or_curve_endp); - EXPECT_EQ(mesh->faces_num, exp.mesh_faces_num_or_curve_order); - EXPECT_EQ(mesh->corners_num, exp.mesh_corner_num_or_curve_cyclic); - const Span positions = mesh->vert_positions(); - EXPECT_V3_NEAR(positions.first(), exp.vert_first, 0.0001f); - EXPECT_V3_NEAR(positions.last(), exp.vert_last, 0.0001f); - const float3 *corner_normals = mesh->normals_domain() == bke::MeshNormalDomain::Corner ? - mesh->corner_normals().data() : - nullptr; - float3 normal_first = corner_normals != nullptr ? corner_normals[0] : float3(0, 0, 0); - EXPECT_V3_NEAR(normal_first, exp.normal_first, 0.0001f); - const float2 *mloopuv = static_cast( - CustomData_get_layer(&mesh->corner_data, CD_PROP_FLOAT2)); - float2 uv_first = mloopuv ? *mloopuv : float2(0, 0); - EXPECT_V2_NEAR(uv_first, exp.uv_first, 0.0001f); - if (exp.color_first.x >= 0) { - const float4 *colors = (const float4 *)CustomData_get_layer(&mesh->vert_data, - CD_PROP_COLOR); - EXPECT_TRUE(colors != nullptr); - EXPECT_V4_NEAR(colors[0], exp.color_first, 0.0001f); - } - else { - EXPECT_FALSE(CustomData_has_layer(&mesh->vert_data, CD_PROP_COLOR)); - } - } - if (object->type == OB_CURVES_LEGACY) { - Curve *curve = static_cast(DEG_get_evaluated_object(depsgraph, object)->data); - int numVerts; - float(*vertexCos)[3] = BKE_curve_nurbs_vert_coords_alloc(&curve->nurb, &numVerts); - EXPECT_EQ(numVerts, exp.totvert); - EXPECT_V3_NEAR(vertexCos[0], exp.vert_first, 0.0001f); - EXPECT_V3_NEAR(vertexCos[numVerts - 1], exp.vert_last, 0.0001f); - MEM_freeN(vertexCos); - const Nurb *nurb = static_cast(BLI_findlink(&curve->nurb, 0)); - int endpoint = (nurb->flagu & CU_NURB_ENDPOINT) ? 1 : 0; - EXPECT_EQ(nurb->orderu, exp.mesh_faces_num_or_curve_order); - EXPECT_EQ(endpoint, exp.mesh_edges_num_or_curve_endp); - int cyclic = (nurb->flagu & CU_NURB_CYCLIC) ? 1 : 0; - EXPECT_EQ(cyclic, exp.mesh_corner_num_or_curve_cyclic); - } - if (!exp.first_mat.empty()) { - Material *mat = BKE_object_material_get(object, 1); - ASSERT_STREQ(mat ? mat->id.name : "", exp.first_mat.c_str()); - } - ++object_index; - } - DEG_OBJECT_ITER_END; - EXPECT_EQ(object_index, expect_count); - - /* Check number of materials & textures. */ - const int mat_count = BLI_listbase_count(&bfile->main->materials); - EXPECT_EQ(mat_count, expect_mat_count); - - const int ima_count = BLI_listbase_count(&bfile->main->images); - EXPECT_EQ(ima_count, expect_image_count); - } +TEST(obj_import, BufferRefillTest) +{ + CLG_init(); OBJImportParams params; -}; + /* nurbs_cyclic.obj file has quite long lines, good to test read buffer refill. */ + std::string obj_path = blender::tests::flags_test_asset_dir() + + SEP_STR "io_tests" SEP_STR "obj" SEP_STR + "nurbs_cyclic.obj"; + STRNCPY(params.filepath, obj_path.c_str()); -TEST_F(OBJImportTest, import_cube) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBcube", - OB_MESH, - 8, - 12, - 6, - 24, - float3(-1, -1, 1), - float3(1, -1, -1), - float3(-0.57758f, 0.57735f, -0.57711f)}, - }; - import_and_check("cube.obj", expect, std::size(expect), 1); -} + /* Use a small read buffer size to test buffer refilling behavior. */ + const size_t read_buffer_size = 650; + OBJParser obj_parser{params, read_buffer_size}; -TEST_F(OBJImportTest, import_cube_o_after_verts) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - { - "OBActualCube", - OB_MESH, - 8, - 12, - 6, - 24, - float3(-1, -1, 1), - float3(1, -1, -1), - float3(0.57735f, -0.57735f, 0.57735f), - }, - { - "OBSparseTri", - OB_MESH, - 3, - 3, - 1, - 3, - float3(1, -1, 1), - float3(-2, -2, 2), - float3(-0.2357f, 0.9428f, 0.2357f), - }, - }; - import_and_check("cube_o_after_verts.obj", expect, std::size(expect), 2); -} + Vector> all_geometries; + GlobalVertices global_vertices; + obj_parser.parse(all_geometries, global_vertices); -TEST_F(OBJImportTest, import_suzanne_all_data) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBMonkey", - OB_MESH, - 505, - 1005, - 500, - 1968, - float3(-0.4375f, 0.164062f, 0.765625f), - float3(0.4375f, 0.164062f, 0.765625f), - float3(-0.6040f, -0.5102f, 0.6122f), - float2(0.692094f, 0.40191f)}, - }; - import_and_check("suzanne_all_data.obj", expect, std::size(expect), 0); -} + EXPECT_EQ(1, all_geometries.size()); + EXPECT_EQ(GEOM_CURVE, all_geometries[0]->geom_type_); + EXPECT_EQ(28, global_vertices.vertices.size()); + EXPECT_EQ(31, all_geometries[0]->nurbs_element_.curv_indices.size()); + EXPECT_EQ(35, all_geometries[0]->nurbs_element_.parm.size()); -TEST_F(OBJImportTest, import_nurbs) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBnurbs", - OB_CURVES_LEGACY, - 9, - 0, - 4, - 1, - float3(1.149067f, 0.964181f, -0.866025f), - float3(-1.5f, 2.598076f, 0)}, - }; - import_and_check("nurbs.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_nurbs_curves) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCurveDeg3", OB_CURVES_LEGACY, 4, 0, 3, 0, float3(10, -2, 0), float3(6, -2, 0)}, - {"OBnurbs_curves", OB_CURVES_LEGACY, 4, 0, 4, 0, float3(2, -2, 0), float3(-2, -2, 0)}, - {"OBNurbsCurveCyclic", OB_CURVES_LEGACY, 4, 0, 4, 1, float3(-6, -2, -0), float3(-6, 2, 0)}, - {"OBNurbsCurveDiffWeights", - OB_CURVES_LEGACY, - 4, - 0, - 4, - 0, - float3(6, -2, 0), - float3(2, -2, 0)}, - {"OBNurbsCurveEndpoint", - OB_CURVES_LEGACY, - 4, - 1, - 4, - 0, - float3(-6, -2, 0), - float3(-10, -2, 0)}, - }; - import_and_check("nurbs_curves.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_nurbs_cyclic) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBnurbs_cyclic", - OB_CURVES_LEGACY, - 28, - 0, - 4, - 1, - float3(0.935235f, -0.000000f, 3.518242f), - float3(3.280729f, 0, 3.043217f)}, - }; - import_and_check("nurbs_cyclic.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_nurbs_endpoint) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCurveEndpointRange01", - OB_CURVES_LEGACY, - 15, - 1, - 4, - 0, - float3(0.29f, 0, -0.11f), - float3(22.17f, 0, -5.31f)}, - {"OBCurveEndpointRangeNon01", - OB_CURVES_LEGACY, - 15, - 1, - 4, - 0, - float3(0.29f, 0, -0.11f), - float3(22.17f, 0, -5.31f)}, - {"OBCurveNoEndpointRange01", - OB_CURVES_LEGACY, - 15, - 0, - 4, - 0, - float3(0.29f, 0, -0.11f), - float3(22.17f, 0, -5.31f)}, - }; - import_and_check("nurbs_endpoint.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_nurbs_manual) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCurve_Cyclic", OB_CURVES_LEGACY, 4, 0, 4, 1, float3(-2, 0, -2), float3(2, 0, -2)}, - {"OBCurve_Endpoints", OB_CURVES_LEGACY, 5, 1, 4, 0, float3(-2, 0, 2), float3(-2, 0, 2)}, - {"OBCurve_NonUniform_Parm", - OB_CURVES_LEGACY, - 5, - 0, - 4, - 0, - float3(-2, 0, 2), - float3(-2, 0, 2)}, - {"OBCurve_Uniform_Parm", OB_CURVES_LEGACY, 5, 0, 4, 0, float3(-2, 0, 2), float3(-2, 0, 2)}, - }; - import_and_check("nurbs_manual.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_nurbs_mesh) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBTorus_Knot", - OB_MESH, - 108, - 108, - 0, - 0, - float3(0.438725f, 1.070313f, 0.433013f), - float3(0.625557f, 1.040691f, 0.460328f)}, - }; - import_and_check("nurbs_mesh.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_materials) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBmaterials", - OB_MESH, - 8, - 12, - 6, - 24, - float3(-1, -1, 1), - float3(1, -1, -1), - float3(0), - float2(0), - float4(-1), - "MAno_textures_red"}, - {"OBObjMtlAfter", - OB_MESH, - 3, - 3, - 1, - 3, - float3(3, 0, 0), - float3(5, 0, 0), - float3(0), - float2(0), - float4(-1), - "MAno_textures_red"}, - {"OBObjMtlBefore", - OB_MESH, - 3, - 3, - 1, - 3, - float3(6, 0, 0), - float3(8, 0, 0), - float3(0), - float2(0), - float4(-1), - "MAClay"}, - }; - import_and_check("materials.obj", expect, std::size(expect), 4, 8); -} - -TEST_F(OBJImportTest, import_cubes_with_textures_rel) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCube4Tex", - OB_MESH, - 8, - 12, - 6, - 24, - float3(1, 1, -1), - float3(-1, -1, 1), - float3(0, 1, 0), - float2(0.9935f, 0.0020f), - float4(-1), - "MAMat_BaseRoughEmissNormal10"}, - {"OBCubeTexMul", - OB_MESH, - 8, - 12, - 6, - 24, - float3(4, -2, -1), - float3(2, -4, 1), - float3(0, 1, 0), - float2(0.9935f, 0.0020f), - float4(-1), - "MAMat_BaseMul"}, - {"OBCubeTiledTex", - OB_MESH, - 8, - 12, - 6, - 24, - float3(4, 1, -1), - float3(2, -1, 1), - float3(0, 1, 0), - float2(0.9935f, 0.0020f), - float4(-1), - "MAMat_BaseTiled"}, - {"OBCubeTiledTexFromAnotherFolder", - OB_MESH, - 8, - 12, - 6, - 24, - float3(7, 1, -1), - float3(5, -1, 1), - float3(0, 1, 0), - float2(0.9935f, 0.0020f), - float4(-1), - "MAMat_EmissTiledAnotherFolder"}, - }; - import_and_check("cubes_with_textures_rel.obj", expect, std::size(expect), 4, 4); -} - -TEST_F(OBJImportTest, import_faces_invalid_or_with_holes) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBFaceAllVerts_BecomesOneOverlappingFaceUsingAllVerts", - OB_MESH, - 8, - 8, - 1, - 8, - float3(8, 0, -2), - float3(11, 0, -1)}, - {"OBFaceAllVertsDup_BecomesOneOverlappingFaceUsingAllVerts", - OB_MESH, - 8, - 8, - 1, - 8, - float3(3, 0, 3), - float3(6, 0, 4)}, - {"OBFaceJustTwoVerts_IsSkipped", OB_MESH, 2, 0, 0, 0, float3(8, 0, 3), float3(8, 0, 7)}, - {"OBFaceQuadDupSomeVerts_BecomesOneQuadUsing4Verts", - OB_MESH, - 4, - 4, - 1, - 4, - float3(3, 0, -2), - float3(7, 0, -2)}, - {"OBFaceTriDupVert_Becomes1Tri", OB_MESH, 3, 3, 1, 3, float3(-2, 0, 3), float3(2, 0, 7)}, - {"OBFaceWithHole_BecomesTwoFacesFormingAHole", - OB_MESH, - 8, - 10, - 2, - 12, - float3(-2, 0, -2), - float3(1, 0, -1)}, - }; - import_and_check("faces_invalid_or_with_holes.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_invalid_faces) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBTheMesh", OB_MESH, 5, 3, 1, 3, float3(-2, 0, -2), float3(0, 2, 0)}, - }; - import_and_check("invalid_faces.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_invalid_indices) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBQuad", - OB_MESH, - 3, - 3, - 1, - 3, - float3(-2, 0, -2), - float3(2, 0, 2), - float3(0, 1, 0), - float2(0.5f, 0.25f)}, - }; - import_and_check("invalid_indices.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_invalid_syntax) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBObjectWithAReallyLongNameToCheckHowImportHandlesNamesThatAreLon", - OB_MESH, - 3, - 3, - 1, - 3, - float3(1, 2, 3), - float3(7, 8, 9), - float3(0, 1, 0), - float2(0.5f, 0.25f)}, - }; - import_and_check("invalid_syntax.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_all_objects) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - /* `.obj` file has empty EmptyText and EmptyMesh objects; these are ignored and skipped. */ - {"OBBezierCurve", OB_MESH, 13, 12, 0, 0, float3(-1, -2, 0), float3(1, -2, 0)}, - {"OBBlankCube", OB_MESH, 8, 13, 7, 26, float3(1, 1, -1), float3(-1, 1, 1), float3(0, 0, 1)}, - {"OBMaterialCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(28, 1, -1), - float3(26, 1, 1), - float3(-1, 0, 0), - float2(0), - float4(-1), - "MARed"}, - {"OBNurbsCircle", - OB_MESH, - 96, - 96, - 0, - 0, - float3(3.292893f, -2.707107f, 0), - float3(3.369084f, -2.77607f, 0)}, - {"OBNurbsCircle.001", OB_MESH, 4, 4, 0, 0, float3(2, -3, 0), float3(3, -2, 0)}, - {"OBParticleCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(22, 1, -1), - float3(20, 1, 1), - float3(0, 0, 1)}, - {"OBShapeKeyCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(19, 1, -1), - float3(17, 1, 1), - float3(-0.4082f, -0.4082f, 0.8165f)}, - {"OBSmoothCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(4, 1, -1), - float3(2, 1, 1), - float3(0.5774f, 0.5773f, 0.5774f), - float2(0), - float4(-1), - "MAMaterial"}, - {"OBSurface", - OB_MESH, - 256, - 480, - 224, - 896, - float3(7.292893f, -2.707107f, -1), - float3(7.525872f, -2.883338f, 1), - float3(-0.7071f, -0.7071f, 0), - float2(0, 0.142857f)}, - {"OBSurfPatch", - OB_MESH, - 256, - 480, - 225, - 900, - float3(12.5f, -2.5f, 0.694444f), - float3(13.5f, -1.5f, 0.694444f), - float3(-0.3246f, -0.3531f, 0.8775f), - float2(0, 0.066667f)}, - {"OBSurfSphere", - OB_MESH, - 640, - 1248, - 608, - 2432, - float3(11, -2, -1), - float3(11, -2, 1), - float3(-0.0541f, -0.0541f, -0.9971f), - float2(0, 1)}, - {"OBSurfTorus.001", - OB_MESH, - 1024, - 2048, - 1024, - 4096, - float3(5.34467f, -2.65533f, -0.176777f), - float3(5.232792f, -2.411795f, -0.220835f), - float3(-0.5042f, -0.5042f, -0.7011f), - float2(0, 1)}, - {"OBTaperCube", - OB_MESH, - 106, - 208, - 104, - 416, - float3(24.444445f, 0.502543f, -0.753814f), - float3(23.790743f, 0.460522f, -0.766546f), - float3(-0.0546f, 0.1716f, 0.9837f)}, - {"OBText", - OB_MESH, - 177, - 345, - 171, - 513, - float3(1.75f, -9.458f, 0), - float3(0.587f, -9.406f, 0), - float3(0, 0, 1), - float2(0.017544f, 0)}, - {"OBUVCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(7, 1, -1), - float3(5, 1, 1), - float3(0, 0, 1), - float2(0.654526f, 0.579873f)}, - {"OBUVImageCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(10, 1, -1), - float3(8, 1, 1), - float3(0, 0, 1), - float2(0.654526f, 0.579873f)}, - {"OBVColCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(13, 1, -1), - float3(11, 1, 1), - float3(0, 0, 1), - float2(0, 0), - float4(0.0f, 0.002125f, 1.0f, 1.0f)}, - {"OBVGroupCube", - OB_MESH, - 8, - 13, - 7, - 26, - float3(16, 1, -1), - float3(14, 1, 1), - float3(0, 0, 1)}, - }; - import_and_check("all_objects.obj", expect, std::size(expect), 7); -} - -TEST_F(OBJImportTest, import_cubes_vertex_colors) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCubeCornerByte", - OB_MESH, - 8, - 12, - 6, - 24, - float3(1.0f, 1.0f, -3.812445f), - float3(-1.0f, -1.0f, -1.812445f), - float3(0, 0, 0), - float2(0, 0), - float4(0.89627f, 0.036889f, 0.47932f, 1.0f)}, - {"OBCubeCornerFloat", - OB_MESH, - 8, - 12, - 6, - 24, - float3(3.481967f, 1.0f, -3.812445f), - float3(1.481967f, -1.0f, -1.812445f), - float3(0, 0, 0), - float2(0, 0), - float4(1.564582f, 0.039217f, 0.664309f, 1.0f)}, - {"OBCubeMultiColorAttribs", - OB_MESH, - 8, - 12, - 6, - 24, - float3(-4.725068f, -1.0f, 1.0f), - float3(-2.725068f, 1.0f, -1.0f), - float3(0, 0, 0), - float2(0, 0), - float4(0.270498f, 0.47932f, 0.262251f, 1.0f)}, - {"OBCubeNoColors", - OB_MESH, - 8, - 12, - 6, - 24, - float3(-4.550208f, -1.0f, -1.918042f), - float3(-2.550208f, 1.0f, -3.918042f)}, - {"OBCubeVertexByte", - OB_MESH, - 8, - 12, - 6, - 24, - float3(1.0f, 1.0f, -1.0f), - float3(-1.0f, -1.0f, 1.0f), - float3(0, 0, 0), - float2(0, 0), - float4(0.846873f, 0.027321f, 0.982123f, 1.0f)}, - {"OBCubeVertexFloat", - OB_MESH, - 8, - 12, - 6, - 24, - float3(3.392028f, 1.0f, -1.0f), - float3(1.392028f, -1.0f, 1.0f), - float3(0, 0, 0), - float2(0, 0), - float4(49.99467f, 0.027321f, 0.982123f, 1.0f)}, - }; - import_and_check("cubes_vertex_colors.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_cubes_vertex_colors_mrgb) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBCubeMRGB", - OB_MESH, - 8, - 12, - 6, - 24, - float3(4, 1, -1), - float3(2, -1, 1), - float3(0, 0, 0), - float2(0, 0), - float4(0.8714f, 0.6308f, 0.5271f, 1.0f)}, - {"OBCubeXYZRGB", - OB_MESH, - 8, - 12, - 6, - 24, - float3(1, 1, -1), - float3(-1, -1, 1), - float3(0, 0, 0), - float2(0, 0), - float4(0.6038f, 0.3185f, 0.1329f, 1.0f)}, - {"OBTriMRGB", - OB_MESH, - 3, - 3, - 1, - 3, - float3(12, 1, -1), - float3(10, 0, -1), - float3(0, 0, 0), - float2(0, 0), - float4(1.0f, 0.0f, 0.0f, 1.0f)}, - { - "OBTriNoColors", - OB_MESH, - 3, - 3, - 1, - 3, - float3(8, 1, -1), - float3(6, 0, -1), - }, - }; - import_and_check("cubes_vertex_colors_mrgb.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_vertex_colors_non_contiguous) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBNoColor", - OB_MESH, - 3, - 3, - 1, - 3, - float3(0, 0, 1), - float3(1, 0, 1), - float3(0, 0, 0), - float2(0, 0), - float4(-1, -1, -1, -1)}, - {"OBRed", - OB_MESH, - 3, - 3, - 1, - 3, - float3(0, 0, 0), - float3(1, 0, 0), - float3(0, 0, 0), - float2(0, 0), - float4(1, 0, 0, 1)}, - }; - import_and_check("vertex_colors_non_contiguous.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_vertices) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - /* Loose vertices without faces or edges. */ - {"OBCube.001", OB_MESH, 8, 0, 0, 0, float3(1, 1, -1), float3(-1, 1, 1)}, - }; - import_and_check("vertices.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_split_options_by_object) -{ - /* Default is to split by object */ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBBox", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, -1, 1)}, - {"OBPyramid", OB_MESH, 5, 8, 5, 16, float3(3, 1, -1), float3(4, 0, 2)}, - }; - import_and_check("split_options.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_split_options_by_group) -{ - params.use_split_objects = false; - params.use_split_groups = true; - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBBoxOne", OB_MESH, 4, 4, 1, 4, float3(1, -1, -1), float3(-1, -1, 1)}, - {"OBBoxTwo", OB_MESH, 6, 7, 2, 8, float3(1, 1, 1), float3(-1, -1, 1)}, - {"OBBoxTwo.001", OB_MESH, 6, 7, 2, 8, float3(1, 1, -1), float3(-1, -1, -1)}, - {"OBPyrBottom", OB_MESH, 4, 4, 1, 4, float3(3, 1, -1), float3(3, -1, -1)}, - {"OBPyrSides", OB_MESH, 5, 8, 4, 12, float3(3, 1, -1), float3(4, 0, 2)}, - {"OBsplit_options", OB_MESH, 4, 4, 1, 4, float3(1, 1, -1), float3(-1, 1, 1)}, - }; - import_and_check("split_options.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_split_options_by_object_and_group) -{ - params.use_split_objects = true; - params.use_split_groups = true; - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBBox", OB_MESH, 4, 4, 1, 4, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBBoxOne", OB_MESH, 4, 4, 1, 4, float3(1, -1, -1), float3(-1, -1, 1)}, - {"OBBoxTwo", OB_MESH, 6, 7, 2, 8, float3(1, 1, 1), float3(-1, -1, 1)}, - {"OBBoxTwo.001", OB_MESH, 6, 7, 2, 8, float3(1, 1, -1), float3(-1, -1, -1)}, - {"OBPyrBottom", OB_MESH, 4, 4, 1, 4, float3(3, 1, -1), float3(3, -1, -1)}, - {"OBPyrSides", OB_MESH, 5, 8, 4, 12, float3(3, 1, -1), float3(4, 0, 2)}, - }; - import_and_check("split_options.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_split_options_none) -{ - params.use_split_objects = false; - params.use_split_groups = false; - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBsplit_options", OB_MESH, 13, 20, 11, 40, float3(1, 1, -1), float3(4, 0, 2)}, - }; - import_and_check("split_options.obj", expect, std::size(expect), 0); -} - -TEST_F(OBJImportTest, import_polylines) -{ - Expectation expect[] = { - {"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, - {"OBpolylines", OB_MESH, 13, 8, 0, 0, float3(1, 0, 0), float3(.7, .7, 2)}, - }; - import_and_check("polylines.obj", expect, std::size(expect), 0); + CLG_exit(); } } // namespace blender::io::obj diff --git a/source/blender/io/wavefront_obj/tests/obj_mtl_parser_tests.cc b/source/blender/io/wavefront_obj/tests/obj_mtl_parser_tests.cc index 0ee72e67cd9..8385715cc1c 100644 --- a/source/blender/io/wavefront_obj/tests/obj_mtl_parser_tests.cc +++ b/source/blender/io/wavefront_obj/tests/obj_mtl_parser_tests.cc @@ -142,44 +142,6 @@ TEST_F(OBJMTLParserTest, string_newlines_whitespace) check_string(text, mat, ARRAY_SIZE(mat)); } -TEST_F(OBJMTLParserTest, cube) -{ - MTLMaterial mat; - mat.name = "red"; - mat.ambient_color = {0.2f, 0.2f, 0.2f}; - mat.color = {1, 0, 0}; - check("cube.mtl", &mat, 1); -} - -TEST_F(OBJMTLParserTest, all_objects) -{ - MTLMaterial mat[7]; - for (auto &m : mat) { - m.ambient_color = {1, 1, 1}; - m.spec_color = {0.5f, 0.5f, 0.5f}; - m.emission_color = {0, 0, 0}; - m.spec_exponent = 250; - m.ior = 1; - m.alpha = 1; - m.illum_mode = 2; - } - mat[0].name = "Blue"; - mat[0].color = {0, 0, 1}; - mat[1].name = "BlueDark"; - mat[1].color = {0, 0, 0.5f}; - mat[2].name = "Green"; - mat[2].color = {0, 1, 0}; - mat[3].name = "GreenDark"; - mat[3].color = {0, 0.5f, 0}; - mat[4].name = "Material"; - mat[4].color = {0.8f, 0.8f, 0.8f}; - mat[5].name = "Red"; - mat[5].color = {1, 0, 0}; - mat[6].name = "RedDark"; - mat[6].color = {0.5f, 0, 0}; - check("all_objects.mtl", mat, ARRAY_SIZE(mat)); -} - TEST_F(OBJMTLParserTest, materials) { MTLMaterial mat[6]; @@ -286,36 +248,6 @@ TEST_F(OBJMTLParserTest, materials) check("materials.mtl", mat, ARRAY_SIZE(mat)); } -TEST_F(OBJMTLParserTest, materials_without_pbr) -{ - MTLMaterial mat[2]; - mat[0].name = "Mat1"; - mat[0].spec_exponent = 360.0f; - mat[0].ambient_color = {0.9f, 0.9f, 0.9f}; - mat[0].color = {0.8f, 0.276449f, 0.101911f}; - mat[0].spec_color = {0.25f, 0.25f, 0.25f}; - mat[0].emission_color = {0, 0, 0}; - mat[0].ior = 1.45f; - mat[0].alpha = 1; - mat[0].illum_mode = 3; - - mat[1].name = "Mat2"; - mat[1].ambient_color = {1, 1, 1}; - mat[1].color = {0.8f, 0.8f, 0.8f}; - mat[1].spec_color = {0.5f, 0.5f, 0.5f}; - mat[1].ior = 1.45f; - mat[1].alpha = 1; - mat[1].illum_mode = 2; - { - MTLTexMap &ns = mat[1].tex_map_of_type(MTLTexMapType::SpecularExponent); - ns.image_path = "../blend_geometry/texture_roughness.png"; - MTLTexMap &ke = mat[1].tex_map_of_type(MTLTexMapType::Emission); - ke.image_path = "../blend_geometry/texture_illum.png"; - } - - check("materials_without_pbr.mtl", mat, ARRAY_SIZE(mat)); -} - TEST_F(OBJMTLParserTest, materials_pbr) { MTLMaterial mat[2]; diff --git a/tests/data b/tests/data index eb313866273..355a198573d 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit eb3138662731eb2848b9bf4d2daba95da97e6145 +Subproject commit 355a198573d64b7e493a7c6101df4068ca83dbeb diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 030f3287cf5..8dc59f52cb7 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -47,6 +47,21 @@ function(add_blender_test testname) ) endfunction() +function(add_blender_test_io testname) + # Remove `--debug-exit-on-error` since errors + # are printed on e.g. meshes with invalid data. But + # we do want to test those in import tests. + set(EXE_PARAMS ${TEST_BLENDER_EXE_PARAMS}) + list(REMOVE_ITEM EXE_PARAMS --debug-exit-on-error) + add_blender_test_impl( + "${testname}" + "" + "${TEST_BLENDER_EXE}" + ${EXE_PARAMS} + ${ARGN} + ) +endfunction() + if(WITH_UI_TESTS) set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}") @@ -938,6 +953,44 @@ if(WITH_USD) ) endif() +add_blender_test_io( + io_fbx_import + --python ${CMAKE_CURRENT_LIST_DIR}/io_fbx_import_test.py + -- + --testdir "${TEST_SRC_DIR}/io_tests/fbx" + --outdir "${TEST_OUT_DIR}/io_fbx" +) + +if(WITH_IO_WAVEFRONT_OBJ) + add_blender_test_io( + io_obj_import + --python ${CMAKE_CURRENT_LIST_DIR}/io_obj_import_test.py + -- + --testdir "${TEST_SRC_DIR}/io_tests/obj" + --outdir "${TEST_OUT_DIR}/io_obj" + ) +endif() + +if(WITH_IO_PLY) + add_blender_test_io( + io_ply_import + --python ${CMAKE_CURRENT_LIST_DIR}/io_ply_import_test.py + -- + --testdir "${TEST_SRC_DIR}/io_tests/ply" + --outdir "${TEST_OUT_DIR}/io_ply" + ) +endif() + +if(WITH_IO_STL) + add_blender_test_io( + io_stl_import + --python ${CMAKE_CURRENT_LIST_DIR}/io_stl_import_test.py + -- + --testdir "${TEST_SRC_DIR}/io_tests/stl" + --outdir "${TEST_OUT_DIR}/io_stl" + ) +endif() + if(WITH_CODEC_FFMPEG) add_python_test( ffmpeg diff --git a/tests/python/io_fbx_import_test.py b/tests/python/io_fbx_import_test.py new file mode 100644 index 00000000000..4d83a7c5eae --- /dev/null +++ b/tests/python/io_fbx_import_test.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pathlib +import sys +import unittest + +import bpy + +sys.path.append(str(pathlib.Path(__file__).parent.absolute())) + +args = None + + +class FBXImportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.testdir = args.testdir + cls.output_dir = args.outdir + + def test_import_fbx(self): + input_files = sorted(pathlib.Path(self.testdir).glob("*.fbx")) + self.passed_tests = [] + self.failed_tests = [] + self.updated_tests = [] + + from modules import io_report + report = io_report.Report("FBX Import", self.output_dir, self.testdir, self.testdir.joinpath("reference")) + + for input_file in input_files: + with self.subTest(pathlib.Path(input_file).stem): + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "../empty.blend")) + ok = report.import_and_check( + input_file, lambda filepath, params: bpy.ops.import_scene.fbx( + filepath=str(input_file), use_subsurf=True, use_custom_props=True, **params)) + if not ok: + self.fail(f"{input_file.stem} import result does not match expectations") + + report.finish("io_fbx") + + +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) + parser.add_argument('--outdir', required=True, type=pathlib.Path) + args, remaining = parser.parse_known_args(argv) + + unittest.main(argv=remaining) + + +if __name__ == "__main__": + main() diff --git a/tests/python/io_obj_import_test.py b/tests/python/io_obj_import_test.py new file mode 100644 index 00000000000..38e67de6076 --- /dev/null +++ b/tests/python/io_obj_import_test.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pathlib +import sys +import unittest + +import bpy + +sys.path.append(str(pathlib.Path(__file__).parent.absolute())) + +args = None + + +class OBJImportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.testdir = args.testdir + cls.output_dir = args.outdir + + def test_import_obj(self): + input_files = sorted(pathlib.Path(self.testdir).glob("*.obj")) + self.passed_tests = [] + self.failed_tests = [] + self.updated_tests = [] + + from modules import io_report + report = io_report.Report("OBJ Import", self.output_dir, self.testdir, self.testdir.joinpath("reference")) + + for input_file in input_files: + with self.subTest(pathlib.Path(input_file).stem): + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "../empty.blend")) + ok = report.import_and_check( + input_file, lambda filepath, params: bpy.ops.wm.obj_import(filepath=str(input_file), **params)) + if not ok: + self.fail(f"{input_file.stem} import result does not match expectations") + + report.finish("io_obj") + + +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) + parser.add_argument('--outdir', required=True, type=pathlib.Path) + args, remaining = parser.parse_known_args(argv) + + unittest.main(argv=remaining) + + +if __name__ == "__main__": + main() diff --git a/tests/python/io_ply_import_test.py b/tests/python/io_ply_import_test.py new file mode 100644 index 00000000000..914f24aa292 --- /dev/null +++ b/tests/python/io_ply_import_test.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pathlib +import sys +import unittest + +import bpy + +sys.path.append(str(pathlib.Path(__file__).parent.absolute())) + +args = None + + +class PLYImportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.testdir = args.testdir + cls.output_dir = args.outdir + + def test_import_ply(self): + input_files = sorted(pathlib.Path(self.testdir).glob("*.ply")) + self.passed_tests = [] + self.failed_tests = [] + self.updated_tests = [] + + from modules import io_report + report = io_report.Report("PLY Import", self.output_dir, self.testdir, self.testdir.joinpath("reference")) + + for input_file in input_files: + with self.subTest(pathlib.Path(input_file).stem): + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "../empty.blend")) + ok = report.import_and_check( + input_file, lambda filepath, params: bpy.ops.wm.ply_import( + filepath=str(input_file), **params)) + if not ok: + self.fail(f"{input_file.stem} import result does not match expectations") + + report.finish("io_ply") + + +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) + parser.add_argument('--outdir', required=True, type=pathlib.Path) + args, remaining = parser.parse_known_args(argv) + + unittest.main(argv=remaining) + + +if __name__ == "__main__": + main() diff --git a/tests/python/io_stl_import_test.py b/tests/python/io_stl_import_test.py new file mode 100644 index 00000000000..8b3a28df010 --- /dev/null +++ b/tests/python/io_stl_import_test.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pathlib +import sys +import unittest + +import bpy + +sys.path.append(str(pathlib.Path(__file__).parent.absolute())) + +args = None + + +class STLImportTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.testdir = args.testdir + cls.output_dir = args.outdir + + def test_import_stl(self): + input_files = sorted(pathlib.Path(self.testdir).glob("*.stl")) + self.passed_tests = [] + self.failed_tests = [] + self.updated_tests = [] + + from modules import io_report + report = io_report.Report("STL Import", self.output_dir, self.testdir, self.testdir.joinpath("reference")) + + for input_file in input_files: + with self.subTest(pathlib.Path(input_file).stem): + bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "../empty.blend")) + ok = report.import_and_check( + input_file, lambda filepath, params: bpy.ops.wm.stl_import( + filepath=str(input_file), **params)) + if not ok: + self.fail(f"{input_file.stem} import result does not match expectations") + + report.finish("io_stl") + + +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) + parser.add_argument('--outdir', required=True, type=pathlib.Path) + args, remaining = parser.parse_known_args(argv) + + unittest.main(argv=remaining) + + +if __name__ == "__main__": + main() diff --git a/tests/python/modules/io_report.py b/tests/python/modules/io_report.py new file mode 100644 index 00000000000..dcb206a7e92 --- /dev/null +++ b/tests/python/modules/io_report.py @@ -0,0 +1,633 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Compare textual dump of imported data against reference versions and generate +a HTML report showing the differences, for regression testing. +""" + +import bpy +import bpy_extras.node_shader_utils +import difflib +import json +import os +import pathlib + +from . import global_report +from io import StringIO +from typing import Callable + + +def fmtf(f: float) -> str: + # ensure tiny numbers are 0.0, + # and not "-0.0" for example + if abs(f) < 0.0005: + return "0.000" + return f"{f:.3f}" + + +class Report: + __slots__ = ( + 'title', + 'output_dir', + 'global_dir', + 'input_dir', + 'reference_dir', + 'tested_count', + 'failed_list', + 'passed_list', + 'updated_list', + 'failed_html', + 'passed_html', + 'update_templates', + ) + + def __init__(self, title: str, output_dir: pathlib.Path, input_dir: pathlib.Path, reference_dir: pathlib.Path): + self.title = title + self.output_dir = output_dir + self.global_dir = os.path.dirname(output_dir) + self.input_dir = input_dir + self.reference_dir = reference_dir + + self.tested_count = 0 + self.failed_list = [] + self.passed_list = [] + self.updated_list = [] + self.failed_html = "" + self.passed_html = "" + + os.makedirs(output_dir, exist_ok=True) + self.update_templates = os.getenv('BLENDER_TEST_UPDATE', "0").strip() == "1" + if self.update_templates: + os.makedirs(self.reference_dir, exist_ok=True) + + # write out dummy html in case test crashes + if not self.update_templates: + filename = "report.html" + filepath = os.path.join(self.output_dir, filename) + pathlib.Path(filepath).write_text( + 'Report not generated yet. Crashed during tests?') + + @staticmethod + def _navigation_item(title, href, active): + if active: + return """""" % title + else: + return """""" % (href, title) + + def _navigation_html(self): + html = """""" + return html + + def finish(self, test_suite_name: str) -> None: + """ + Finishes the report: short summary to the console, + generates full report as HTML. + """ + print(f"\n============") + if self.update_templates: + print(f"{self.tested_count} input files tested, {len(self.updated_list)} references updated to new results") + for test in self.updated_list: + print(f"UPDATED {test}") + else: + self._write_html(test_suite_name) + print(f"{self.tested_count} input files tested, {len(self.passed_list)} passed") + if len(self.failed_list): + print(f"FAILED {len(self.failed_list)} tests:") + for test in self.failed_list: + print(f"FAILED {test}") + + def _write_html(self, test_suite_name: str): + + tests_html = self.failed_html + self.passed_html + menu = self._navigation_html() + + failed = len(self.failed_html) > 0 + if failed: + message = """""" + message += f"Tested files: {self.tested_count}, failed: {len(self.failed_list)}" + else: + message = f"Tested files: {self.tested_count}" + + title = self.title + " Test Report" + columns_html = "NameNewReferenceDiff" + + html = f""" + + + {title} + + + + +
+
+

{title}

+ {menu} + {message} + + {columns_html} + {tests_html} +
+
+
+ + + """ + + filename = "report.html" + filepath = os.path.join(self.output_dir, filename) + pathlib.Path(filepath).write_text(html) + + print(f"Report saved to: {pathlib.Path(filepath).as_uri()}") + + # Update global report + global_failed = failed + global_report.add(self.global_dir, "IO", self.title, filepath, global_failed) + + def _relative_url(self, filepath): + relpath = os.path.relpath(filepath, self.output_dir) + return pathlib.Path(relpath).as_posix() + + @staticmethod + def _colored_diff(a: str, b: str): + a_lines = a.splitlines() + b_lines = b.splitlines() + diff = difflib.unified_diff(a_lines, b_lines, lineterm='') + html = [] + for line in diff: + if line.startswith('+++') or line.startswith('---'): + pass + elif line.startswith('@@'): + html.append(f'{line}') + elif line.startswith('-'): + html.append(f'{line}') + elif line.startswith('+'): + html.append(f'{line}') + else: + html.append(line) + return '\n'.join(html) + + def _add_test_result(self, testname: str, got_desc: str, ref_desc: str): + error = got_desc != ref_desc + status = "FAILED" if error else "" + table_style = """ class="table-danger" """ if error else "" + cell_class = "text_cell text_cell_larger" if error else "text_cell" + diff_text = " " + if error: + diff_text = Report._colored_diff(ref_desc, got_desc) + + test_html = f""" + + {testname}
{status} +
{got_desc}
+
{ref_desc}
+
{diff_text}
+ """ + + if error: + self.failed_html += test_html + else: + self.passed_html += test_html + + @staticmethod + def _val_to_str(val) -> str: + if isinstance(val, bpy.types.BoolAttributeValue): + return f"{1 if val.value else 0}" + if isinstance(val, bpy.types.IntAttributeValue): + return f"{val.value}" + if isinstance(val, bpy.types.FloatAttributeValue): + return f"{val.value:.3f}" + if isinstance(val, bpy.types.FloatVectorAttributeValue): + return f"({val.vector[0]:.3f}, {val.vector[1]:.3f}, {val.vector[2]:.3f})" + if isinstance(val, bpy.types.Float2AttributeValue): + return f"({val.vector[0]:.3f}, {val.vector[1]:.3f})" + if isinstance(val, bpy.types.FloatColorAttributeValue) or isinstance(val, bpy.types.ByteColorAttributeValue): + return f"({val.color[0]:.3f}, {val.color[1]:.3f}, {val.color[2]:.3f}, {val.color[3]:.3f})" + if isinstance(val, bpy.types.Int2AttributeValue) or isinstance(val, bpy.types.Short2AttributeValue): + return f"({val.value[0]}, {val.value[1]})" + if isinstance(val, bpy.types.ID): + return f"'{val.name}'" + if isinstance(val, bpy.types.MeshLoop): + return f"{val.vertex_index}" + if isinstance(val, bpy.types.MeshEdge): + return f"{min(val.vertices[0],val.vertices[1])}/{max(val.vertices[0],val.vertices[1])}" + if isinstance(val, bpy.types.MaterialSlot): + return f"('{val.name}', {val.link})" + if isinstance(val, bpy.types.VertexGroup): + return f"'{val.name}'" + if isinstance(val, bpy.types.Keyframe): + return f"({val.co[0]:.1f}, {val.co[1]:.1f} int:{val.interpolation} ease:{val.easing})" + if isinstance(val, bpy.types.SplinePoint): + return f"({val.co[0]:.3f}, {val.co[1]:.3f}, {val.co[2]:.3f}) w:{val.weight:.3f}" + return str(val) + + # single-line dump of head/tail + @staticmethod + def _write_collection_single(col, desc: StringIO) -> None: + desc.write(f" - ") + side_to_print = 5 + if len(col) <= side_to_print * 2: + for val in col: + desc.write(f"{Report._val_to_str(val)} ") + else: + for val in col[:side_to_print]: + desc.write(f"{Report._val_to_str(val)} ") + desc.write(f"... ") + for val in col[-side_to_print:]: + desc.write(f"{Report._val_to_str(val)} ") + desc.write(f"\n") + + # multi-line dump of head/tail + @staticmethod + def _write_collection_multi(col, desc: StringIO) -> None: + side_to_print = 3 + if len(col) <= side_to_print * 2: + for val in col: + desc.write(f" - {Report._val_to_str(val)}\n") + else: + for val in col[:side_to_print]: + desc.write(f" - {Report._val_to_str(val)}\n") + desc.write(f" ...\n") + for val in col[-side_to_print:]: + desc.write(f" - {Report._val_to_str(val)}\n") + + @staticmethod + def _write_attr(attr: bpy.types.Attribute, desc: StringIO) -> None: + if len(attr.data) == 0: + return + desc.write(f" - attr '{attr.name}' {attr.data_type} {attr.domain}\n") + if isinstance( + attr, + bpy.types.BoolAttribute) or isinstance( + attr, + bpy.types.IntAttribute) or isinstance( + attr, + bpy.types.FloatAttribute): + Report._write_collection_single(attr.data, desc) + else: + Report._write_collection_multi(attr.data, desc) + + @staticmethod + def _write_custom_props(bid, desc: StringIO) -> None: + items = bid.items() + if not items: + return + + rna_properties = {prop.identifier for prop in bid.bl_rna.properties if prop.is_runtime} + + had_any = False + for k, v in items: + if k in rna_properties: + continue + + if not had_any: + desc.write(f" - props:") + had_any = True + + if isinstance(v, str): + if k != "cycles": + desc.write(f" str:{k}='{v}'") + else: + desc.write(f" str:{k}=") + elif isinstance(v, int): + desc.write(f" int:{k}={v}") + elif isinstance(v, float): + desc.write(f" fl:{k}={v:.3f}") + elif len(v) == 3: + desc.write(f" f3:{k}=({v[0]:.3f}, {v[1]:.3f}, {v[2]:.3f})") + else: + desc.write(f" o:{k}={str(v)}") + if had_any: + desc.write(f"\n") + + def _node_shader_image_desc(self, tex: bpy_extras.node_shader_utils.ShaderImageTextureWrapper) -> str: + if not tex or not tex.image: + return "" + # Get relative path of the image + try: + rel_path = pathlib.Path(tex.image.filepath).relative_to(self.input_dir).as_posix() + except ValueError: + rel_path = "" + desc = f" tex:'{tex.image.name}' ({rel_path}) a:{tex.use_alpha}" + if tex.texcoords != 'UV': + desc += f" uv:{tex.texcoords}" + if tex.extension != 'REPEAT': + desc += f" ext:{tex.extension}" + if tuple(tex.translation) != (0.0, 0.0, 0.0): + desc += f" tr:({tex.translation[0]:.3f}, {tex.translation[1]:.3f}, {tex.translation[2]:.3f})" + if tuple(tex.rotation) != (0.0, 0.0, 0.0): + desc += f" rot:({tex.rotation[0]:.3f}, {tex.rotation[1]:.3f}, {tex.rotation[2]:.3f})" + if tuple(tex.scale) != (1.0, 1.0, 1.0): + desc += f" scl:({tex.scale[0]:.3f}, {tex.scale[1]:.3f}, {tex.scale[2]:.3f})" + return desc + + @staticmethod + def _write_animdata_desc(adt: bpy.types.AnimData, desc: StringIO) -> None: + if adt: + if adt.action: + desc.write(f" - anim act:{adt.action.name}") + if adt.action_slot: + desc.write(f" slot:{adt.action_slot.identifier}") + desc.write(f" blend:{adt.action_blend_type} drivers:{len(adt.drivers)}\n") + + def generate_main_data_desc(self) -> str: + """Generates textual description of the current state of the + Blender main data.""" + + desc = StringIO() + + # meshes + if len(bpy.data.meshes): + desc.write(f"==== Meshes: {len(bpy.data.meshes)}\n") + for mesh in bpy.data.meshes: + # mesh overview + desc.write( + f"- Mesh '{mesh.name}' vtx:{len(mesh.vertices)} face:{len(mesh.polygons)} loop:{len(mesh.loops)} edge:{len(mesh.edges)}\n") + if len(mesh.loops) > 0: + Report._write_collection_single(mesh.loops, desc) + if len(mesh.edges) > 0: + Report._write_collection_single(mesh.edges, desc) + # attributes + for attr in mesh.attributes: + if not attr.is_internal: + Report._write_attr(attr, desc) + # skinning / vertex groups + has_skinning = any(v.groups for v in mesh.vertices) + if has_skinning: + desc.write(f" - vertex groups:\n") + for vtx in mesh.vertices[:5]: + desc.write(f" -") + # emit vertex group weights in decreasing weight order + sorted_groups = sorted(vtx.groups, key=lambda g: g.weight, reverse=True) + for grp in sorted_groups: + desc.write(f" {grp.group}={grp.weight:.3f}") + desc.write(f"\n") + # materials + if mesh.materials: + desc.write(f" - {len(mesh.materials)} materials\n") + Report._write_collection_single(mesh.materials, desc) + # blend shapes + key = mesh.shape_keys + if key: + for kb in key.key_blocks: + desc.write(f" - shape key '{kb.name}' w:{kb.value:.3f} vgrp:'{kb.vertex_group}'") + # print first several deltas that are not zero from the key + count = 0 + idx = 0 + for pt in kb.points: + if pt.co.length_squared > 0: + desc.write(f" {idx}:({pt.co[0]:.3f}, {pt.co[1]:.3f}, {pt.co[2]:.3f})") + count += 1 + if count >= 3: + break + idx += 1 + desc.write(f"\n") + Report._write_animdata_desc(mesh.animation_data, desc) + Report._write_custom_props(mesh, desc) + desc.write(f"\n") + + # curves + if len(bpy.data.curves): + desc.write(f"==== Curves: {len(bpy.data.curves)}\n") + for curve in bpy.data.curves: + # overview + desc.write( + f"- Curve '{curve.name}' dim:{curve.dimensions} resu:{curve.resolution_u} resv:{curve.resolution_v} splines:{len(curve.splines)}\n") + for spline in curve.splines[:5]: + desc.write( + f" - spline type:{spline.type} pts:{spline.point_count_u}x{spline.point_count_v} order:{spline.order_u}x{spline.order_v} cyclic:{spline.use_cyclic_u},{spline.use_cyclic_v} endp:{spline.use_endpoint_u},{spline.use_endpoint_v}\n") + Report._write_collection_multi(spline.points, desc) + # materials + if curve.materials: + desc.write(f" - {len(curve.materials)} materials\n") + Report._write_collection_single(curve.materials, desc) + Report._write_animdata_desc(curve.animation_data, desc) + Report._write_custom_props(curve, desc) + desc.write(f"\n") + + # objects + if len(bpy.data.objects): + desc.write(f"==== Objects: {len(bpy.data.objects)}\n") + for obj in bpy.data.objects: + desc.write(f"- Obj '{obj.name}' {obj.type}") + if obj.data: + desc.write(f" data:'{obj.data.name}'") + if obj.parent: + desc.write(f" par:'{obj.parent.name}'") + desc.write(f"\n") + desc.write(f" - pos {fmtf(obj.location[0])}, {fmtf(obj.location[1])}, {fmtf(obj.location[2])}\n") + desc.write( + f" - rot {fmtf(obj.rotation_euler[0])}, {fmtf(obj.rotation_euler[1])}, {fmtf(obj.rotation_euler[2])} ({obj.rotation_mode})\n") + desc.write(f" - scl {obj.scale[0]:.3f}, {obj.scale[1]:.3f}, {obj.scale[2]:.3f}\n") + if obj.vertex_groups: + desc.write(f" - {len(obj.vertex_groups)} vertex groups\n") + Report._write_collection_single(obj.vertex_groups, desc) + if obj.material_slots: + has_object_link = any(slot.link == 'OBJECT' for slot in obj.material_slots if slot.link) + if has_object_link: + desc.write(f" - {len(obj.material_slots)} object materials\n") + Report._write_collection_single(obj.material_slots, desc) + if obj.modifiers: + desc.write(f" - {len(obj.modifiers)} modifiers\n") + for mod in obj.modifiers: + desc.write(f" - {mod.type} '{mod.name}'") + if isinstance(mod, bpy.types.SubsurfModifier): + desc.write( + f" levels:{mod.levels}/{mod.render_levels} type:{mod.subdivision_type} crease:{mod.use_creases}") + desc.write(f"\n") + Report._write_animdata_desc(obj.animation_data, desc) + Report._write_custom_props(obj, desc) + desc.write(f"\n") + + # cameras + if len(bpy.data.cameras): + desc.write(f"==== Cameras: {len(bpy.data.cameras)}\n") + for cam in bpy.data.cameras: + desc.write( + f"- Cam '{cam.name}' {cam.type} lens:{cam.lens:.1f} {cam.lens_unit} near:{cam.clip_start:.3f} far:{cam.clip_end:.1f} orthosize:{cam.ortho_scale:.1f}\n") + desc.write(f" - fov {cam.angle:.3f} (h {cam.angle_x:.3f} v {cam.angle_y:.3f})\n") + desc.write( + f" - sensor {cam.sensor_width:.1f}x{cam.sensor_height:.1f} shift {cam.shift_x:.3f},{cam.shift_y:.3f}\n") + if cam.dof.use_dof: + desc.write( + f" - dof dist:{cam.dof.focus_distance:.3f} fstop:{cam.dof.aperture_fstop:.1f} blades:{cam.dof.aperture_blades}\n") + Report._write_animdata_desc(cam.animation_data, desc) + Report._write_custom_props(cam, desc) + desc.write(f"\n") + + # lights + if len(bpy.data.lights): + desc.write(f"==== Lights: {len(bpy.data.lights)}\n") + for light in bpy.data.lights: + desc.write( + f"- Light '{light.name}' {light.type} col:({light.color[0]:.3f}, {light.color[1]:.3f}, {light.color[2]:.3f}) energy:{light.energy:.3f}\n") + if isinstance(light, bpy.types.SpotLight): + desc.write(f" - spot {light.spot_size:.3f} blend {light.spot_blend:.3f}\n") + Report._write_animdata_desc(light.animation_data, desc) + Report._write_custom_props(light, desc) + desc.write(f"\n") + + # materials + if len(bpy.data.materials): + desc.write(f"==== Materials: {len(bpy.data.materials)}\n") + for mat in bpy.data.materials: + desc.write(f"- Mat '{mat.name}'\n") + wrap = bpy_extras.node_shader_utils.PrincipledBSDFWrapper(mat) + desc.write( + f" - base color ({wrap.base_color[0]:.3f}, {wrap.base_color[1]:.3f}, {wrap.base_color[2]:.3f}){self._node_shader_image_desc(wrap.base_color_texture)}\n") + desc.write( + f" - specular ior {wrap.specular:.3f}{self._node_shader_image_desc(wrap.specular_texture)}\n") + desc.write( + f" - specular tint ({wrap.specular_tint[0]:.3f}, {wrap.specular_tint[1]:.3f}, {wrap.specular_tint[2]:.3f}){self._node_shader_image_desc(wrap.specular_tint_texture)}\n") + desc.write( + f" - roughness {wrap.roughness:.3f}{self._node_shader_image_desc(wrap.roughness_texture)}\n") + desc.write( + f" - metallic {wrap.metallic:.3f}{self._node_shader_image_desc(wrap.metallic_texture)}\n") + desc.write(f" - ior {wrap.ior:.3f}{self._node_shader_image_desc(wrap.ior_texture)}\n") + if wrap.transmission > 0.0 or (wrap.transmission_texture and wrap.transmission_texture.image): + desc.write( + f" - transmission {wrap.transmission:.3f}{self._node_shader_image_desc(wrap.transmission_texture)}\n") + if wrap.alpha < 1.0 or (wrap.alpha_texture and wrap.alpha_texture.image): + desc.write( + f" - alpha {wrap.alpha:.3f}{self._node_shader_image_desc(wrap.alpha_texture)}\n") + if (wrap.emission_strength > 0.0 and wrap.emission_color[0] > 0.0 and wrap.emission_color[1] > 0.0 and wrap.emission_color[2] > 0.0) or ( + wrap.emission_strength_texture and wrap.emission_strength_texture.image): + desc.write( + f" - emission color ({wrap.emission_color[0]:.3f}, {wrap.emission_color[1]:.3f}, {wrap.emission_color[2]:.3f}){self._node_shader_image_desc(wrap.emission_color_texture)}\n") + desc.write( + f" - emission strength {wrap.emission_strength:.3f}{self._node_shader_image_desc(wrap.emission_strength_texture)}\n") + if (wrap.normalmap_texture and wrap.normalmap_texture.image): + desc.write( + f" - normalmap {wrap.normalmap_strength:.3f}{self._node_shader_image_desc(wrap.normalmap_texture)}\n") + Report._write_animdata_desc(mat.animation_data, desc) + Report._write_custom_props(mat, desc) + desc.write(f"\n") + + # actions + if len(bpy.data.actions): + desc.write(f"==== Actions: {len(bpy.data.actions)}\n") + for act in sorted(bpy.data.actions, key=lambda a: a.name): + desc.write( + f"- Action '{act.name}' curverange:({act.curve_frame_range[0]:.1f} .. {act.curve_frame_range[1]:.1f}) curves:{len(act.fcurves)}\n") + for fcu in act.fcurves[:5]: + desc.write( + f" - fcu '{fcu.data_path}[{fcu.array_index}] smooth:{fcu.auto_smoothing} extra:{fcu.extrapolation} keyframes:{len(fcu.keyframe_points)}'\n") + Report._write_collection_multi(fcu.keyframe_points, desc) + Report._write_custom_props(act, desc) + desc.write(f"\n") + + # armatures + if len(bpy.data.armatures): + desc.write(f"==== Armatures: {len(bpy.data.armatures)}\n") + for arm in bpy.data.armatures: + desc.write(f"- Armature '{arm.name}' {len(arm.bones)} bones\n") + for bone in arm.bones: + desc.write(f" - bone '{bone.name}'") + if bone.parent: + desc.write(f" parent:'{bone.parent.name}'") + desc.write( + f" h:({fmtf(bone.head[0])}, {fmtf(bone.head[1])}, {fmtf(bone.head[2])}) t:({fmtf(bone.tail[0]):}, {fmtf(bone.tail[1])}, {fmtf(bone.tail[2])})") + if bone.head_radius > 0.0 or bone.tail_radius > 0.0: + desc.write(f" radius h:{bone.head_radius:.3f} t:{bone.tail_radius:.3f}") + desc.write(f"\n") + desc.write(f"\n") + + # images + if len(bpy.data.images): + desc.write(f"==== Images: {len(bpy.data.images)}\n") + for img in bpy.data.images: + desc.write(f"- Image '{img.name}' {img.size[0]}x{img.size[1]} {img.depth}bpp\n") + Report._write_custom_props(img, desc) + desc.write(f"\n") + + text = desc.getvalue() + desc.close() + return text + + def import_and_check(self, input_file: pathlib.Path, import_func: Callable[[str, dict], None]) -> bool: + """ + Imports a single file using the provided import function, and + checks whether it matches with expected template, returns + comparison result. + + If there is a .json file next to the input file, the parameters from + that one file will be passed as extra parameters to the import function. + + When working in template update mode (environment variable + BLENDER_TEST_UPDATE=1), updates the template with new result + and always returns true. + """ + self.tested_count += 1 + input_basename = pathlib.Path(input_file).stem + print(f"Importing {input_file}...", flush=True) + + # load json parameters if they exist + params = {} + input_params_file = input_file.with_suffix(".json") + if input_params_file.exists(): + try: + with input_params_file.open('r', encoding='utf-8') as file: + params = json.load(file) + except: + pass + + # import + try: + import_func(str(input_file), params) + got_desc = self.generate_main_data_desc() + except RuntimeError as ex: + got_desc = f"Error during import: {ex}" + + ref_path: pathlib.Path = self.reference_dir / f"{input_basename}.txt" + if ref_path.exists(): + ref_desc = ref_path.read_text(encoding="utf-8").replace("\r\n", "\n") + else: + ref_desc = "" + + ok = True + if self.update_templates: + # write out newly got result as reference + if ref_desc != got_desc: + ref_path.write_text(got_desc, encoding="utf-8", newline="\n") + self.updated_list.append(input_basename) + else: + # compare result with expected reference + self._add_test_result(input_basename, got_desc, ref_desc) + if ref_desc == got_desc: + self.passed_list.append(input_basename) + else: + self.failed_list.append(input_basename) + ok = False + return ok