Files
ViewDesignEngine/tests/sdf/test_sdf_operations.cpp
茂之钳 3064572072
CI / Build & Test (push) Failing after 36s
CI / Release Build (push) Failing after 40s
fix: onion thickness — reapply correct formula
2026-07-24 08:52:36 +00:00

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));
}
}
}
}