413 lines
15 KiB
C++
413 lines
15 KiB
C++
#include <gtest/gtest.h>
|
|
#include <cmath>
|
|
#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<double>::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<double>::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));
|
|
}
|
|
}
|
|
}
|
|
}
|