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
This commit is contained in:
Aras Pranckevicius
2025-01-15 05:52:15 +01:00
committed by Aras Pranckevicius
parent 98060efcf1
commit 1ad083dabf
12 changed files with 998 additions and 1391 deletions

View File

@@ -1,11 +1,10 @@
/* SPDX-FileCopyrightText: 2023 Blender Authors /* SPDX-FileCopyrightText: 2023-2025 Blender Authors
* *
* SPDX-License-Identifier: GPL-2.0-or-later */ * SPDX-License-Identifier: GPL-2.0-or-later */
#include "testing/testing.h" #include "testing/testing.h"
#include "BLI_fileops.hh" #include "BLI_path_utils.hh"
#include "BLI_hash_mm2a.hh"
#include "ply_import.hh" #include "ply_import.hh"
#include "ply_import_buffer.hh" #include "ply_import_buffer.hh"
@@ -13,291 +12,61 @@
namespace blender::io::ply { namespace blender::io::ply {
struct Expectation { /* Extensive tests for PLY importing are in `io_ply_import_test.py`.
int totvert, faces_num, totindex, totedge; * The tests here are only for testing PLY reader buffer refill behavior,
uint16_t polyhash = 0, edgehash = 0; * by using a very small buffer size on purpose. */
float3 vert_first, vert_last;
float3 normal_first = {0, 0, 0};
float2 uv_first = {0, 0};
float4 color_first = {-1, -1, -1, -1};
};
class PLYImportTest : public testing::Test { TEST(ply_import, BufferRefillTest)
public: {
void import_and_check(const char *path, const Expectation &exp) 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 = blender::tests::flags_test_asset_dir() + std::string ply_path_b = blender::tests::flags_test_asset_dir() +
SEP_STR "io_tests" SEP_STR "ply" SEP_STR + path; 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. */ /* Use a small read buffer size to test buffer refilling behavior. */
PlyReadBuffer infile(ply_path.c_str(), 128); constexpr size_t buffer_size = 50;
PlyHeader header; PlyReadBuffer infile_a(ply_path_a.c_str(), buffer_size);
const char *header_err = read_header(infile, header); PlyReadBuffer infile_b(ply_path_b.c_str(), buffer_size);
if (header_err != nullptr) { PlyHeader header_a, header_b;
ADD_FAILURE(); const char *header_err_a = read_header(infile_a, header_a);
return; const char *header_err_b = read_header(infile_b, header_b);
} if (header_err_a != nullptr || header_err_b != nullptr) {
std::unique_ptr<PlyData> data = import_ply_data(infile, header); fprintf(stderr, "Failed to read PLY header\n");
if (!data->error.empty()) { ADD_FAILURE();
fprintf(stderr, "%s\n", data->error.c_str()); return;
ASSERT_EQ(0, exp.totvert); }
ASSERT_EQ(0, exp.faces_num); std::unique_ptr<PlyData> data_a = import_ply_data(infile_a, header_a);
return; std::unique_ptr<PlyData> 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");
/* Test expected amount of vertices, edges, and faces. */ ADD_FAILURE();
ASSERT_EQ(data->vertices.size(), exp.totvert); return;
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);
} }
};
TEST_F(PLYImportTest, PLYImportCube) /* Check whether the edges list matches expectations. */
{ std::pair<int, int> exp_edges[] = {{2, 0},
Expectation expect = {24, {0, 1},
6, {1, 3},
24, {3, 2},
0, {6, 2},
26429, {3, 7},
0, {7, 6},
float3(1, 1, -1), {4, 6},
float3(-1, 1, 1), {7, 5},
float3(0, 0, -1), {5, 4},
float2(0.979336, 0.844958), {0, 4},
float4(1, 0.8470, 0, 1)}; {5, 1}};
import_and_check("cube_ascii.ply", expect); EXPECT_EQ(12, data_a->edges.size());
} EXPECT_EQ(12, data_b->edges.size());
EXPECT_EQ_ARRAY(exp_edges, data_a->edges.data(), 12);
TEST_F(PLYImportTest, PLYImportWireframeCube) EXPECT_EQ_ARRAY(exp_edges, data_b->edges.data(), 12);
{
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);
} }
//@TODO: now we put vertex color attribute first, maybe put position first?
//@TODO: test with vertex element having list properties //@TODO: test with vertex element having list properties
//@TODO: test with edges starting with non-vertex index properties //@TODO: test with edges starting with non-vertex index properties
//@TODO: test various malformed headers //@TODO: test various malformed headers
//@TODO: UVs with: s,t; u,v; texture_u,texture_v; texture_s,texture_t (from miniply) //@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: 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 } // namespace blender::io::ply

View File

@@ -54,7 +54,6 @@ blender_add_lib(bf_io_stl "${SRC}" "${INC}" "${INC_SYS}" "${LIB}")
if(WITH_GTESTS) if(WITH_GTESTS)
set(TEST_SRC set(TEST_SRC
tests/stl_exporter_tests.cc tests/stl_exporter_tests.cc
tests/stl_importer_tests.cc
) )
set(TEST_INC set(TEST_INC

View File

@@ -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 (&deg_iter_settings, object) {
printf(" {");
if (object->type == OB_MESH) {
Mesh *mesh = BKE_object_get_evaluated_mesh(object);
const Span<float3> 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 (&deg_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<float3> 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

View File

@@ -1,943 +1,49 @@
/* SPDX-FileCopyrightText: 2023 Blender Authors /* SPDX-FileCopyrightText: 2023-2025 Blender Authors
* *
* SPDX-License-Identifier: Apache-2.0 */ * SPDX-License-Identifier: Apache-2.0 */
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "testing/testing.h" #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 "BLI_string.h"
#include "BLO_readfile.hh" #include "CLG_log.h"
#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 "obj_import_file_reader.hh"
#include "obj_importer.hh" #include "obj_importer.hh"
namespace blender::io::obj { namespace blender::io::obj {
struct Expectation { /* Extensive tests for OBJ importing are in `io_obj_import_test.py`.
std::string name; * The tests here are only for testing OBJ reader buffer refill behavior,
short type; /* OB_MESH, ... */ * by using a very small buffer size on purpose. */
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;
};
class OBJImportTest : public BlendfileLoadingBaseTest { TEST(obj_import, BufferRefillTest)
public: {
void import_and_check(const char *path, CLG_init();
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 (&deg_iter_settings, object) {
printf(" {\"%s\", ", object->id.name);
if (object->type == OB_MESH) {
Mesh *mesh = BKE_object_get_evaluated_mesh(object);
const Span<float3> 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 (&deg_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<float3> 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<const float2 *>(
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<Curve *>(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<const Nurb *>(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 : "<null>", 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);
}
OBJImportParams params; 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) /* Use a small read buffer size to test buffer refilling behavior. */
{ const size_t read_buffer_size = 650;
Expectation expect[] = { OBJParser obj_parser{params, read_buffer_size};
{"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);
}
TEST_F(OBJImportTest, import_cube_o_after_verts) Vector<std::unique_ptr<Geometry>> all_geometries;
{ GlobalVertices global_vertices;
Expectation expect[] = { obj_parser.parse(all_geometries, global_vertices);
{"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);
}
TEST_F(OBJImportTest, import_suzanne_all_data) EXPECT_EQ(1, all_geometries.size());
{ EXPECT_EQ(GEOM_CURVE, all_geometries[0]->geom_type_);
Expectation expect[] = { EXPECT_EQ(28, global_vertices.vertices.size());
{"OBCube", OB_MESH, 8, 12, 6, 24, float3(1, 1, -1), float3(-1, 1, 1)}, EXPECT_EQ(31, all_geometries[0]->nurbs_element_.curv_indices.size());
{"OBMonkey", EXPECT_EQ(35, all_geometries[0]->nurbs_element_.parm.size());
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);
}
TEST_F(OBJImportTest, import_nurbs) CLG_exit();
{
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);
} }
} // namespace blender::io::obj } // namespace blender::io::obj

View File

@@ -142,44 +142,6 @@ TEST_F(OBJMTLParserTest, string_newlines_whitespace)
check_string(text, mat, ARRAY_SIZE(mat)); 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) TEST_F(OBJMTLParserTest, materials)
{ {
MTLMaterial mat[6]; MTLMaterial mat[6];
@@ -286,36 +248,6 @@ TEST_F(OBJMTLParserTest, materials)
check("materials.mtl", mat, ARRAY_SIZE(mat)); 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) TEST_F(OBJMTLParserTest, materials_pbr)
{ {
MTLMaterial mat[2]; MTLMaterial mat[2];

View File

@@ -47,6 +47,21 @@ function(add_blender_test testname)
) )
endfunction() 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) if(WITH_UI_TESTS)
set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}") set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}")
@@ -938,6 +953,44 @@ if(WITH_USD)
) )
endif() 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) if(WITH_CODEC_FFMPEG)
add_python_test( add_python_test(
ffmpeg ffmpeg

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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(
'<html><body>Report not generated yet. Crashed during tests?</body></html>')
@staticmethod
def _navigation_item(title, href, active):
if active:
return """<li class="breadcrumb-item active" aria-current="page">%s</li>""" % title
else:
return """<li class="breadcrumb-item"><a href="%s">%s</a></li>""" % (href, title)
def _navigation_html(self):
html = """<nav aria-label="breadcrumb"><ol class="breadcrumb">"""
base_path = os.path.relpath(self.global_dir, self.output_dir)
global_report_path = os.path.join(base_path, "report.html")
html += self._navigation_item("Test Reports", global_report_path, False)
html += self._navigation_item(self.title, "report.html", True)
html += """</ol></nav>"""
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 = """<div class="alert alert-danger" role="alert">"""
message += """<p>Run this command to regenerate reference (ground truth) output:</p>"""
message += """<p><tt>BLENDER_TEST_UPDATE=1 ctest -R %s</tt></p>""" % test_suite_name
message += """<p>The reference output of new and failing tests will be updated. """ \
"""Be sure to commit the new reference """ \
"""files to the tests/data git submodule afterwards.</p>"""
message += """</div>"""
message += f"Tested files: {self.tested_count}, <b>failed: {len(self.failed_list)}</b>"
else:
message = f"Tested files: {self.tested_count}"
title = self.title + " Test Report"
columns_html = "<tr><th>Name</th><th>New</th><th>Reference</th><th>Diff</th>"
html = f"""
<html>
<head>
<title>{title}</title>
<style>
div.page_container {{ text-align: center; }}
div.page_container div {{ text-align: left; }}
div.page_content {{ display: inline-block; }}
.text_cell {{
max-width: 15em;
max-height: 8em;
overflow: auto;
font-family: monospace;
white-space: pre;
font-size: 10pt;
border: 1px solid gray;
}}
.text_cell_larger {{ max-height: 14em; }}
.text_cell_wider {{ max-width: 40em; }}
.added {{ background-color: #d4edda; }}
.removed {{ background-color: #f8d7da; }}
.place {{ color: #808080; font-style: italic; }}
p {{ margin-bottom: 0.5rem; }}
</style>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="page_container"><div class="page_content">
<br/>
<h1>{title}</h1>
{menu}
{message}
<table class="table table-striped">
<thead class="thead-dark">{columns_html}</thead>
{tests_html}
</table>
<br/>
</div></div>
</body>
</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'<span class="place">{line}</span>')
elif line.startswith('-'):
html.append(f'<span class="removed">{line}</span>')
elif line.startswith('+'):
html.append(f'<span class="added">{line}</span>')
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 = "&nbsp;"
if error:
diff_text = Report._colored_diff(ref_desc, got_desc)
test_html = f"""
<tr>
<td{table_style}><b>{testname}</b><br/>{status}</td>
<td><div class="{cell_class}">{got_desc}</div></td>
<td><div class="{cell_class}">{ref_desc}</div></td>
<td><div class="{cell_class} text_cell_wider">{diff_text}</div></td>
</tr>"""
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}=<cyclesval>")
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 = "<outside of test folder>"
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