From d856ea5469e08f7fbbd92c9d07025634217cd549 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 25 Nov 2025 16:44:28 -0500 Subject: [PATCH 1/7] OBJ Format detection & testing --- .../parser/mesh/obj_parser.hpp | 17 +++++++ inc/starlet-serializer/parser/mesh_parser.hpp | 1 + src/parser/mesh/obj_parser.cpp | 23 +++++++++ src/parser/mesh_parser.cpp | 5 ++ tests/mesh_parser_test.cpp | 49 ++++++++++++++++--- 5 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 inc/starlet-serializer/parser/mesh/obj_parser.hpp create mode 100644 src/parser/mesh/obj_parser.cpp diff --git a/inc/starlet-serializer/parser/mesh/obj_parser.hpp b/inc/starlet-serializer/parser/mesh/obj_parser.hpp new file mode 100644 index 0000000..5f00248 --- /dev/null +++ b/inc/starlet-serializer/parser/mesh/obj_parser.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "mesh_parser_base.hpp" + +namespace Starlet::Serializer { + +struct MeshData; + +class ObjParser : public MeshParserBase { +public: + bool parse(const std::string& path, MeshData& out); + +private: + +}; + +} \ No newline at end of file diff --git a/inc/starlet-serializer/parser/mesh_parser.hpp b/inc/starlet-serializer/parser/mesh_parser.hpp index 46ceb1b..b810747 100644 --- a/inc/starlet-serializer/parser/mesh_parser.hpp +++ b/inc/starlet-serializer/parser/mesh_parser.hpp @@ -13,6 +13,7 @@ class MeshParser : public Parser { private: enum class MeshFormat { PLY, + OBJ, UNKNOWN }; diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp new file mode 100644 index 0000000..f8160be --- /dev/null +++ b/src/parser/mesh/obj_parser.cpp @@ -0,0 +1,23 @@ +#include "starlet-serializer/parser/mesh/obj_parser.hpp" +#include "starlet-serializer/data/mesh_data.hpp" + +#include "starlet-logger/logger.hpp" + +#include +#include + +namespace Starlet::Serializer { + +namespace { + inline bool strncasecmp(const char* a, const char* b, size_t n) { + for (size_t i = 0; i < n; ++i) + if (tolower(a[i]) != tolower(b[i])) return false; + return true; + } +} + +bool ObjParser::parse(const std::string& path, MeshData& out) { + return Starlet::Logger::error("ObjParser", "parse", "OBJ not yet implemented!"), false; +} + +} \ No newline at end of file diff --git a/src/parser/mesh_parser.cpp b/src/parser/mesh_parser.cpp index c15f724..933562f 100644 --- a/src/parser/mesh_parser.cpp +++ b/src/parser/mesh_parser.cpp @@ -1,6 +1,7 @@ #include "starlet-serializer/parser/mesh_parser.hpp" #include "starlet-serializer/parser/mesh/ply_parser.hpp" +#include "starlet-serializer/parser/mesh/obj_parser.hpp" #include "starlet-logger/logger.hpp" @@ -14,6 +15,9 @@ bool MeshParser::parse(const std::string& path, MeshData& out) { case MeshFormat::PLY: parser = std::make_unique(); break; + case MeshFormat::OBJ: + parser = std::make_unique(); + break; default: Logger::error("MeshParser", "parse", "Unsupported mesh format: " + path); return false; @@ -31,6 +35,7 @@ MeshParser::MeshFormat MeshParser::detectFormat(const std::string& path) { for (char& c : extension) c = static_cast(tolower(c)); if (extension == "ply") return MeshFormat::PLY; + if (extension == "obj") return MeshFormat::OBJ; else return MeshFormat::UNKNOWN; } diff --git a/tests/mesh_parser_test.cpp b/tests/mesh_parser_test.cpp index dbb76a8..066c329 100644 --- a/tests/mesh_parser_test.cpp +++ b/tests/mesh_parser_test.cpp @@ -2,7 +2,7 @@ // PLY format detection TEST_F(MeshParserTest, DetectFormatPlyLowercase) { - createTestFile("test_data/model.ply", "ply\nformat ascii 1.0\nelement vertex 0\nelement face 0\nend_header\n"); + createTestFile("test_data/model.ply", "ply"); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model.ply", out)); @@ -13,7 +13,7 @@ TEST_F(MeshParserTest, DetectFormatPlyLowercase) { } TEST_F(MeshParserTest, DetectFormatPlyUppercase) { - createTestFile("test_data/model.PLY", "ply\nformat ascii 1.0\nelement vertex 0\nelement face 0\nend_header\n"); + createTestFile("test_data/model.PLY", "ply"); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model.PLY", out)); @@ -24,7 +24,7 @@ TEST_F(MeshParserTest, DetectFormatPlyUppercase) { } TEST_F(MeshParserTest, DetectFormatPlyMixedCase) { - createTestFile("test_data/model.PlY", "ply\nformat ascii 1.0\nelement vertex 0\nelement face 0\nend_header\n"); + createTestFile("test_data/model.PlY", "ply"); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model.PlY", out)); @@ -35,9 +35,44 @@ TEST_F(MeshParserTest, DetectFormatPlyMixedCase) { } +// OBJ format detection +TEST_F(MeshParserTest, DetectFormatObjLowercase) { + createTestFile("test_data/model.obj", "obj"); + + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/model.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + + // OBJ extension was properly detected and routed to ObjParser + EXPECT_NE(output.find("ObjParser"), std::string::npos); +} + +TEST_F(MeshParserTest, DetectFormatObjUppercase) { + createTestFile("test_data/model.OBJ", "obj"); + + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/model.OBJ", out)); + std::string output = testing::internal::GetCapturedStderr(); + + // OBJ extension was properly detected and routed to ObjParser + EXPECT_NE(output.find("ObjParser"), std::string::npos); +} + +TEST_F(MeshParserTest, DetectFormatObjMixedCase) { + createTestFile("test_data/model.ObJ", "obj"); + + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/model.ObJ", out)); + std::string output = testing::internal::GetCapturedStderr(); + + // OBJ extension was properly detected and routed to ObjParser + EXPECT_NE(output.find("ObjParser"), std::string::npos); +} + + // Edge cases TEST_F(MeshParserTest, DetectFormatNoExtension) { - createTestFile("test_data/model", "ply\nformat ascii 1.0\nelement vertex 0\nelement face 0\nend_header\n"); + createTestFile("test_data/model", ""); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model", out)); @@ -47,7 +82,7 @@ TEST_F(MeshParserTest, DetectFormatNoExtension) { } TEST_F(MeshParserTest, DetectFormatUnknownExtension) { - createTestFile("test_data/model.unknown", "ply\nformat ascii 1.0\nelement vertex 0\nelement face 0\nend_header\n"); + createTestFile("test_data/model.unknown", ""); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model.unknown", out)); @@ -57,11 +92,11 @@ TEST_F(MeshParserTest, DetectFormatUnknownExtension) { } TEST_F(MeshParserTest, DetectFormatEmptyExtension) { - createTestFile("test_data/model.", "ply\n"); + createTestFile("test_data/model.", ""); testing::internal::CaptureStderr(); EXPECT_FALSE(parser.parse("test_data/model.", out)); std::string output = testing::internal::GetCapturedStderr(); EXPECT_NE(output.find("Unsupported mesh format: test_data/model."), std::string::npos); -} \ No newline at end of file +} From 141ba384d6668a8b4791132d6fe95e0fe61442cc Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 25 Nov 2025 23:39:52 -0500 Subject: [PATCH 2/7] File I/O, comment handling and basic testing --- src/parser/mesh/obj_parser.cpp | 30 +++++++++++++++++++++++++++++- tests/mesh/obj_parser_test.cpp | 20 ++++++++++++++++++++ tests/mesh/ply_parser_test.cpp | 2 +- tests/mesh_parser_test.cpp | 30 ++++++------------------------ 4 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 tests/mesh/obj_parser_test.cpp diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index f8160be..bd4b744 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -17,7 +17,35 @@ namespace { } bool ObjParser::parse(const std::string& path, MeshData& out) { - return Starlet::Logger::error("ObjParser", "parse", "OBJ not yet implemented!"), false; + std::vector file; + if (!loadBinaryFile(file, path)) return false; + + if (file.empty() || file[0] == '\0') + return Logger::error("ObjParser", "parse", "File is empty"); + + const unsigned char* p = file.data(); + + while (*p) { + p = skipWhitespace(p); + if (*p == '\0') break; + + if (*p == '#') { + p = skipToNextLine(p); + continue; + } + + unsigned char cmd[32]{}; + if (!parseToken(p, cmd, sizeof(cmd))) { + p = skipToNextLine(p); + continue; + } + + // TODO: Impelement vertex/face parsing + + p = skipToNextLine(p); + } + + return true; } } \ No newline at end of file diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp new file mode 100644 index 0000000..a276578 --- /dev/null +++ b/tests/mesh/obj_parser_test.cpp @@ -0,0 +1,20 @@ +#include "../test_helpers.hpp" + +class ObjParserTest : public MeshParserTest {}; + + +TEST_F(ObjParserTest, CommentOnly) { + createTestFile("test_data/comments.obj", "# Comment\n# Another comment\n"); + EXPECT_TRUE(parser.parse("test_data/comments.obj", out)); +} + + +// Error tests +TEST_F(ObjParserTest, EmptyFile) { + createTestFile("test_data/empty.obj", ""); + + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/empty.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("File is empty"), std::string::npos); +} \ No newline at end of file diff --git a/tests/mesh/ply_parser_test.cpp b/tests/mesh/ply_parser_test.cpp index cd962a2..3171968 100644 --- a/tests/mesh/ply_parser_test.cpp +++ b/tests/mesh/ply_parser_test.cpp @@ -358,7 +358,7 @@ end_header // Error tests -TEST_F(PlyParserTest, PlyEmpty) { +TEST_F(PlyParserTest, EmptyFile) { createTestFile("test_data/empty.ply", ""); testing::internal::CaptureStderr(); diff --git a/tests/mesh_parser_test.cpp b/tests/mesh_parser_test.cpp index 066c329..bfdd22e 100644 --- a/tests/mesh_parser_test.cpp +++ b/tests/mesh_parser_test.cpp @@ -37,36 +37,18 @@ TEST_F(MeshParserTest, DetectFormatPlyMixedCase) { // OBJ format detection TEST_F(MeshParserTest, DetectFormatObjLowercase) { - createTestFile("test_data/model.obj", "obj"); - - testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/model.obj", out)); - std::string output = testing::internal::GetCapturedStderr(); - - // OBJ extension was properly detected and routed to ObjParser - EXPECT_NE(output.find("ObjParser"), std::string::npos); + createTestFile("test_data/model.obj", "# obj"); + EXPECT_TRUE(parser.parse("test_data/model.obj", out)); } TEST_F(MeshParserTest, DetectFormatObjUppercase) { - createTestFile("test_data/model.OBJ", "obj"); - - testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/model.OBJ", out)); - std::string output = testing::internal::GetCapturedStderr(); - - // OBJ extension was properly detected and routed to ObjParser - EXPECT_NE(output.find("ObjParser"), std::string::npos); + createTestFile("test_data/model.OBJ", "# obj"); + EXPECT_TRUE(parser.parse("test_data/model.OBJ", out)); } TEST_F(MeshParserTest, DetectFormatObjMixedCase) { - createTestFile("test_data/model.ObJ", "obj"); - - testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/model.ObJ", out)); - std::string output = testing::internal::GetCapturedStderr(); - - // OBJ extension was properly detected and routed to ObjParser - EXPECT_NE(output.find("ObjParser"), std::string::npos); + createTestFile("test_data/model.ObJ", "# obj"); + EXPECT_TRUE(parser.parse("test_data/model.ObJ", out)); } From 55f8ff3897b18e598dbc8a1929dd530bdbc962dd Mon Sep 17 00:00:00 2001 From: Masonlet Date: Tue, 25 Nov 2025 23:49:09 -0500 Subject: [PATCH 3/7] Vertex position parsing --- src/parser/mesh/obj_parser.cpp | 27 +++++++++++++++++--- tests/mesh/obj_parser_test.cpp | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index bd4b744..769ff4e 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -1,10 +1,12 @@ #include "starlet-serializer/parser/mesh/obj_parser.hpp" #include "starlet-serializer/data/mesh_data.hpp" - #include "starlet-logger/logger.hpp" +#include "starlet-math/vec3.hpp" + #include #include +#include namespace Starlet::Serializer { @@ -23,8 +25,9 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { if (file.empty() || file[0] == '\0') return Logger::error("ObjParser", "parse", "File is empty"); - const unsigned char* p = file.data(); + std::vector> positions; + const unsigned char* p = file.data(); while (*p) { p = skipWhitespace(p); if (*p == '\0') break; @@ -40,11 +43,27 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { continue; } - // TODO: Impelement vertex/face parsing + if (strcmp((const char*)cmd, "v") == 0) { + Starlet::Math::Vec3 pos; + if (!parseFloat(p, pos.x) || !parseFloat(p, pos.y) || !parseFloat(p, pos.z)) + return Logger::error("ObjParser", "parse", "Failed to parse vertex position at vertex " + std::to_string(positions.size())); - p = skipToNextLine(p); + float w; + parseFloat(p, w); + + positions.push_back(pos); + } + else { + p = skipToNextLine(p); + } } + out.numVertices = positions.size(); + out.vertices.resize(out.numVertices); + for (size_t i = 0; i < out.numVertices; ++i) { + out.vertices[i].pos = positions[i]; + } + return true; } diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp index a276578..f670577 100644 --- a/tests/mesh/obj_parser_test.cpp +++ b/tests/mesh/obj_parser_test.cpp @@ -8,6 +8,35 @@ TEST_F(ObjParserTest, CommentOnly) { EXPECT_TRUE(parser.parse("test_data/comments.obj", out)); } +TEST_F(ObjParserTest, SingleVertex) { + createTestFile("test_data/single_vertex.obj", "v 1.0 2.0 3.0\n"); + EXPECT_TRUE(parser.parse("test_data/single_vertex.obj", out)); + EXPECT_EQ(out.numVertices, 1); + EXPECT_FLOAT_EQ(out.vertices[0].pos.x, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.y, 2.0f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.z, 3.0f); +} + +TEST_F(ObjParserTest, MultipleVertices) { + createTestFile("test_data/vertices.obj", "v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\n"); + EXPECT_TRUE(parser.parse("test_data/vertices.obj", out)); + EXPECT_EQ(out.numVertices, 3); + EXPECT_FLOAT_EQ(out.vertices[2].pos.y, 1.0f); +} + +TEST_F(ObjParserTest, VertexWithW) { + createTestFile("test_data/vertex_w.obj", "v 1.0 2.0 3.0 0.5\n"); + EXPECT_TRUE(parser.parse("test_data/vertex_w.obj", out)); + EXPECT_EQ(out.numVertices, 1); + EXPECT_FLOAT_EQ(out.vertices[0].pos.z, 3.0f); +} + +TEST_F(ObjParserTest, VerticesWithComments) { + createTestFile("test_data/vert_comments.obj", "# Header\nv 1.0 0.0 0.0\n# Mid comment\nv 0.0 1.0 0.0\n"); + EXPECT_TRUE(parser.parse("test_data/vert_comments.obj", out)); + EXPECT_EQ(out.numVertices, 2); +} + // Error tests TEST_F(ObjParserTest, EmptyFile) { @@ -17,4 +46,20 @@ TEST_F(ObjParserTest, EmptyFile) { EXPECT_FALSE(parser.parse("test_data/empty.obj", out)); std::string output = testing::internal::GetCapturedStderr(); EXPECT_NE(output.find("File is empty"), std::string::npos); +} + +TEST_F(ObjParserTest, VertexMissingZ) { + createTestFile("test_data/missing_z.obj", "v 1.0 2.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/missing_z.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse vertex position at vertex 0"), std::string::npos); +} + +TEST_F(ObjParserTest, VertexInvalidFloat) { + createTestFile("test_data/invalid_float.obj", "v 1.0 abc 3.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/invalid_float.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse vertex position"), std::string::npos); } \ No newline at end of file From 06bba03f3c9f38cee17cfbfd16cfce9baff447d6 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 26 Nov 2025 00:11:22 -0500 Subject: [PATCH 4/7] Face parsing with triangulation --- src/parser/mesh/obj_parser.cpp | 54 +++++++++++++++++++++++++++++----- tests/mesh/obj_parser_test.cpp | 47 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index 769ff4e..088acc4 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -10,14 +10,6 @@ namespace Starlet::Serializer { -namespace { - inline bool strncasecmp(const char* a, const char* b, size_t n) { - for (size_t i = 0; i < n; ++i) - if (tolower(a[i]) != tolower(b[i])) return false; - return true; - } -} - bool ObjParser::parse(const std::string& path, MeshData& out) { std::vector file; if (!loadBinaryFile(file, path)) return false; @@ -26,6 +18,7 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { return Logger::error("ObjParser", "parse", "File is empty"); std::vector> positions; + std::vector indices; const unsigned char* p = file.data(); while (*p) { @@ -53,6 +46,47 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { positions.push_back(pos); } + else if (strcmp((const char*)cmd, "f") == 0) { + std::vector faceIndices; + + while (true) { + p = skipWhitespace(p); + if (!*p || *p == '\n' || *p == '\r') break; + + int i = 0; + bool negative = (*p == '-'); + if (negative) ++p; + + unsigned int absVal; + if (!parseUInt(p, absVal)) break; + + i = negative ? -static_cast(absVal) : static_cast(absVal); + + if (i > 0) --i; + else if (i < 0 ) i = static_cast(positions.size()) + i; + else return Logger::error("ObjParser", "parse", "Face index cannot be 0"); + + if(i < 0 || i >= static_cast(positions.size())) + return Logger::error("ObjParser", "parse", "Face index out of bounds" + std::to_string(i)); + + faceIndices.push_back(i); + + while (*p == '/') { + ++p; + unsigned int dummy; + parseUInt(p, dummy); + } + } + + if (faceIndices.size() < 3) + return Logger::error("ObjParser", "parse", "Face has fewer than 3 vertices"); + + for (size_t i = 2; i < faceIndices.size(); ++i) { + indices.push_back(faceIndices[0]); + indices.push_back(faceIndices[i - 1]); + indices.push_back(faceIndices[i]); + } + } else { p = skipToNextLine(p); } @@ -64,6 +98,10 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { out.vertices[i].pos = positions[i]; } + out.numIndices = indices.size(); + out.numTriangles = out.numIndices / 3; + out.indices = std::move(indices); + return true; } diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp index f670577..4a43fd2 100644 --- a/tests/mesh/obj_parser_test.cpp +++ b/tests/mesh/obj_parser_test.cpp @@ -37,6 +37,29 @@ TEST_F(ObjParserTest, VerticesWithComments) { EXPECT_EQ(out.numVertices, 2); } +TEST_F(ObjParserTest, SingleTriangle) { + createTestFile("test_data/triangle.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); + expectValidParse("test_data/triangle.obj", 3, 1); + EXPECT_EQ(out.indices[0], 0); + EXPECT_EQ(out.indices[1], 1); + EXPECT_EQ(out.indices[2], 2); +} + +TEST_F(ObjParserTest, QuadTriangulation) { + createTestFile("test_data/quad.obj", "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n"); + expectValidParse("test_data/quad.obj", 4, 2); + EXPECT_EQ(out.indices[0], 0); + EXPECT_EQ(out.indices[1], 1); + EXPECT_EQ(out.indices[2], 2); + EXPECT_EQ(out.indices[3], 0); + EXPECT_EQ(out.indices[4], 2); + EXPECT_EQ(out.indices[5], 3); +} + +TEST_F(ObjParserTest, NegativeIndex) { + createTestFile("test_data/negative.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -3 -2 -1\n"); + expectValidParse("test_data/negative.obj", 3, 1); +} // Error tests TEST_F(ObjParserTest, EmptyFile) { @@ -62,4 +85,28 @@ TEST_F(ObjParserTest, VertexInvalidFloat) { EXPECT_FALSE(parser.parse("test_data/invalid_float.obj", out)); std::string output = testing::internal::GetCapturedStderr(); EXPECT_NE(output.find("Failed to parse vertex position"), std::string::npos); +} + +TEST_F(ObjParserTest, FaceIndexZero) { + createTestFile("test_data/zero_index.obj", "v 0 0 0\nf 0 1 2\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/zero_index.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Face index cannot be 0"), std::string::npos); +} + +TEST_F(ObjParserTest, FaceIndexOutOfBounds) { + createTestFile("test_data/bounds.obj", "v 0 0 0\nf 1 2 3\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/bounds.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Face index out of bounds"), std::string::npos); +} + +TEST_F(ObjParserTest, FaceTooFewVertices) { + createTestFile("test_data/few_verts.obj", "v 0 0 0\nv 1 0 0\nf 1 2\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/few_verts.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Face has fewer than 3 vertices"), std::string::npos); } \ No newline at end of file From b020294f84310a428de8f9d6c08f576fedc9361f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 26 Nov 2025 00:24:27 -0500 Subject: [PATCH 5/7] Texture coordinate parsing, normal parsing and basic testing --- src/parser/mesh/obj_parser.cpp | 51 ++++++++++++++++++++--------- tests/mesh/obj_parser_test.cpp | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index 088acc4..9e66f8e 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -18,34 +18,55 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { return Logger::error("ObjParser", "parse", "File is empty"); std::vector> positions; + std::vector> texCoords; + std::vector> normals; std::vector indices; - const unsigned char* p = file.data(); - while (*p) { - p = skipWhitespace(p); - if (*p == '\0') break; + const unsigned char* p = file.data(); + while (*p) { + p = skipWhitespace(p); + if (*p == '\0') break; - if (*p == '#') { - p = skipToNextLine(p); - continue; - } + if (*p == '#') { + p = skipToNextLine(p); + continue; + } - unsigned char cmd[32]{}; - if (!parseToken(p, cmd, sizeof(cmd))) { - p = skipToNextLine(p); - continue; - } + unsigned char cmd[32]{}; + if (!parseToken(p, cmd, sizeof(cmd))) { + p = skipToNextLine(p); + continue; + } - if (strcmp((const char*)cmd, "v") == 0) { + if (strcmp(reinterpret_cast(cmd), "v") == 0) { Starlet::Math::Vec3 pos; if (!parseFloat(p, pos.x) || !parseFloat(p, pos.y) || !parseFloat(p, pos.z)) return Logger::error("ObjParser", "parse", "Failed to parse vertex position at vertex " + std::to_string(positions.size())); float w; - parseFloat(p, w); + parseFloat(p, w); positions.push_back(pos); } + else if (strcmp(reinterpret_cast(cmd), "vt") == 0) { + Starlet::Math::Vec2 texCoord; + if (!parseVec2f(p, texCoord)) + return Logger::error("ObjParser", "parse", "Failed to parse texture coordinate at texCoord " + std::to_string(texCoords.size())); + + float w; + parseFloat(p, w); + + texCoords.push_back(texCoord); + + } + else if (strcmp(reinterpret_cast(cmd), "vn") == 0){ + Starlet::Math::Vec3 normal; + + if (!parseVec3f(p, normal)) + return Logger::error("ObjParser", "parse", "Failed to parse normal at normal " + std::to_string(normals.size())); + + normals.push_back(normal); + } else if (strcmp((const char*)cmd, "f") == 0) { std::vector faceIndices; diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp index 4a43fd2..bad2464 100644 --- a/tests/mesh/obj_parser_test.cpp +++ b/tests/mesh/obj_parser_test.cpp @@ -61,6 +61,33 @@ TEST_F(ObjParserTest, NegativeIndex) { expectValidParse("test_data/negative.obj", 3, 1); } +TEST_F(ObjParserTest, TextureCoordinate) { + createTestFile("test_data/texcoord.obj", "vt 0.5 0.5\n"); + EXPECT_TRUE(parser.parse("test_data/texcoord.obj", out)); + //TODO: Verify correct +} + +TEST_F(ObjParserTest, TextureCoordinateWithW) { + createTestFile("test_data/texcoord_w.obj", "vt 0.5 0.5 1.0\n"); + EXPECT_TRUE(parser.parse("test_data/texcoord_w.obj", out)); + //TODO: Verify correct +} + +TEST_F(ObjParserTest, Normal) { + createTestFile("test_data/normal.obj", "vn 0.0 1.0 0.0\n"); + EXPECT_TRUE(parser.parse("test_data/normal.obj", out)); + //TODO: Verify correct +} + +TEST_F(ObjParserTest, MixedVertexData) { + createTestFile("test_data/mixed.obj", "v 0 0 0\nvt 0.0 0.0\nvn 0 1 0\nv 1 0 0\n"); + EXPECT_TRUE(parser.parse("test_data/mixed.obj", out)); + EXPECT_EQ(out.numVertices, 2); + //TODO: Verify correct +} + + + // Error tests TEST_F(ObjParserTest, EmptyFile) { createTestFile("test_data/empty.obj", ""); @@ -109,4 +136,36 @@ TEST_F(ObjParserTest, FaceTooFewVertices) { EXPECT_FALSE(parser.parse("test_data/few_verts.obj", out)); std::string output = testing::internal::GetCapturedStderr(); EXPECT_NE(output.find("Face has fewer than 3 vertices"), std::string::npos); +} + +TEST_F(ObjParserTest, TextureCoordMissingV) { + createTestFile("test_data/tc_missing.obj", "vt 0.5\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/tc_missing.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse texture coordinate at texCoord 0"), std::string::npos); +} + +TEST_F(ObjParserTest, TextureCoordInvalid) { + createTestFile("test_data/tc_invalid.obj", "vt abc 0.5\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/tc_invalid.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse texture coordinate"), std::string::npos); +} + +TEST_F(ObjParserTest, NormalMissingZ) { + createTestFile("test_data/norm_missing.obj", "vn 0.0 1.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/norm_missing.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse normal at normal 0"), std::string::npos); +} + +TEST_F(ObjParserTest, NormalInvalid) { + createTestFile("test_data/norm_invalid.obj", "vn 0.0 abc 1.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/norm_invalid.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse normal"), std::string::npos); } \ No newline at end of file From e6a3dde663737fde7dfd00c9fde42ec613b47a58 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 26 Nov 2025 18:31:51 -0500 Subject: [PATCH 6/7] Implement face parsing and vertex deduplication - Add logic to parse 'f' lines in OBJ files, supporting: - Position, texture coordinate, and normal indices. - Absolute (positive) and relative (negative) indexing. - Triangulation of meshes (N > 3) via fan triangulation - Vertex deduplication based on unique tuples --- .../parser/mesh/obj_parser.hpp | 41 +- src/parser/mesh/obj_parser.cpp | 219 +++++++++-- tests/mesh/obj_parser_test.cpp | 365 ++++++++++++++++-- 3 files changed, 548 insertions(+), 77 deletions(-) diff --git a/inc/starlet-serializer/parser/mesh/obj_parser.hpp b/inc/starlet-serializer/parser/mesh/obj_parser.hpp index 5f00248..96e0cea 100644 --- a/inc/starlet-serializer/parser/mesh/obj_parser.hpp +++ b/inc/starlet-serializer/parser/mesh/obj_parser.hpp @@ -1,17 +1,42 @@ #pragma once #include "mesh_parser_base.hpp" +#include -namespace Starlet::Serializer { +namespace Starlet { -struct MeshData; +namespace Math { + struct Vertex; + template struct Vec3; + template struct Vec2; +} -class ObjParser : public MeshParserBase { -public: - bool parse(const std::string& path, MeshData& out); +namespace Serializer { + struct MeshData; -private: - -}; + class ObjParser : public MeshParserBase { + public: + bool parse(const std::string& path, MeshData& out); + + private: + struct ObjVertex { + int posI; + int texI; + int normI; + }; + + bool parsePosition(const unsigned char*& p, std::vector>& positions); + bool parseTexCoord(const unsigned char*& p, std::vector>& texCoords); + bool parseNormal(const unsigned char*& p, std::vector>& normals); + + void fillMeshData( + MeshData& out, + std::vector& vertices, + std::vector& indices, + bool usedTexCoords, + bool usedNormals + ); + }; +} } \ No newline at end of file diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index 9e66f8e..9111329 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -2,11 +2,15 @@ #include "starlet-serializer/data/mesh_data.hpp" #include "starlet-logger/logger.hpp" +#include "starlet-math/vertex.hpp" #include "starlet-math/vec3.hpp" +#include "starlet-math/vec2.hpp" #include #include #include +#include +#include namespace Starlet::Serializer { @@ -20,6 +24,12 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { std::vector> positions; std::vector> texCoords; std::vector> normals; + + bool usedTexCoords = false; + bool usedNormals = false; + + std::map, unsigned int> vertexMap; + std::vector vertices; std::vector indices; const unsigned char* p = file.data(); @@ -39,91 +49,218 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { } if (strcmp(reinterpret_cast(cmd), "v") == 0) { - Starlet::Math::Vec3 pos; - if (!parseFloat(p, pos.x) || !parseFloat(p, pos.y) || !parseFloat(p, pos.z)) + if (!parsePosition(p, positions)) return Logger::error("ObjParser", "parse", "Failed to parse vertex position at vertex " + std::to_string(positions.size())); - - float w; - parseFloat(p, w); - - positions.push_back(pos); } else if (strcmp(reinterpret_cast(cmd), "vt") == 0) { - Starlet::Math::Vec2 texCoord; - if (!parseVec2f(p, texCoord)) + if (!parseTexCoord(p, texCoords)) return Logger::error("ObjParser", "parse", "Failed to parse texture coordinate at texCoord " + std::to_string(texCoords.size())); - - float w; - parseFloat(p, w); - - texCoords.push_back(texCoord); - } - else if (strcmp(reinterpret_cast(cmd), "vn") == 0){ - Starlet::Math::Vec3 normal; - - if (!parseVec3f(p, normal)) + else if (strcmp(reinterpret_cast(cmd), "vn") == 0) { + if (!parseNormal(p, normals)) return Logger::error("ObjParser", "parse", "Failed to parse normal at normal " + std::to_string(normals.size())); - - normals.push_back(normal); } else if (strcmp((const char*)cmd, "f") == 0) { - std::vector faceIndices; + std::vector faceVertices; while (true) { p = skipWhitespace(p); if (!*p || *p == '\n' || *p == '\r') break; - int i = 0; + ObjVertex fv{ -1, -1, -1 }; + + int posI = 0; bool negative = (*p == '-'); if (negative) ++p; unsigned int absVal; if (!parseUInt(p, absVal)) break; + posI = negative ? -static_cast(absVal) : static_cast(absVal); - i = negative ? -static_cast(absVal) : static_cast(absVal); - - if (i > 0) --i; - else if (i < 0 ) i = static_cast(positions.size()) + i; + if (posI > 0) --posI; + else if (posI < 0) posI = static_cast(positions.size()) + posI; else return Logger::error("ObjParser", "parse", "Face index cannot be 0"); - if(i < 0 || i >= static_cast(positions.size())) - return Logger::error("ObjParser", "parse", "Face index out of bounds" + std::to_string(i)); + if (posI < 0 || posI >= static_cast(positions.size())) + return Logger::error("ObjParser", "parse", "Face index out of bounds" + std::to_string(posI)); - faceIndices.push_back(i); + fv.posI = posI; while (*p == '/') { ++p; - unsigned int dummy; - parseUInt(p, dummy); + + if (*p == '/') { + ++p; + + if (*p != ' ' && *p != '\n' && *p != '\r') { + int normI{ 0 }; + negative = (*p == '-'); + if (negative) ++p; + + if (parseUInt(p, absVal)) { + normI = negative ? -static_cast(absVal) : static_cast(absVal); + + if (normI > 0) normI--; + else if (normI < 0) normI = static_cast(normals.size()) + normI; + else return Logger::error("ObjParser", "parse", "Normal index cannot be 0"); + + if (normI < 0 || normI >= static_cast(normals.size())) + return Logger::error("ObjParser", "parse", "Normal index out of bounds: " + std::to_string(normI)); + + fv.normI = normI; + } + } + break; + } + + if (*p == ' ' || *p == '\n' || *p == '\r') + return Logger::error("ObjParser", "parse", "Expected texture coordinate index after '/'"); + + int texCoordI{ 0 }; + negative = (*p == '-'); + if (negative) ++p; + + if (!parseUInt(p, absVal)) + return Logger::error("ObjParser", "parse", "Expected texture coordinate index after '/'"); + + texCoordI = negative ? -static_cast(absVal) : static_cast(absVal); + + if (texCoordI > 0) texCoordI--; + else if (texCoordI < 0) texCoordI = static_cast(texCoords.size()) + texCoordI; + else return Logger::error("ObjParser", "parse", "TexCoord index cannot be 0"); + + if (texCoordI < 0 || texCoordI >= static_cast(texCoords.size())) + return Logger::error("ObjParser", "parse", "TexCoord index out of bounds: " + std::to_string(texCoordI)); + + fv.texI = texCoordI; + + if (*p == '/') { + ++p; + + if (*p != ' ' && *p != '\n' && *p != '\r') { + int normI{ 0 }; + negative = (*p == '-'); + if (negative) ++p; + + if (parseUInt(p, absVal)) { + normI = negative ? -static_cast(absVal) : static_cast(absVal); + + if (normI > 0) normI--; + else if (normI < 0) normI = static_cast(normals.size()) + normI; + else return Logger::error("ObjParser", "parse", "Normal index cannot be 0"); + + if (normI < 0 || normI >= static_cast(normals.size())) + return Logger::error("ObjParser", "parse", "Normal index out of bounds: " + std::to_string(normI)); + + fv.normI = normI; + } + } + } + break; } + + faceVertices.push_back(fv); } - if (faceIndices.size() < 3) + if (faceVertices.size() < 3) return Logger::error("ObjParser", "parse", "Face has fewer than 3 vertices"); + std::vector faceIndices; + for (const ObjVertex& fv : faceVertices) { + std::tuple key = std::make_tuple(fv.posI, fv.texI, fv.normI); + + auto it = vertexMap.find(key); + if (it != vertexMap.end()) + faceIndices.push_back(it->second); + else { + Math::Vertex v{}; + v.pos = positions[fv.posI]; + if (fv.texI >= 0) { + v.texCoord = texCoords[fv.texI]; + usedTexCoords = true; + } + if (fv.normI >= 0) { + v.norm = normals[fv.normI]; + usedNormals = true; + } + + unsigned int i = static_cast(vertices.size()); + vertices.push_back(v); + vertexMap[key] = i; + faceIndices.push_back(i); + } + } + for (size_t i = 2; i < faceIndices.size(); ++i) { indices.push_back(faceIndices[0]); indices.push_back(faceIndices[i - 1]); indices.push_back(faceIndices[i]); } } - else { - p = skipToNextLine(p); - } + else p = skipToNextLine(p); } - out.numVertices = positions.size(); - out.vertices.resize(out.numVertices); - for (size_t i = 0; i < out.numVertices; ++i) { - out.vertices[i].pos = positions[i]; + if (vertices.empty() && !positions.empty()) { + for (const Starlet::Math::Vec3& pos : positions) { + Math::Vertex v{}; + v.pos = pos; + vertices.push_back(v); + } } + fillMeshData(out, vertices, indices, usedTexCoords, usedNormals); + return true; +} + +bool ObjParser::parsePosition(const unsigned char*& p, std::vector>& positions) { + Starlet::Math::Vec3 pos; + if (!parseVec3f(p, pos)) + return false; + + float w; + parseFloat(p, w); + + positions.push_back(pos); + return true; +} + +bool ObjParser::parseTexCoord(const unsigned char*& p, std::vector>& texCoords) { + Starlet::Math::Vec2 tex; + if (!parseVec2f(p, tex)) + return false; + + float w; + parseFloat(p, w); + + texCoords.push_back(tex); + return true; +} + +bool ObjParser::parseNormal(const unsigned char*& p, std::vector>& normals) { + Starlet::Math::Vec3 norm; + if (!parseVec3f(p, norm)) + return false; + + normals.push_back(norm); + return true; +} + +void ObjParser::fillMeshData( + MeshData& out, + std::vector& vertices, + std::vector& indices, + bool usedTexCoords, + bool usedNormals +) { + out.vertices = std::move(vertices); + out.numVertices = out.vertices.size(); + out.numIndices = indices.size(); out.numTriangles = out.numIndices / 3; out.indices = std::move(indices); - return true; + out.hasTexCoords = usedTexCoords; + out.hasNormals = usedNormals; } } \ No newline at end of file diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp index bad2464..58e37dc 100644 --- a/tests/mesh/obj_parser_test.cpp +++ b/tests/mesh/obj_parser_test.cpp @@ -3,11 +3,14 @@ class ObjParserTest : public MeshParserTest {}; + TEST_F(ObjParserTest, CommentOnly) { createTestFile("test_data/comments.obj", "# Comment\n# Another comment\n"); EXPECT_TRUE(parser.parse("test_data/comments.obj", out)); } + + TEST_F(ObjParserTest, SingleVertex) { createTestFile("test_data/single_vertex.obj", "v 1.0 2.0 3.0\n"); EXPECT_TRUE(parser.parse("test_data/single_vertex.obj", out)); @@ -37,6 +40,8 @@ TEST_F(ObjParserTest, VerticesWithComments) { EXPECT_EQ(out.numVertices, 2); } + + TEST_F(ObjParserTest, SingleTriangle) { createTestFile("test_data/triangle.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); expectValidParse("test_data/triangle.obj", 3, 1); @@ -44,7 +49,6 @@ TEST_F(ObjParserTest, SingleTriangle) { EXPECT_EQ(out.indices[1], 1); EXPECT_EQ(out.indices[2], 2); } - TEST_F(ObjParserTest, QuadTriangulation) { createTestFile("test_data/quad.obj", "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n"); expectValidParse("test_data/quad.obj", 4, 2); @@ -56,34 +60,285 @@ TEST_F(ObjParserTest, QuadTriangulation) { EXPECT_EQ(out.indices[5], 3); } -TEST_F(ObjParserTest, NegativeIndex) { - createTestFile("test_data/negative.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -3 -2 -1\n"); - expectValidParse("test_data/negative.obj", 3, 1); +TEST_F(ObjParserTest, Pentagon) { + createTestFile("test_data/pentagon.obj", + "v 0 0 0\nv 1 0 0\nv 1.5 0.5 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4 5\n"); + expectValidParse("test_data/pentagon.obj", 5, 3); + EXPECT_EQ(out.indices.size(), 9); +} + +TEST_F(ObjParserTest, Hexagon) { + createTestFile("test_data/hexagon.obj", + "v 0 0 0\nv 1 0 0\nv 1.5 0.5 0\nv 1.5 1.5 0\nv 1 2 0\nv 0 2 0\nf 1 2 3 4 5 6\n"); + expectValidParse("test_data/hexagon.obj", 6, 4); + EXPECT_EQ(out.indices.size(), 12); +} + +TEST_F(ObjParserTest, LargePolygon) { + std::string content; + for (int i = 0; i < 10; i++) + content += "v " + std::to_string(i) + " 0 0\n"; + content += "f 1 2 3 4 5 6 7 8 9 10\n"; + createTestFile("test_data/large_poly.obj", content); + expectValidParse("test_data/large_poly.obj", 10, 8); +} + +TEST_F(ObjParserTest, DegenerateFace) { + createTestFile("test_data/degen.obj", "v 0 0 0\nv 1 0 0\nf 1 1 2\n"); + expectValidParse("test_data/degen.obj", 2, 1); } + + TEST_F(ObjParserTest, TextureCoordinate) { - createTestFile("test_data/texcoord.obj", "vt 0.5 0.5\n"); + createTestFile("test_data/texcoord.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0.5 0.5\nf 1/1 2/1 3/1\n"); EXPECT_TRUE(parser.parse("test_data/texcoord.obj", out)); - //TODO: Verify correct + EXPECT_TRUE(out.hasTexCoords); + EXPECT_EQ(out.numVertices, 3); } TEST_F(ObjParserTest, TextureCoordinateWithW) { - createTestFile("test_data/texcoord_w.obj", "vt 0.5 0.5 1.0\n"); + createTestFile("test_data/texcoord_w.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0.5 0.5 1.0\nf 1/1 2/1 3/1\n\n"); EXPECT_TRUE(parser.parse("test_data/texcoord_w.obj", out)); - //TODO: Verify correct + EXPECT_TRUE(out.hasTexCoords); + EXPECT_EQ(out.numVertices, 3); +} + +TEST_F(ObjParserTest, TriangleWithTexCoords) { + createTestFile("test_data/tri_tc.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nf 1/1 2/2 3/3\n"); + expectValidParse("test_data/tri_tc.obj", 3, 1); + EXPECT_TRUE(out.hasTexCoords); + EXPECT_FALSE(out.hasNormals); + EXPECT_FLOAT_EQ(out.vertices[0].texCoord.x, 0.0f); + EXPECT_FLOAT_EQ(out.vertices[0].texCoord.y, 0.0f); + EXPECT_FLOAT_EQ(out.vertices[2].texCoord.x, 0.0f); + EXPECT_FLOAT_EQ(out.vertices[2].texCoord.y, 1.0f); } + + TEST_F(ObjParserTest, Normal) { - createTestFile("test_data/normal.obj", "vn 0.0 1.0 0.0\n"); + createTestFile("test_data/normal.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0.0 1.0 0.0\nf 1//1 2//1 3//1\n"); EXPECT_TRUE(parser.parse("test_data/normal.obj", out)); - //TODO: Verify correct + EXPECT_TRUE(out.hasNormals); + EXPECT_EQ(out.numVertices, 3); +} + +TEST_F(ObjParserTest, TriangleWithNormals) { + createTestFile("test_data/tri_norm.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//1 2//1 3//1\n"); + expectValidParse("test_data/tri_norm.obj", 3, 1); + EXPECT_FALSE(out.hasTexCoords); + EXPECT_TRUE(out.hasNormals); + EXPECT_FLOAT_EQ(out.vertices[0].norm.z, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[2].norm.z, 1.0f); +} + +TEST_F(ObjParserTest, FaceMissingTexCoordIndex) { + createTestFile("test_data/missing_tc_idx.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//1 2//1 3//1\n"); + expectValidParse("test_data/missing_tc_idx.obj", 3, 1); + EXPECT_FALSE(out.hasTexCoords); + EXPECT_TRUE(out.hasNormals); +} + + + +TEST_F(ObjParserTest, TriangleWithTexCoordsAndNormals) { + createTestFile("test_data/tri_full.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nf 1/1/1 2/2/1 3/3/1\n"); + expectValidParse("test_data/tri_full.obj", 3, 1); + EXPECT_TRUE(out.hasTexCoords); + EXPECT_TRUE(out.hasNormals); + EXPECT_FLOAT_EQ(out.vertices[1].texCoord.x, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[1].norm.z, 1.0f); } TEST_F(ObjParserTest, MixedVertexData) { - createTestFile("test_data/mixed.obj", "v 0 0 0\nvt 0.0 0.0\nvn 0 1 0\nv 1 0 0\n"); + createTestFile("test_data/mixed.obj", "v 0 0 0\nvt 0.0 0.0\nvn 0 1 0\nv 1 0 0\nv 0 1 0\nf 1/1/1 2/1/1 3/1/1\n"); EXPECT_TRUE(parser.parse("test_data/mixed.obj", out)); + EXPECT_EQ(out.numVertices, 3); + EXPECT_TRUE(out.hasTexCoords); + EXPECT_TRUE(out.hasNormals); +} + +TEST_F(ObjParserTest, MixedFaceSeparators) { + createTestFile("test_data/mixed_sep.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvn 0 0 1\nf 1//1 2/2 3\n"); + expectValidParse("test_data/mixed_sep.obj", 3, 1); + EXPECT_TRUE(out.hasNormals); + EXPECT_TRUE(out.hasTexCoords); +} + +TEST_F(ObjParserTest, FaceWithOnlyPositions) { + createTestFile("test_data/pos_only.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0.5 0.5\nvn 0 0 1\nf 1 2 3\n"); + expectValidParse("test_data/pos_only.obj", 3, 1); + EXPECT_FALSE(out.hasTexCoords); + EXPECT_FALSE(out.hasNormals); +} + +TEST_F(ObjParserTest, FaceUsingOnlySubsetOfDefinedData) { + createTestFile("test_data/subset_data.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvn 0 0 1\nf 1 2 3\n"); + expectValidParse("test_data/subset_data.obj", 3, 1); + EXPECT_FALSE(out.hasTexCoords); + EXPECT_FALSE(out.hasNormals); +} + + + +TEST_F(ObjParserTest, NegativeIndex) { + createTestFile("test_data/negative.obj", "v 0 0 0\nv 1 0 0\nv 0 1 0\nf -3 -2 -1\n"); + expectValidParse("test_data/negative.obj", 3, 1); +} + +TEST_F(ObjParserTest, NegativeIndexForPositionOnlyFace) { + createTestFile("test_data/neg_pos_only.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 -1\n"); + expectValidParse("test_data/neg_pos_only.obj", 3, 1); + EXPECT_EQ(out.indices[2], 2); +} + +TEST_F(ObjParserTest, NegativeTexCoordIndex) { + createTestFile("test_data/neg_tc.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nf 1/-2 1/-1 1/-2\n"); + expectValidParse("test_data/neg_tc.obj", 2, 1); + EXPECT_TRUE(out.hasTexCoords); +} + +TEST_F(ObjParserTest, NegativeNormalIndex) { + createTestFile("test_data/neg_norm.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//-1 2//-1 3//-1\n"); + expectValidParse("test_data/neg_norm.obj", 3, 1); + EXPECT_TRUE(out.hasNormals); +} + +TEST_F(ObjParserTest, FaceNegativeIndexMixedRefs) { + createTestFile("test_data/mixed_neg_idx.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nf 1/1 2/-2 3/1\n"); + expectValidParse("test_data/mixed_neg_idx.obj", 3, 1); + EXPECT_TRUE(out.hasTexCoords); +} + + + +TEST_F(ObjParserTest, VertexDeduplication) { + createTestFile("test_data/dedup.obj", + "v 0 0 0\nvt 0 0\nvt 1 0\nf 1/1 1/2 1/1\n"); + EXPECT_TRUE(parser.parse("test_data/dedup.obj", out)); EXPECT_EQ(out.numVertices, 2); - //TODO: Verify correct + EXPECT_EQ(out.numTriangles, 1); + EXPECT_EQ(out.indices[0], 0); + EXPECT_EQ(out.indices[1], 1); + EXPECT_EQ(out.indices[2], 0); +} + +TEST_F(ObjParserTest, DeduplicationWithDifferentNormals) { + createTestFile("test_data/dedup_diff_norms.obj", + "v 0 0 0\nvt 0 0\nvn 0 0 1\nvn 0 1 0\nf 1/1/1 1/1/2 1/1/1\n"); + EXPECT_TRUE(parser.parse("test_data/dedup_diff_norms.obj", out)); + EXPECT_EQ(out.numVertices, 2); + EXPECT_EQ(out.numTriangles, 1); + EXPECT_EQ(out.indices[0], 0); + EXPECT_EQ(out.indices[1], 1); + EXPECT_EQ(out.indices[2], 0); +} + +TEST_F(ObjParserTest, DeduplicationWithDifferentTexCoords) { + createTestFile("test_data/dedup_diff_tex.obj", + "v 0 0 0\nvt 0 0\nvt 1 1\nvn 0 0 1\nf 1/1/1 1/2/1 1/1/1\n"); + EXPECT_TRUE(parser.parse("test_data/dedup_diff_tex.obj", out)); + EXPECT_EQ(out.numVertices, 2); + EXPECT_EQ(out.numTriangles, 1); + EXPECT_EQ(out.indices[0], 0); + EXPECT_EQ(out.indices[1], 1); + EXPECT_EQ(out.indices[2], 0); +} + + + +TEST_F(ObjParserTest, ExtraWhitespace) { + createTestFile("test_data/whitespace.obj", "v 1.0 2.0 3.0\nvt\t0.5\t0.5\nf 1 1 1\n"); + EXPECT_TRUE(parser.parse("test_data/whitespace.obj", out)); + EXPECT_EQ(out.numVertices, 1); +} + +TEST_F(ObjParserTest, WindowsCRLF) { + createTestFile("test_data/crlf.obj", "v 1 2 3\r\nvt 0.5 0.5\r\nf 1/1 1/1 1/1\r\n"); + EXPECT_TRUE(parser.parse("test_data/crlf.obj", out)); + EXPECT_EQ(out.numVertices, 1); + EXPECT_TRUE(out.hasTexCoords); +} + +TEST_F(ObjParserTest, LeadingTrailingBlankLines) { + createTestFile("test_data/blanks.obj", "\n\nv 0 0 0\nv 1 0 0\nv 0 1 0\n\nf 1 2 3\n\n"); + expectValidParse("test_data/blanks.obj", 3, 1); +} + +TEST_F(ObjParserTest, MultipleSpacesInFace) { + createTestFile("test_data/multi_space.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//1 2//1 3//1\n"); + expectValidParse("test_data/multi_space.obj", 3, 1); +} + +TEST_F(ObjParserTest, CommentAfterFace) { + createTestFile("test_data/face_comment.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nf 1/1/1 2/2/1 3/3/1 # triangle\n"); + expectValidParse("test_data/face_comment.obj", 3, 1); +} + +TEST_F(ObjParserTest, ScientificNotation) { + createTestFile("test_data/scientific.obj", "v 1e-3 2E+2 -3e0\nvt 5e-1 5e-1\nf 1/1 1/1 1/1\n"); + EXPECT_TRUE(parser.parse("test_data/scientific.obj", out)); + EXPECT_FLOAT_EQ(out.vertices[0].pos.x, 0.001f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.y, 200.0f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.z, -3.0f); +} + +TEST_F(ObjParserTest, TexCoordExtraComponents) { + createTestFile("test_data/tc_extra.obj", "v 0 0 0\nvt 0.5 0.5 1.0 extra junk\nf 1/1 1/1 1/1\n"); + EXPECT_TRUE(parser.parse("test_data/tc_extra.obj", out)); + EXPECT_EQ(out.numVertices, 1); +} + +TEST_F(ObjParserTest, InterleavedCommands) { + createTestFile("test_data/interleaved.obj", + "v 0 0 0\nvt 0 0\nv 1 0 0\nvn 0 0 1\nv 0 1 0\nf 1/1/1 2/1/1 3/1/1\n"); + expectValidParse("test_data/interleaved.obj", 3, 1); + EXPECT_TRUE(out.hasTexCoords); + EXPECT_TRUE(out.hasNormals); +} + +TEST_F(ObjParserTest, UnknownCommands) { + createTestFile("test_data/unknown.obj", + "usemtl Material\ns off\ng groupName\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); + expectValidParse("test_data/unknown.obj", 3, 1); +} + +TEST_F(ObjParserTest, ObjectCommand) { + createTestFile("test_data/object.obj", "o Cube\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n"); + expectValidParse("test_data/object.obj", 3, 1); +} + +TEST_F(ObjParserTest, LargeFile) { + std::string content; + for (int i = 0; i < 1000; i++) + content += "v " + std::to_string(i) + " 0 0\n"; + + for (int i = 0; i < 300; i++) { + int v1 = i * 3 + 1; + int v2 = i * 3 + 2; + int v3 = i * 3 + 3; + if (v3 <= 1000) + content += "f " + std::to_string(v1) + " " + std::to_string(v2) + " " + std::to_string(v3) + "\n"; + } + + createTestFile("test_data/large.obj", content); + EXPECT_TRUE(parser.parse("test_data/large.obj", out)); + EXPECT_EQ(out.numVertices, 900); + EXPECT_EQ(out.numTriangles, 300); } @@ -98,6 +353,8 @@ TEST_F(ObjParserTest, EmptyFile) { EXPECT_NE(output.find("File is empty"), std::string::npos); } + + TEST_F(ObjParserTest, VertexMissingZ) { createTestFile("test_data/missing_z.obj", "v 1.0 2.0\n"); testing::internal::CaptureStderr(); @@ -114,6 +371,40 @@ TEST_F(ObjParserTest, VertexInvalidFloat) { EXPECT_NE(output.find("Failed to parse vertex position"), std::string::npos); } +TEST_F(ObjParserTest, TextureCoordMissingV) { + createTestFile("test_data/tc_missing.obj", "vt 0.5\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/tc_missing.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse texture coordinate at texCoord 0"), std::string::npos); +} + +TEST_F(ObjParserTest, TextureCoordInvalid) { + createTestFile("test_data/tc_invalid.obj", "vt abc 0.5\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/tc_invalid.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse texture coordinate"), std::string::npos); +} + +TEST_F(ObjParserTest, NormalMissingZ) { + createTestFile("test_data/norm_missing.obj", "vn 0.0 1.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/norm_missing.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse normal at normal 0"), std::string::npos); +} + +TEST_F(ObjParserTest, NormalInvalid) { + createTestFile("test_data/norm_invalid.obj", "vn 0.0 abc 1.0\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/norm_invalid.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("Failed to parse normal"), std::string::npos); +} + + + TEST_F(ObjParserTest, FaceIndexZero) { createTestFile("test_data/zero_index.obj", "v 0 0 0\nf 0 1 2\n"); testing::internal::CaptureStderr(); @@ -130,6 +421,14 @@ TEST_F(ObjParserTest, FaceIndexOutOfBounds) { EXPECT_NE(output.find("Face index out of bounds"), std::string::npos); } +TEST_F(ObjParserTest, VeryLargeIndex) { + createTestFile("test_data/huge_idx.obj", "v 0 0 0\nf 9999999 1 2\n"); + testing::internal::CaptureStderr(); + EXPECT_FALSE(parser.parse("test_data/huge_idx.obj", out)); + std::string output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("out of bounds"), std::string::npos); +} + TEST_F(ObjParserTest, FaceTooFewVertices) { createTestFile("test_data/few_verts.obj", "v 0 0 0\nv 1 0 0\nf 1 2\n"); testing::internal::CaptureStderr(); @@ -138,34 +437,44 @@ TEST_F(ObjParserTest, FaceTooFewVertices) { EXPECT_NE(output.find("Face has fewer than 3 vertices"), std::string::npos); } -TEST_F(ObjParserTest, TextureCoordMissingV) { - createTestFile("test_data/tc_missing.obj", "vt 0.5\n"); + + +TEST_F(ObjParserTest, TrailingSlashInFace) { + createTestFile("test_data/trail_slash.obj", + "v 0 0 0\nv 1 0 0\nvt 0.5 0.5\nf 1/ 2/1\n"); + testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/tc_missing.obj", out)); + EXPECT_FALSE(parser.parse("test_data/trail_slash.obj", out)); std::string output = testing::internal::GetCapturedStderr(); - EXPECT_NE(output.find("Failed to parse texture coordinate at texCoord 0"), std::string::npos); + EXPECT_NE(output.find("index"), std::string::npos); } -TEST_F(ObjParserTest, TextureCoordInvalid) { - createTestFile("test_data/tc_invalid.obj", "vt abc 0.5\n"); +TEST_F(ObjParserTest, FaceMissingNormalIndex) { + createTestFile("test_data/missing_norm_idx.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nf 1/1 2/2 3/\n"); + testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/tc_invalid.obj", out)); + EXPECT_FALSE(parser.parse("test_data/missing_norm_idx.obj", out)); std::string output = testing::internal::GetCapturedStderr(); - EXPECT_NE(output.find("Failed to parse texture coordinate"), std::string::npos); + EXPECT_NE(output.find("Expected texture coordinate index"), std::string::npos); } + +TEST_F(ObjParserTest, BareSlashMalformed) { + createTestFile("test_data/bare_slash.obj", + "v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1// 2/3/ 3//\n"); -TEST_F(ObjParserTest, NormalMissingZ) { - createTestFile("test_data/norm_missing.obj", "vn 0.0 1.0\n"); testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/norm_missing.obj", out)); + EXPECT_FALSE(parser.parse("test_data/bare_slash.obj", out)); std::string output = testing::internal::GetCapturedStderr(); - EXPECT_NE(output.find("Failed to parse normal at normal 0"), std::string::npos); + EXPECT_FALSE(output.empty()); } -TEST_F(ObjParserTest, NormalInvalid) { - createTestFile("test_data/norm_invalid.obj", "vn 0.0 abc 1.0\n"); +TEST_F(ObjParserTest, FaceIndexMissingIndexAfterFirstSlash) { + createTestFile("test_data/empty_after_slash.obj", + "v 0 0 0\nvn 0 0 1\nf 1/ /1\n"); + testing::internal::CaptureStderr(); - EXPECT_FALSE(parser.parse("test_data/norm_invalid.obj", out)); + EXPECT_FALSE(parser.parse("test_data/empty_after_slash.obj", out)); std::string output = testing::internal::GetCapturedStderr(); - EXPECT_NE(output.find("Failed to parse normal"), std::string::npos); + EXPECT_NE(output.find("Expected texture coordinate index"), std::string::npos); } \ No newline at end of file From e062b4921acfe04b869b89232a0e7f5f25c87bc9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 26 Nov 2025 19:17:43 -0500 Subject: [PATCH 7/7] Implement OBJ vertex colour parsing --- .../parser/mesh/obj_parser.hpp | 4 +- src/parser/mesh/obj_parser.cpp | 50 ++++++++++++++++--- tests/mesh/obj_parser_test.cpp | 32 ++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/inc/starlet-serializer/parser/mesh/obj_parser.hpp b/inc/starlet-serializer/parser/mesh/obj_parser.hpp index 96e0cea..0803b79 100644 --- a/inc/starlet-serializer/parser/mesh/obj_parser.hpp +++ b/inc/starlet-serializer/parser/mesh/obj_parser.hpp @@ -25,7 +25,9 @@ namespace Serializer { int normI; }; - bool parsePosition(const unsigned char*& p, std::vector>& positions); + bool parsePosition(const unsigned char*& p, + std::vector>& positions, + std::vector>& colours); bool parseTexCoord(const unsigned char*& p, std::vector>& texCoords); bool parseNormal(const unsigned char*& p, std::vector>& normals); diff --git a/src/parser/mesh/obj_parser.cpp b/src/parser/mesh/obj_parser.cpp index 9111329..92d5753 100644 --- a/src/parser/mesh/obj_parser.cpp +++ b/src/parser/mesh/obj_parser.cpp @@ -22,6 +22,8 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { return Logger::error("ObjParser", "parse", "File is empty"); std::vector> positions; + std::vector> colours; + std::vector> texCoords; std::vector> normals; @@ -49,7 +51,7 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { } if (strcmp(reinterpret_cast(cmd), "v") == 0) { - if (!parsePosition(p, positions)) + if (!parsePosition(p, positions, colours)) return Logger::error("ObjParser", "parse", "Failed to parse vertex position at vertex " + std::to_string(positions.size())); } else if (strcmp(reinterpret_cast(cmd), "vt") == 0) { @@ -175,6 +177,7 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { else { Math::Vertex v{}; v.pos = positions[fv.posI]; + v.col = colours[fv.posI]; if (fv.texI >= 0) { v.texCoord = texCoords[fv.texI]; usedTexCoords = true; @@ -201,9 +204,12 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { } if (vertices.empty() && !positions.empty()) { - for (const Starlet::Math::Vec3& pos : positions) { + for (size_t i = 0; i < positions.size(); ++i) { Math::Vertex v{}; - v.pos = pos; + + v.pos = positions[i]; + v.col = colours[i]; + vertices.push_back(v); } } @@ -212,14 +218,46 @@ bool ObjParser::parse(const std::string& path, MeshData& out) { return true; } -bool ObjParser::parsePosition(const unsigned char*& p, std::vector>& positions) { +bool ObjParser::parsePosition(const unsigned char*& p, + std::vector>& positions, + std::vector>& colours) { + Starlet::Math::Vec3 pos; if (!parseVec3f(p, pos)) return false; - float w; - parseFloat(p, w); + Starlet::Math::Vec4 col{ 1.0f, 1.0f, 1.0f, 1.0f }; + float w{ 1.0f }; + + const unsigned char* start = p; + std::vector extra; + while (true) { + float val; + const unsigned char* save = p; + if (parseFloat(p, val)) + extra.push_back(val); + else { + p = save; + break; + } + } + + if (extra.size() == 1) { + w = extra[0]; + } + else if (extra.size() == 3) { + col.x = extra[0]; + col.y = extra[1]; + col.z = extra[2]; + } + else if (extra.size() == 4) { + col.x = extra[0]; + col.y = extra[1]; + col.z = extra[2]; + col.w = extra[3]; + } + colours.push_back(col); positions.push_back(pos); return true; } diff --git a/tests/mesh/obj_parser_test.cpp b/tests/mesh/obj_parser_test.cpp index 58e37dc..05fe543 100644 --- a/tests/mesh/obj_parser_test.cpp +++ b/tests/mesh/obj_parser_test.cpp @@ -40,6 +40,38 @@ TEST_F(ObjParserTest, VerticesWithComments) { EXPECT_EQ(out.numVertices, 2); } +TEST_F(ObjParserTest, VertexWithColour) { + createTestFile("test_data/vertex_color.obj", + "v 1.0 2.0 3.0 0.1 0.2 0.3 0.4\n"); + EXPECT_TRUE(parser.parse("test_data/vertex_color.obj", out)); + EXPECT_EQ(out.numVertices, 1); + EXPECT_FLOAT_EQ(out.vertices[0].pos.x, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.y, 2.0f); + EXPECT_FLOAT_EQ(out.vertices[0].pos.z, 3.0f); + EXPECT_FLOAT_EQ(out.vertices[0].col.x, 0.1f); + EXPECT_FLOAT_EQ(out.vertices[0].col.y, 0.2f); + EXPECT_FLOAT_EQ(out.vertices[0].col.z, 0.3f); + EXPECT_FLOAT_EQ(out.vertices[0].col.w, 0.4f); +} + +TEST_F(ObjParserTest, VertexColourOptionalAlpha) { + createTestFile("test_data/vertex_color_alpha.obj", + "v 1.0 2.0 3.0 0.5 0.5 0.5\n"); + EXPECT_TRUE(parser.parse("test_data/vertex_color_alpha.obj", out)); + EXPECT_EQ(out.numVertices, 1); + EXPECT_FLOAT_EQ(out.vertices[0].col.w, 1.0f); +} + +TEST_F(ObjParserTest, VertexColourAndFace) { + createTestFile("test_data/color_face.obj", + "v 0 0 0 1 0 0\nv 1 0 0 0 1 0\nv 0 1 0 0 0 1\n" + "f 1 2 3\n"); + EXPECT_TRUE(parser.parse("test_data/color_face.obj", out)); + EXPECT_EQ(out.numTriangles, 1); + EXPECT_FLOAT_EQ(out.vertices[0].col.x, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[1].col.y, 1.0f); + EXPECT_FLOAT_EQ(out.vertices[2].col.z, 1.0f); +} TEST_F(ObjParserTest, SingleTriangle) {