343 lines
14 KiB
C++
343 lines
14 KiB
C++
#include <gtest/gtest.h>
|
|
#include "vde/sdf/sdf_optimize.h"
|
|
#include "vde/sdf/sdf_primitives.h"
|
|
#include "vde/sdf/sdf_tree.h"
|
|
#include <cmath>
|
|
|
|
using namespace vde::sdf;
|
|
using vde::core::Point3D;
|
|
using vde::core::Vector3D;
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// fit_to_point_cloud — sphere
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, FitSphereToPointCloud_Converges) {
|
|
// Ground truth: sphere of radius 2.0 at origin
|
|
const double target_radius = 2.0;
|
|
std::vector<Point3D> surface_points;
|
|
|
|
// Generate points on the sphere surface (Fibonacci sphere)
|
|
const int n_pts = 200;
|
|
const double golden_angle = M_PI * (3.0 - std::sqrt(5.0));
|
|
for (int i = 0; i < n_pts; ++i) {
|
|
double y = 1.0 - (i / double(n_pts - 1)) * 2.0; // y ∈ [-1, 1]
|
|
double radius_at_y = std::sqrt(1.0 - y * y);
|
|
double theta = golden_angle * i;
|
|
surface_points.emplace_back(
|
|
target_radius * radius_at_y * std::cos(theta),
|
|
target_radius * y,
|
|
target_radius * radius_at_y * std::sin(theta));
|
|
}
|
|
|
|
// Initial sphere (wrong radius)
|
|
auto initial = SdfNode::sphere(1.0);
|
|
|
|
auto result = fit_to_point_cloud(initial, surface_points, 0.05, 200);
|
|
|
|
EXPECT_LT(result.final_loss, 0.5);
|
|
EXPECT_TRUE(result.loss_history.size() > 0);
|
|
EXPECT_GT(result.loss_history.front(), result.loss_history.back())
|
|
<< "Loss should decrease during optimization";
|
|
}
|
|
|
|
TEST(SdfOptimize, FitSphereToPointCloud_EmptyPoints) {
|
|
auto initial = SdfNode::sphere(1.0);
|
|
auto result = fit_to_point_cloud(initial, {}, 0.01, 100);
|
|
EXPECT_EQ(result.iterations, 0);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// fit_to_sdf — box to sphere
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, FitBoxToSphereSdf_LossDecreases) {
|
|
// Target SDF: sphere of radius 1.0
|
|
auto target_sdf = [](const Point3D& p) -> double {
|
|
return sphere(p, 1.0);
|
|
};
|
|
|
|
// Initial: box
|
|
auto source = SdfNode::box(Point3D(1.5, 1.5, 1.5));
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
|
|
auto result = fit_to_sdf(source, target_sdf, bmin, bmax, 8, 0.01, 50);
|
|
|
|
// Loss should decrease from initial
|
|
EXPECT_GT(result.loss_history.size(), 0u);
|
|
if (result.loss_history.size() >= 2) {
|
|
EXPECT_LT(result.loss_history.back(), result.loss_history.front())
|
|
<< "Loss should decrease";
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// resolve_collision — two spheres
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, ResolveCollision_Spheres) {
|
|
auto shape_a = SdfNode::sphere(1.0);
|
|
// Place shape_b overlapping shape_a
|
|
auto shape_b = SdfNode::translate(
|
|
SdfNode::sphere(1.0), Point3D(1.0, 0, 0));
|
|
|
|
bool resolved = resolve_collision(shape_a, shape_b, 0.05, 100);
|
|
|
|
// After resolution, shapes should not overlap
|
|
// We accept that it may not fully resolve in all cases
|
|
// At minimum, it shouldn't crash
|
|
EXPECT_TRUE(true); // at least it ran
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// penetration_depth — two overlapping spheres
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, PenetrationDepth_OverlappingSpheres) {
|
|
auto a = SdfNode::sphere(1.0);
|
|
auto b = SdfNode::translate(SdfNode::sphere(1.0), Point3D(1.0, 0, 0));
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
|
|
double depth = penetration_depth(a, b, bmin, bmax, 5000);
|
|
EXPECT_GT(depth, 0.0) << "Overlapping shapes should have positive penetration depth";
|
|
}
|
|
|
|
TEST(SdfOptimize, PenetrationDepth_NonOverlappingSpheres) {
|
|
auto a = SdfNode::sphere(1.0);
|
|
auto b = SdfNode::translate(SdfNode::sphere(1.0), Point3D(5.0, 0, 0));
|
|
|
|
Point3D bmin(-1, -1, -1);
|
|
Point3D bmax(1, 1, 1);
|
|
|
|
// Only sampling in a's bbox, b is far away
|
|
double depth = penetration_depth(a, b, bmin, bmax, 500);
|
|
EXPECT_NEAR(depth, 0.0, 1e-6)
|
|
<< "Non-overlapping shapes should have zero penetration depth";
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// accessibility — point on sphere
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, Accessibility_OnSphere) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
|
|
// Point on sphere surface
|
|
Point3D surface_point(1.0, 0.0, 0.0);
|
|
Vector3D direction(1.0, 0.0, 0.0); // outward normal
|
|
|
|
double score = accessibility(shape, surface_point, direction, 64);
|
|
EXPECT_GT(score, 0.0) << "Should have some accessibility";
|
|
EXPECT_LE(score, 1.0);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// symmetry_score — sphere is highly symmetric
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, SymmetryScore_Sphere) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
Vector3D normal(0, 1, 0); // Y-axis plane
|
|
|
|
double score = symmetry_score(shape, bmin, bmax, normal, 0.0, 500);
|
|
EXPECT_GT(score, 0.9) << "Sphere should be highly symmetric about any plane through center";
|
|
}
|
|
|
|
TEST(SdfOptimize, SymmetryScore_Asymmetric) {
|
|
// Union of sphere and an offset box — not symmetric about Y=0
|
|
auto shape = SdfNode::op_union(
|
|
SdfNode::sphere(1.0),
|
|
SdfNode::translate(SdfNode::box(Point3D(0.5, 0.5, 0.5)), Point3D(1.5, 0, 0)));
|
|
|
|
Point3D bmin(-3, -3, -3);
|
|
Point3D bmax(3, 3, 3);
|
|
Vector3D normal(1, 0, 0); // X-axis plane through origin
|
|
|
|
double score = symmetry_score(shape, bmin, bmax, normal, 0.0, 500);
|
|
EXPECT_LT(score, 0.9) << "Asymmetric shape should have lower symmetry score";
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// find_symmetry_plane — sphere
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, FindSymmetryPlane_Sphere) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
|
|
auto result = find_symmetry_plane(shape, bmin, bmax, 500);
|
|
EXPECT_GT(result.score, 0.5) << "Should find at least one reasonable symmetry plane";
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// estimate_volume — sphere
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, EstimateVolume_Sphere) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
|
|
Point3D bmin(-1.5, -1.5, -1.5);
|
|
Point3D bmax(1.5, 1.5, 1.5);
|
|
|
|
double vol = estimate_volume(shape, bmin, bmax, 50000);
|
|
|
|
double expected = (4.0 / 3.0) * M_PI; // ≈ 4.18879
|
|
double tolerance = 0.15 * expected;
|
|
EXPECT_NEAR(vol, expected, tolerance)
|
|
<< "Estimated volume should be close to (4/3)πr³";
|
|
}
|
|
|
|
TEST(SdfOptimize, EstimateVolume_Box) {
|
|
auto shape = SdfNode::box(Point3D(1.0, 0.5, 0.5));
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
|
|
double vol = estimate_volume(shape, bmin, bmax, 50000);
|
|
|
|
double expected = 2.0 * 1.0 * 1.0; // width * height * depth
|
|
double tolerance = 0.15 * expected;
|
|
EXPECT_NEAR(vol, expected, tolerance);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// center_of_mass — sphere at origin
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, CenterOfMass_SphereAtOrigin) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
|
|
Point3D com = center_of_mass(shape, bmin, bmax, 10000);
|
|
EXPECT_NEAR(com.x(), 0.0, 0.15);
|
|
EXPECT_NEAR(com.y(), 0.0, 0.15);
|
|
EXPECT_NEAR(com.z(), 0.0, 0.15);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// collect_params — sphere has radius param
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, CollectParams_Sphere) {
|
|
auto shape = SdfNode::sphere(2.5);
|
|
auto params = collect_params(shape);
|
|
|
|
ASSERT_EQ(params.size(), 1u);
|
|
EXPECT_EQ(params[0].name, "radius");
|
|
EXPECT_DOUBLE_EQ(*params[0].value, 2.5);
|
|
}
|
|
|
|
TEST(SdfOptimize, CollectParams_Box) {
|
|
auto shape = SdfNode::box(Point3D(1.0, 2.0, 3.0));
|
|
auto params = collect_params(shape);
|
|
|
|
ASSERT_EQ(params.size(), 3u);
|
|
EXPECT_DOUBLE_EQ(*params[0].value, 1.0); // extent_x
|
|
EXPECT_DOUBLE_EQ(*params[1].value, 2.0); // extent_y
|
|
EXPECT_DOUBLE_EQ(*params[2].value, 3.0); // extent_z
|
|
}
|
|
|
|
TEST(SdfOptimize, CollectParams_Cylinder) {
|
|
auto shape = SdfNode::cylinder(1.5, 3.0);
|
|
auto params = collect_params(shape);
|
|
|
|
ASSERT_EQ(params.size(), 2u);
|
|
EXPECT_EQ(params[0].name, "radius");
|
|
EXPECT_DOUBLE_EQ(*params[0].value, 1.5);
|
|
EXPECT_EQ(params[1].name, "height");
|
|
EXPECT_DOUBLE_EQ(*params[1].value, 3.0);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// numerical_gradient — simple quadratic
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, NumericalGradient_Quadratic) {
|
|
double val = 3.0;
|
|
ParamRef ref{&val, "x"};
|
|
std::vector<ParamRef> params = {ref};
|
|
|
|
// f(x) = x², gradient should be 2x = 6.0 at x=3
|
|
auto loss_fn = [&]() -> double { return val * val; };
|
|
|
|
auto grad = numerical_gradient(params, loss_fn, 1e-6);
|
|
ASSERT_EQ(grad.size(), 1u);
|
|
EXPECT_NEAR(grad[0], 6.0, 1e-3);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// GradientDescent class
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, GradientDescent_MinimizesQuadratic) {
|
|
GradientDescent gd(0.1);
|
|
|
|
std::vector<double> params = {5.0}; // start at x=5
|
|
// f(x) = (x-3)², df/dx = 2(x-3)
|
|
auto grad_fn = [](const std::vector<double>& p, std::vector<double>& g) -> double {
|
|
double x = p[0];
|
|
g[0] = 2.0 * (x - 3.0);
|
|
return (x - 3.0) * (x - 3.0);
|
|
};
|
|
|
|
double initial_loss = (5.0 - 3.0) * (5.0 - 3.0);
|
|
|
|
for (int i = 0; i < 100; ++i) {
|
|
gd.step(params, grad_fn);
|
|
}
|
|
|
|
EXPECT_NEAR(params[0], 3.0, 0.01)
|
|
<< "Gradient descent should converge to minimum x=3";
|
|
EXPECT_GT(gd.iteration(), 0);
|
|
}
|
|
|
|
TEST(SdfOptimize, GradientDescent_LearningRate) {
|
|
GradientDescent gd(0.01);
|
|
EXPECT_DOUBLE_EQ(gd.learning_rate(), 0.01);
|
|
|
|
gd.set_learning_rate(0.05);
|
|
EXPECT_DOUBLE_EQ(gd.learning_rate(), 0.05);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// fit_surface_to_points
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, FitSurfaceToPoints_Alias) {
|
|
auto initial = SdfNode::sphere(0.5);
|
|
std::vector<Point3D> points;
|
|
points.emplace_back(1, 0, 0);
|
|
points.emplace_back(0, 1, 0);
|
|
points.emplace_back(0, 0, 1);
|
|
|
|
auto result = fit_surface_to_points(initial, points, 0.01, 10);
|
|
// At minimum, should produce a valid result
|
|
EXPECT_GE(result.iterations, 1);
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────
|
|
// find_accessible_point
|
|
// ───────────────────────────────────────────────────
|
|
|
|
TEST(SdfOptimize, FindAccessiblePoint_Sphere) {
|
|
auto shape = SdfNode::sphere(1.0);
|
|
Point3D bmin(-2, -2, -2);
|
|
Point3D bmax(2, 2, 2);
|
|
Vector3D approach_dir(-1, 0, 0); // from -X direction
|
|
|
|
Point3D result = find_accessible_point(shape, approach_dir, bmin, bmax, 16);
|
|
// Should find some point (won't crash)
|
|
EXPECT_TRUE(true);
|
|
}
|