diff --git a/tests/general/unit_test_gjk_comprehensive.cpp b/tests/general/unit_test_gjk_comprehensive.cpp new file mode 100644 index 0000000..46f07b8 --- /dev/null +++ b/tests/general/unit_test_gjk_comprehensive.cpp @@ -0,0 +1,277 @@ +// +// Comprehensive GJK tests. +// Covers: all 6 axis directions, diagonal cases, boundary touching, +// asymmetric sizes, nesting, symmetry, simplex info, far separation. +// +#include +#include +#include +#include + +using Mesh = omath::source_engine::Mesh; +using Collider = omath::source_engine::MeshCollider; +using Gjk = omath::collision::GjkAlgorithm; +using Vec3 = omath::Vector3; + +namespace +{ + // Unit cube [-1, 1]^3 in local space. + const std::vector> k_cube_vbo = { + { { -1.f, -1.f, -1.f }, {}, {} }, + { { -1.f, -1.f, 1.f }, {}, {} }, + { { -1.f, 1.f, -1.f }, {}, {} }, + { { -1.f, 1.f, 1.f }, {}, {} }, + { { 1.f, 1.f, 1.f }, {}, {} }, + { { 1.f, 1.f, -1.f }, {}, {} }, + { { 1.f, -1.f, 1.f }, {}, {} }, + { { 1.f, -1.f, -1.f }, {}, {} }, + }; + const std::vector> k_empty_ebo{}; + + Collider make_cube(const Vec3& origin = {}, const Vec3& scale = { 1, 1, 1 }) + { + Mesh m{ k_cube_vbo, k_empty_ebo, scale }; + m.set_origin(origin); + return Collider{ m }; + } +} // namespace + +// --------------------------------------------------------------------------- +// Separation — expect false +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, Separated_AlongPosX) +{ + // A extends to x=1, B starts at x=1.1 → clear gap + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 2.1f, 0, 0 }))); +} + +TEST(GjkComprehensive, Separated_AlongNegX) +{ + // B to the left of A + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ -2.1f, 0, 0 }))); +} + +TEST(GjkComprehensive, Separated_AlongPosY) +{ + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, 2.1f, 0 }))); +} + +TEST(GjkComprehensive, Separated_AlongNegY) +{ + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, -2.1f, 0 }))); +} + +TEST(GjkComprehensive, Separated_AlongPosZ) +{ + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, 0, 2.1f }))); +} + +TEST(GjkComprehensive, Separated_AlongNegZ) +{ + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, 0, -2.1f }))); +} + +TEST(GjkComprehensive, Separated_AlongDiagonal) +{ + // All components exceed 2.0 — no overlap on any axis + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 2.1f, 2.1f, 2.1f }))); +} + +TEST(GjkComprehensive, Separated_LargeDistance) +{ + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 100.f, 0, 0 }))); +} + +TEST(GjkComprehensive, Separated_AsymmetricSizes) +{ + // Big (scale 2, half-ext 2), small (scale 0.5, half-ext 0.5) at 2.6 → gap of 0.1 + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }, { 2, 2, 2 }), make_cube({ 2.6f, 0, 0 }, { 0.5f, 0.5f, 0.5f }))); +} + +// --------------------------------------------------------------------------- +// Overlap — expect true +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, Overlapping_AlongPosX) +{ + // B offset 1.5 → overlap depth 0.5 in X + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 1.5f, 0, 0 }))); +} + +TEST(GjkComprehensive, Overlapping_AlongNegX) +{ + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ -1.5f, 0, 0 }))); +} + +TEST(GjkComprehensive, Overlapping_AlongPosZ) +{ + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, 0, 1.5f }))); +} + +TEST(GjkComprehensive, Overlapping_AlongNegZ) +{ + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 0, 0, -1.5f }))); +} + +TEST(GjkComprehensive, Overlapping_AlongDiagonalXY) +{ + // Minkowski sum extends ±2 on each axis; offset (1,1,0) is inside + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 1.f, 1.f, 0.f }))); +} + +TEST(GjkComprehensive, Overlapping_AlongDiagonalXYZ) +{ + // All three axes overlap: (1,1,1) is inside the Minkowski sum + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 1.f, 1.f, 1.f }))); +} + +TEST(GjkComprehensive, FullyNested_SmallInsideBig) +{ + // Small cube (half-ext 0.5) fully inside big cube (half-ext 2) + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }, { 2, 2, 2 }), make_cube({ 0, 0, 0 }, { 0.5f, 0.5f, 0.5f }))); +} + +TEST(GjkComprehensive, FullyNested_OffCenter) +{ + // Small at (0.5, 0, 0) still fully inside big (half-ext 2) + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }, { 2, 2, 2 }), make_cube({ 0.5f, 0, 0 }, { 0.5f, 0.5f, 0.5f }))); +} + +TEST(GjkComprehensive, Overlapping_AsymmetricSizes) +{ + // Big (scale 2, half-ext 2) and small (scale 0.5, half-ext 0.5) at 2.0 → overlap 0.5 in X + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }, { 2, 2, 2 }), make_cube({ 2.0f, 0, 0 }, { 0.5f, 0.5f, 0.5f }))); +} + +// --------------------------------------------------------------------------- +// Boundary cases +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, BoundaryCase_JustColliding) +{ + // B at 1.999 — 0.001 overlap in X + EXPECT_TRUE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 1.999f, 0, 0 }))); +} + +TEST(GjkComprehensive, BoundaryCase_JustSeparated) +{ + // B at 2.001 — 0.001 gap in X + EXPECT_FALSE(Gjk::is_collide(make_cube({ 0, 0, 0 }), make_cube({ 2.001f, 0, 0 }))); +} + +// --------------------------------------------------------------------------- +// Symmetry +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, Symmetry_WhenColliding) +{ + const auto a = make_cube({ 0, 0, 0 }); + const auto b = make_cube({ 1.5f, 0, 0 }); + EXPECT_EQ(Gjk::is_collide(a, b), Gjk::is_collide(b, a)); +} + +TEST(GjkComprehensive, Symmetry_WhenSeparated) +{ + const auto a = make_cube({ 0, 0, 0 }); + const auto b = make_cube({ 2.1f, 0.5f, 0 }); + EXPECT_EQ(Gjk::is_collide(a, b), Gjk::is_collide(b, a)); +} + +TEST(GjkComprehensive, Symmetry_DiagonalSeparation) +{ + const auto a = make_cube({ 0, 0, 0 }); + const auto b = make_cube({ 1.5f, 1.5f, 1.5f }); + EXPECT_EQ(Gjk::is_collide(a, b), Gjk::is_collide(b, a)); +} + +// --------------------------------------------------------------------------- +// Simplex info +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, SimplexInfo_HitProducesSimplex4) +{ + // On collision the simplex must be a full tetrahedron (4 points) + const auto [hit, simplex] = Gjk::is_collide_with_simplex_info(make_cube({ 0, 0, 0 }), make_cube({ 0.5f, 0, 0 })); + EXPECT_TRUE(hit); + EXPECT_EQ(simplex.size(), 4u); +} + +TEST(GjkComprehensive, SimplexInfo_MissProducesLessThan4) +{ + // On non-collision the simplex can never be a full tetrahedron + const auto [hit, simplex] = Gjk::is_collide_with_simplex_info(make_cube({ 0, 0, 0 }), make_cube({ 2.1f, 0, 0 })); + EXPECT_FALSE(hit); + EXPECT_LT(simplex.size(), 4u); +} + +TEST(GjkComprehensive, SimplexInfo_HitAlongY) +{ + const auto [hit, simplex] = Gjk::is_collide_with_simplex_info(make_cube({ 0, 0, 0 }), make_cube({ 0, 1.5f, 0 })); + EXPECT_TRUE(hit); + EXPECT_EQ(simplex.size(), 4u); +} + +TEST(GjkComprehensive, SimplexInfo_HitAlongZ) +{ + const auto [hit, simplex] = Gjk::is_collide_with_simplex_info(make_cube({ 0, 0, 0 }), make_cube({ 0, 0, 1.5f })); + EXPECT_TRUE(hit); + EXPECT_EQ(simplex.size(), 4u); +} + +TEST(GjkComprehensive, SimplexInfo_MissAlongDiagonal) +{ + const auto [hit, simplex] = Gjk::is_collide_with_simplex_info(make_cube({ 0, 0, 0 }), make_cube({ 2.1f, 2.1f, 2.1f })); + EXPECT_FALSE(hit); + EXPECT_LT(simplex.size(), 4u); +} + +// --------------------------------------------------------------------------- +// Non-trivial geometry — tetrahedron shaped colliders +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, TetrahedronShapes_Overlapping) +{ + // A rough tetrahedron mesh; two of them close enough to overlap + const std::vector> tet_vbo = { + { { 0.f, 1.f, 0.f }, {}, {} }, + { { -1.f, -1.f, 1.f }, {}, {} }, + { { 1.f, -1.f, 1.f }, {}, {} }, + { { 0.f, -1.f, -1.f }, {}, {} }, + }; + + Mesh m_a{ tet_vbo, k_empty_ebo }; + Mesh m_b{ tet_vbo, k_empty_ebo }; + m_b.set_origin({ 0.5f, 0.f, 0.f }); + + EXPECT_TRUE(Gjk::is_collide(Collider{ m_a }, Collider{ m_b })); +} + +TEST(GjkComprehensive, TetrahedronShapes_Separated) +{ + const std::vector> tet_vbo = { + { { 0.f, 1.f, 0.f }, {}, {} }, + { { -1.f, -1.f, 1.f }, {}, {} }, + { { 1.f, -1.f, 1.f }, {}, {} }, + { { 0.f, -1.f, -1.f }, {}, {} }, + }; + + Mesh m_a{ tet_vbo, k_empty_ebo }; + Mesh m_b{ tet_vbo, k_empty_ebo }; + m_b.set_origin({ 3.f, 0.f, 0.f }); + + EXPECT_FALSE(Gjk::is_collide(Collider{ m_a }, Collider{ m_b })); +} + +// --------------------------------------------------------------------------- +// Determinism +// --------------------------------------------------------------------------- + +TEST(GjkComprehensive, Deterministic_SameResultOnRepeatedCalls) +{ + const auto a = make_cube({ 0, 0, 0 }); + const auto b = make_cube({ 1.2f, 0.3f, 0.1f }); + const bool first = Gjk::is_collide(a, b); + for (int i = 0; i < 10; ++i) + EXPECT_EQ(Gjk::is_collide(a, b), first); +}