#include #include #include "vde/sdf/sdf_operations.h" using namespace vde::sdf; using vde::core::Point3D; // ── Minimal inline primitives for testing (no sdf_primitives dependency) ── inline double sdf_sphere(const Point3D& p, double r = 1.0) { return std::sqrt(p.x()*p.x() + p.y()*p.y() + p.z()*p.z()) - r; } inline double sdf_box(const Point3D& p, const Point3D& b) { auto q = Point3D(std::abs(p.x()) - b.x(), std::abs(p.y()) - b.y(), std::abs(p.z()) - b.z()); return std::max({q.x(), q.y(), q.z(), 0.0}) + std::min(std::max({q.x(), q.y(), q.z()}), 0.0); } // ═══════════════════════════════════════════════════ // Sharp Boolean Operations // ═══════════════════════════════════════════════════ TEST(SdfOperations, Union) { EXPECT_DOUBLE_EQ(op_union(1.0, 2.0), 1.0); EXPECT_DOUBLE_EQ(op_union(-1.0, 2.0), -1.0); EXPECT_DOUBLE_EQ(op_union(3.0, -1.0), -1.0); EXPECT_DOUBLE_EQ(op_union(-3.0, -1.0), -3.0); } TEST(SdfOperations, Intersection) { EXPECT_DOUBLE_EQ(op_intersection(1.0, 2.0), 2.0); EXPECT_DOUBLE_EQ(op_intersection(-1.0, 2.0), 2.0); EXPECT_DOUBLE_EQ(op_intersection(3.0, -1.0), 3.0); EXPECT_DOUBLE_EQ(op_intersection(-3.0, -1.0), -1.0); } TEST(SdfOperations, Difference) { // d1 is the body, d2 is the cutter // difference = max(d1, -d2) EXPECT_DOUBLE_EQ(op_difference(1.0, 2.0), 1.0); // max(1,-2)=1 EXPECT_DOUBLE_EQ(op_difference(3.0, 2.0), 3.0); // max(3,-2)=3 EXPECT_DOUBLE_EQ(op_difference(-1.0, 2.0), -1.0); // max(-1,-2)=-1 EXPECT_DOUBLE_EQ(op_difference(2.0, -3.0), 3.0); // max(2,3)=3 } TEST(SdfOperations, UnionIdentity) { // Union with positive infinity yields the other value double inf = std::numeric_limits::infinity(); EXPECT_DOUBLE_EQ(op_union(5.0, inf), 5.0); EXPECT_DOUBLE_EQ(op_union(inf, 5.0), 5.0); } TEST(SdfOperations, IntersectionIdentity) { double inf = std::numeric_limits::infinity(); EXPECT_DOUBLE_EQ(op_intersection(5.0, -inf), 5.0); EXPECT_DOUBLE_EQ(op_intersection(-inf, 5.0), 5.0); } // ═══════════════════════════════════════════════════ // Smooth Boolean Operations // ═══════════════════════════════════════════════════ TEST(SdfOperations, SmoothUnionFallsBackToSharp) { // k=0 should fall back to sharp union EXPECT_DOUBLE_EQ(op_smooth_union(1.0, 2.0, 0.0), 1.0); EXPECT_DOUBLE_EQ(op_smooth_union(-1.0, 2.0, 0.0), -1.0); // k negative should also fall back EXPECT_DOUBLE_EQ(op_smooth_union(1.0, 2.0, -5.0), 1.0); } TEST(SdfOperations, SmoothUnionBlending) { // With k > 0, result should be less than min(d1,d2) (blend digs in) double k = 0.1; double result = op_smooth_union(1.0, 1.0, k); EXPECT_LT(result, 1.0); // blend reduces distance EXPECT_GE(result, 1.0 - k * 0.25); // bound: max reduction is k/4 at h=0.5 // Far apart — should approach sharp union double far_result = op_smooth_union(1.0, 10.0, 0.1); EXPECT_NEAR(far_result, 1.0, 0.01); } TEST(SdfOperations, SmoothIntersectionFallsBackToSharp) { EXPECT_DOUBLE_EQ(op_smooth_intersection(1.0, 2.0, 0.0), 2.0); EXPECT_DOUBLE_EQ(op_smooth_intersection(1.0, 2.0, -1.0), 2.0); } TEST(SdfOperations, SmoothIntersectionBlending) { double k = 0.1; double result = op_smooth_intersection(1.0, 1.0, k); EXPECT_GT(result, 1.0); // blend increases distance EXPECT_LE(result, 1.0 + k * 0.25); // bound: max increase is k/4 at h=0.5 } TEST(SdfOperations, SmoothDifferenceFallsBackToSharp) { EXPECT_DOUBLE_EQ(op_smooth_difference(1.0, 2.0, 0.0), 1.0); EXPECT_DOUBLE_EQ(op_smooth_difference(-1.0, 2.0, 0.0), -1.0); } // ═══════════════════════════════════════════════════ // Shape Modifiers // ═══════════════════════════════════════════════════ TEST(SdfOperations, Round) { EXPECT_DOUBLE_EQ(op_round(5.0, 2.0), 3.0); EXPECT_DOUBLE_EQ(op_round(-1.0, 0.5), -1.5); EXPECT_DOUBLE_EQ(op_round(0.0, 3.0), -3.0); } TEST(SdfOperations, Onion) { // Standard op_onion: abs(d) - thickness EXPECT_DOUBLE_EQ(op_onion(0.0, 2.0), -2.0); // abs(0) - 2 = -2 EXPECT_DOUBLE_EQ(op_onion(2.0, 2.0), 0.0); // abs(2) - 2 = 0 EXPECT_DOUBLE_EQ(op_onion(-2.0, 2.0), 0.0); // abs(-2) - 2 = 0 EXPECT_DOUBLE_EQ(op_onion(0.5, 2.0), -1.5); // abs(0.5) - 2 = -1.5 } TEST(SdfOperations, OnionSphere) { // Sphere of radius 1 with thickness 0.2 creates a shell // abs(d) - 0.2 → inner surface at r=0.8, outer at r=1.2 double d = sdf_sphere(Point3D(0, 0, 0.8)); double shell = op_onion(d, 0.2); EXPECT_NEAR(shell, 0.0, 1e-10); // inner surface at r=0.8 d = sdf_sphere(Point3D(0, 0, 1.0)); shell = op_onion(d, 0.2); EXPECT_NEAR(shell, -0.2, 1e-10); // inside shell at r=1.0 } // ═══════════════════════════════════════════════════ // Domain Deformations // ═══════════════════════════════════════════════════ TEST(SdfOperations, RepeatIdentity) { // repeat with zero cell size = no repetition auto p = Point3D(5.0, 3.0, -2.0); auto result = op_repeat(p, Point3D(0, 0, 0)); EXPECT_DOUBLE_EQ(result.x(), p.x()); EXPECT_DOUBLE_EQ(result.y(), p.y()); EXPECT_DOUBLE_EQ(result.z(), p.z()); } TEST(SdfOperations, RepeatMapsToUnitCell) { // With cell size (2,2,2), all points should map to [-1, 1] auto c = Point3D(2.0, 2.0, 2.0); // Center of cell → maps to 0 auto r1 = op_repeat(Point3D(0, 0, 0), c); EXPECT_NEAR(r1.x(), 0.0, 1e-10); EXPECT_NEAR(r1.y(), 0.0, 1e-10); EXPECT_NEAR(r1.z(), 0.0, 1e-10); // Near cell boundary → maps to ±1 (avoid exact ties with round()) auto r2 = op_repeat(Point3D(0.999, 0.999, 0.999), c); EXPECT_NEAR(r2.x(), 0.999, 1e-10); EXPECT_NEAR(r2.y(), 0.999, 1e-10); EXPECT_NEAR(r2.z(), 0.999, 1e-10); // Outside the cell auto r3 = op_repeat(Point3D(3.5, 2.5, 1.5), c); EXPECT_NEAR(r3.x(), -0.5, 1e-10); // 3.5 - 2*2 = -0.5 EXPECT_NEAR(r3.y(), 0.5, 1e-10); // 2.5 - 1*2 = 0.5 EXPECT_NEAR(r3.z(), -0.5, 1e-10); // 1.5 - 1*2 = -0.5 } TEST(SdfOperations, RepeatSingleAxis) { // Only repeat along X auto c = Point3D(3.0, 0.0, 0.0); auto r = op_repeat(Point3D(5.0, 2.0, 1.0), c); EXPECT_NEAR(r.x(), -1.0, 1e-10); // 5 - 2*3 = -1 EXPECT_DOUBLE_EQ(r.y(), 2.0); // unchanged EXPECT_DOUBLE_EQ(r.z(), 1.0); // unchanged } TEST(SdfOperations, RepeatSphereGrid) { // Repeated sphere: check that sphere exists at multiple cell positions auto sphere_repeated = [](const Point3D& p) -> double { return sdf_sphere(op_repeat(p, Point3D(3.0, 3.0, 3.0)), 0.5); }; // Center of any cell should be inside the sphere EXPECT_LT(sphere_repeated(Point3D(0, 0, 0)), 0.0); EXPECT_LT(sphere_repeated(Point3D(3, 0, 0)), 0.0); EXPECT_LT(sphere_repeated(Point3D(-3, 6, -9)), 0.0); // Midpoint between cells should be outside EXPECT_GT(sphere_repeated(Point3D(1.5, 0, 0)), 0.0); EXPECT_GT(sphere_repeated(Point3D(0, 1.5, 0)), 0.0); } TEST(SdfOperations, Mirror) { // Mirror across X=0: everything maps to x >= 0 auto r1 = op_mirror_x(Point3D(3.0, 1.0, 1.0)); EXPECT_DOUBLE_EQ(r1.x(), 3.0); // positive stays positive EXPECT_DOUBLE_EQ(r1.y(), 1.0); EXPECT_DOUBLE_EQ(r1.z(), 1.0); auto r2 = op_mirror_x(Point3D(-3.0, 2.0, 2.0)); EXPECT_DOUBLE_EQ(r2.x(), 3.0); // negative folds to positive EXPECT_DOUBLE_EQ(r2.y(), 2.0); EXPECT_DOUBLE_EQ(r2.z(), 2.0); // With offset auto r3 = op_mirror_x(Point3D(-3.0, 0, 0), 1.0); EXPECT_DOUBLE_EQ(r3.x(), 5.0); // 1 + abs(-3 - 1) = 1 + 4 = 5 } TEST(SdfOperations, RotateY) { // 90° rotation around Y: (1,0,0) → (0,0,-1) auto r1 = op_rotate(Point3D(1, 0, 0), M_PI / 2.0); EXPECT_NEAR(r1.x(), 0.0, 1e-10); EXPECT_NEAR(r1.y(), 0.0, 1e-10); EXPECT_NEAR(r1.z(), -1.0, 1e-10); // 180° rotation: (1,0,0) → (-1,0,0) auto r2 = op_rotate(Point3D(1, 0, 0), M_PI); EXPECT_NEAR(r2.x(), -1.0, 1e-10); // 360° → identity auto r3 = op_rotate(Point3D(3.0, 2.0, 1.0), 2.0 * M_PI); EXPECT_NEAR(r3.x(), 3.0, 1e-10); EXPECT_NEAR(r3.y(), 2.0, 1e-10); EXPECT_NEAR(r3.z(), 1.0, 1e-10); } TEST(SdfOperations, Translate) { auto r = op_translate(Point3D(5, 3, 1), Point3D(2, 1, 0)); EXPECT_DOUBLE_EQ(r.x(), 3.0); // 5 - 2 EXPECT_DOUBLE_EQ(r.y(), 2.0); // 3 - 1 EXPECT_DOUBLE_EQ(r.z(), 1.0); // 1 - 0 } TEST(SdfOperations, Scale) { auto s = Point3D(2.0, 4.0, 0.5); auto r = op_scale(Point3D(4, 8, 1), s); EXPECT_DOUBLE_EQ(r.x(), 2.0); EXPECT_DOUBLE_EQ(r.y(), 2.0); EXPECT_DOUBLE_EQ(r.z(), 2.0); } TEST(SdfOperations, Twist) { // At y=0, twist has no effect (angle = 0) auto r1 = op_twist(Point3D(1, 0, 0), 1.0); EXPECT_NEAR(r1.x(), 1.0, 1e-10); EXPECT_NEAR(r1.z(), 0.0, 1e-10); // At y>0, twist rotates in xz plane auto r2 = op_twist(Point3D(1, 0, 0), 0.5); // still y=0 so no twist EXPECT_NEAR(r2.x(), 1.0, 1e-10); EXPECT_NEAR(r2.z(), 0.0, 1e-10); // Twist should be invertible: twist of -amount should cancel auto p = Point3D(1.5, 2.0, 0.5); auto twisted = op_twist(p, 0.3); // Length should be preserved (rotation preserves norm in xz plane) double orig_xz_len = std::sqrt(p.x()*p.x() + p.z()*p.z()); double twisted_xz_len = std::sqrt(twisted.x()*twisted.x() + twisted.z()*twisted.z()); EXPECT_NEAR(orig_xz_len, twisted_xz_len, 1e-10); EXPECT_DOUBLE_EQ(twisted.y(), p.y()); // y unchanged } TEST(SdfOperations, Bend) { // No bend: amount=0 → identity auto p = Point3D(1.0, 2.0, 3.0); auto r = op_bend(p, 0.0); EXPECT_NEAR(r.x(), p.x(), 1e-10); EXPECT_NEAR(r.y(), p.y(), 1e-10); EXPECT_NEAR(r.z(), p.z(), 1e-10); // Bend with small amount produces valid coordinates auto r2 = op_bend(p, 0.1); EXPECT_TRUE(std::isfinite(r2.x())); EXPECT_TRUE(std::isfinite(r2.y())); EXPECT_TRUE(std::isfinite(r2.z())); } TEST(SdfOperations, Elongate) { auto f = Point3D(2.0, 1.0, 0.5); auto r = op_elongate(Point3D(4, 3, 2), f); EXPECT_DOUBLE_EQ(r.x(), 2.0); EXPECT_DOUBLE_EQ(r.y(), 2.0); EXPECT_DOUBLE_EQ(r.z(), 1.5); } TEST(SdfOperations, Displace) { // At origin, all sin terms = 0 → no displacement double d = op_displace(1.0, Point3D(0, 0, 0), 0.5, 2.0); EXPECT_DOUBLE_EQ(d, 1.0); // Displacement is bounded by amplitude double d2 = op_displace(1.0, Point3D(1.0, 2.0, 3.0), 0.5, 1.0); EXPECT_GE(d2, 0.5); EXPECT_LE(d2, 1.5); } TEST(SdfOperations, CheapBend) { // No bend: k=0 → identity auto p = Point3D(2.0, 3.0, 4.0); auto r1 = op_cheap_bend(p, 0.0); EXPECT_NEAR(r1.x(), p.x(), 1e-10); EXPECT_NEAR(r1.y(), p.y(), 1e-10); EXPECT_NEAR(r1.z(), p.z(), 1e-10); // At x=0, cheap bend has no effect (sin(0)=0, cos(0)=1) auto r2 = op_cheap_bend(Point3D(0, 3, 1), 1.0); EXPECT_NEAR(r2.x(), 0.0, 1e-10); EXPECT_NEAR(r2.y(), 3.0, 1e-10); EXPECT_NEAR(r2.z(), 1.0, 1e-10); } // ═══════════════════════════════════════════════════ // Composition Tests // ═══════════════════════════════════════════════════ TEST(SdfOperations, CompositionRepeatTwistSphere) { // Repeat + twist + sphere: sphere repeated in grid, then twisted auto composed = [](const Point3D& p) -> double { auto q = op_repeat(p, Point3D(2.0, 2.0, 2.0)); q = op_twist(q, 0.5); return sdf_sphere(q, 0.3); }; // Near origin (center of a cell) should be inside sphere EXPECT_LT(composed(Point3D(0, 0, 0)), 0.0); // Further away in the grid should also have spheres // (repeat maps cell centers to origin) EXPECT_LT(composed(Point3D(2, 0, 0)), 0.0); EXPECT_LT(composed(Point3D(0, 2, 0)), 0.0); // Away from cell centers should be outside EXPECT_GT(composed(Point3D(1.0, 1.0, 1.0)), 0.0); } TEST(SdfOperations, CompositionBooleanWithDeformations) { // Union of two translated spheres auto scene = [](const Point3D& p) -> double { double d1 = sdf_sphere(op_translate(p, Point3D(1.5, 0, 0)), 1.0); double d2 = sdf_sphere(op_translate(p, Point3D(-1.5, 0, 0)), 1.0); return op_union(d1, d2); }; // Between the two spheres (origin) should be outside EXPECT_GT(scene(Point3D(0, 0, 0)), 0.0); // Center of each sphere should be inside EXPECT_LT(scene(Point3D(1.5, 0, 0)), 0.0); EXPECT_LT(scene(Point3D(-1.5, 0, 0)), 0.0); } TEST(SdfOperations, CompositionSmoothUnionSpheres) { auto scene = [](const Point3D& p) -> double { double d1 = sdf_sphere(op_translate(p, Point3D(1.0, 0, 0)), 1.0); double d2 = sdf_sphere(op_translate(p, Point3D(-1.0, 0, 0)), 1.0); return op_smooth_union(d1, d2, 0.3); }; // Midpoint between spheres should be within the blend region double d = scene(Point3D(0, 0, 0)); // Both spheres at origin: distance = sqrt(1^2)-1 = 0 // h = clamp(0.5 + 0.5*(0-0)/0.3, 0, 1) = 0.5 // mix(0,0,0.5) - 0.3*0.5*0.5 = 0 - 0.075 = -0.075 EXPECT_LT(d, 0.0); // should be inside blend region EXPECT_GT(d, -0.3); // but not too deep } TEST(SdfOperations, CompositionDifferenceWithTwist) { // Sphere minus a smaller twisted sphere auto scene = [](const Point3D& p) -> double { double body = sdf_sphere(p, 2.0); auto q = op_twist(p, 1.0); double cutter = sdf_sphere(q, 1.0); return op_difference(body, cutter); }; // At origin, both spheres contain the point // body: sqrt(0) - 2 = -2; cutter: sqrt(0) - 1 = -1 // difference = max(-2, -(-1)) = max(-2, 1) = 1 → outside! EXPECT_GT(scene(Point3D(0, 0, 0)), 0.0); // At r=1.5 from origin: body=-0.5, cutter=0.5 (at y=0 twist=0 → sphere at r=1) // difference = max(0.5, 0.5) = 0.5 → outside // Actually let's test a clear case: far outside both EXPECT_GT(scene(Point3D(3.0, 0, 0)), 0.0); // far outside } TEST(SdfOperations, MultipleDeformationsChain) { // Translate → rotate → twist → repeat → sphere auto composed = [](const Point3D& p) -> double { auto q = op_translate(p, Point3D(1.0, 0.0, 0.0)); q = op_rotate(q, M_PI / 4.0); q = op_repeat(q, Point3D(3.0, 3.0, 3.0)); return sdf_sphere(q, 0.5); }; // Should not crash or produce NaN double d = composed(Point3D(0, 0, 0)); EXPECT_TRUE(std::isfinite(d)); // Check at multiple points for (double x = -4.0; x <= 4.0; x += 2.0) { for (double y = -4.0; y <= 4.0; y += 2.0) { for (double z = -4.0; z <= 4.0; z += 2.0) { double val = composed(Point3D(x, y, z)); EXPECT_TRUE(std::isfinite(val)); } } } }