Skip to content

Commit a9c8463

Browse files
cdtwiggfacebook-github-bot
authored andcommitted
Add selectable inside/outside method for SDF sign determination (#1393)
Summary: Add a `SignMethod` enum to `MeshToSdfConfig` with three options: - `RayCasting` (default): existing 6-direction majority vote. Fast but fails on non-watertight meshes where rays pass through gaps. - `WindingNumber`: signed winding number (wn > 0.5), assumes consistent outward-facing normals. Will reject meshes with accidentally flipped normals rather than silently accepting them. - `WindingNumberPermissive`: absolute winding number (abs(wn) > 0.5), tolerant of either winding convention. Handles mesh soups, mixed orientations, and geometry from multiple sources. Both winding number variants use the Van Oosterom-Strackee solid angle formula, computed per voxel over all triangles. No new dependencies — the computation is self-contained. The sign determination step was already isolated in `applySignsToDistanceField`, so the change is minimal: the function now accepts a `SignMethod` parameter and dispatches to the appropriate classifier. Reviewed By: cstollmeta Differential Revision: D104091366
1 parent c991716 commit a9c8463

5 files changed

Lines changed: 383 additions & 33 deletions

File tree

axel/axel/MeshToSdf.cpp

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,8 @@ struct VoxelCandidate {
4949
};
5050

5151
// ================================================================================================
52-
// STEP 2: FAST MARCHING PROPAGATION
53-
// UTILITY FUNCTIONS
54-
// UTILITY FUNCTIONS
55-
// UTILITY FUNCTIONS
56-
// =========================================================================================
52+
// UTILITY TYPES
53+
// ================================================================================================
5754

5855
/**
5956
* Voxel states for fast marching algorithm.
@@ -137,7 +134,7 @@ void meshToSdfImpl(
137134
if (config.verbose) {
138135
std::cout << "Step 3: Applying signs..." << std::endl;
139136
}
140-
applySignsToDistanceField(sdf, vertices, triangles);
137+
applySignsToDistanceField(sdf, vertices, triangles, config.signMethod);
141138
if (config.verbose) {
142139
std::cout << "Signs applied" << std::endl;
143140
}
@@ -674,34 +671,100 @@ bool isPointInsideByRayCasting(
674671
return insideCount > (directions.size() / 2);
675672
}
676673

674+
/**
675+
* Compute the solid angle subtended by a triangle as seen from a point.
676+
* Uses the Van Oosterom-Strackee formula.
677+
*/
678+
template <typename ScalarType>
679+
ScalarType triangleSolidAngle(
680+
const Eigen::Vector3<ScalarType>& point,
681+
const Eigen::Vector3<ScalarType>& v0,
682+
const Eigen::Vector3<ScalarType>& v1,
683+
const Eigen::Vector3<ScalarType>& v2) {
684+
const Eigen::Vector3<ScalarType> a = v0 - point;
685+
const Eigen::Vector3<ScalarType> b = v1 - point;
686+
const Eigen::Vector3<ScalarType> c = v2 - point;
687+
688+
const ScalarType la = a.norm();
689+
const ScalarType lb = b.norm();
690+
const ScalarType lc = c.norm();
691+
692+
// Degenerate case: point is on a vertex
693+
constexpr ScalarType kEps = std::numeric_limits<ScalarType>::epsilon() * ScalarType{100};
694+
if (la < kEps || lb < kEps || lc < kEps) {
695+
return ScalarType{0};
696+
}
697+
698+
const ScalarType numerator = a.dot(b.cross(c));
699+
const ScalarType denominator = la * lb * lc + a.dot(b) * lc + b.dot(c) * la + c.dot(a) * lb;
700+
701+
return ScalarType{2} * std::atan2(numerator, denominator);
702+
}
703+
704+
/**
705+
* Compute the generalized winding number at a point.
706+
* Sums the solid angle of each triangle as seen from the query point,
707+
* normalized by 4pi. Returns +1 for inside (outward normals), -1 for
708+
* inside (inward normals), ~0 for outside.
709+
*/
710+
template <typename ScalarType>
711+
ScalarType computeWindingNumber(
712+
const Eigen::Vector3<ScalarType>& point,
713+
std::span<const Eigen::Vector3<ScalarType>> vertices,
714+
std::span<const Eigen::Vector3i> triangles) {
715+
ScalarType totalSolidAngle{0};
716+
constexpr ScalarType kFourPi = ScalarType{4} * ScalarType{M_PI};
717+
718+
for (const auto& tri : triangles) {
719+
totalSolidAngle +=
720+
triangleSolidAngle(point, vertices[tri.x()], vertices[tri.y()], vertices[tri.z()]);
721+
}
722+
723+
return totalSolidAngle / kFourPi;
724+
}
725+
677726
template <typename ScalarType>
678727
void applySignsToDistanceField(
679728
SignedDistanceField<ScalarType>& sdf,
680729
std::span<const Eigen::Vector3<ScalarType>> vertices,
681-
std::span<const Eigen::Vector3i> triangles) {
730+
std::span<const Eigen::Vector3i> triangles,
731+
SignMethod signMethod) {
682732
const auto& resolution = sdf.resolution();
683733

684-
// BUILD BVH ONCE for efficient ray casting
685-
// Convert spans to matrices for TriBvh constructor
686-
Eigen::MatrixX3<ScalarType> vertexMatrix(vertices.size(), 3);
687-
for (size_t i = 0; i < vertices.size(); ++i) {
688-
vertexMatrix.row(i) = vertices[i];
689-
}
734+
// Build BVH for ray casting (only needed for RayCasting method, but cheap to build)
735+
std::unique_ptr<TriBvh<ScalarType>> bvh;
736+
if (signMethod == SignMethod::RayCasting) {
737+
Eigen::MatrixX3<ScalarType> vertexMatrix(vertices.size(), 3);
738+
for (size_t i = 0; i < vertices.size(); ++i) {
739+
vertexMatrix.row(i) = vertices[i];
740+
}
690741

691-
Eigen::MatrixX3i triangleMatrix(triangles.size(), 3);
692-
for (size_t i = 0; i < triangles.size(); ++i) {
693-
triangleMatrix.row(i) = triangles[i];
694-
}
742+
Eigen::MatrixX3i triangleMatrix(triangles.size(), 3);
743+
for (size_t i = 0; i < triangles.size(); ++i) {
744+
triangleMatrix.row(i) = triangles[i];
745+
}
695746

696-
const TriBvh<ScalarType> bvh(std::move(vertexMatrix), std::move(triangleMatrix));
747+
bvh = std::make_unique<TriBvh<ScalarType>>(std::move(vertexMatrix), std::move(triangleMatrix));
748+
}
697749

698750
// Process each voxel
699751
const auto processVoxel = [&](Index i, Index j, Index k) {
700752
const Eigen::Vector3<ScalarType> gridPos(
701753
static_cast<ScalarType>(i), static_cast<ScalarType>(j), static_cast<ScalarType>(k));
702754
const auto worldPos = sdf.gridToWorld(gridPos);
703755

704-
bool isInside = isPointInsideByRayCasting(worldPos, bvh);
756+
bool isInside = false;
757+
switch (signMethod) {
758+
case SignMethod::RayCasting:
759+
isInside = isPointInsideByRayCasting(worldPos, *bvh);
760+
break;
761+
case SignMethod::WindingNumber:
762+
isInside = computeWindingNumber(worldPos, vertices, triangles) > ScalarType{0.5};
763+
break;
764+
case SignMethod::WindingNumberPermissive:
765+
isInside = std::abs(computeWindingNumber(worldPos, vertices, triangles)) > ScalarType{0.5};
766+
break;
767+
}
705768

706769
// Apply sign: negative inside, positive outside
707770
if (isInside) {
@@ -836,12 +899,14 @@ template void fastMarchingPropagate<double>(SignedDistanceField<double>&);
836899
template void applySignsToDistanceField<float>(
837900
SignedDistanceField<float>&,
838901
std::span<const Eigen::Vector3<float>>,
839-
std::span<const Eigen::Vector3i>);
902+
std::span<const Eigen::Vector3i>,
903+
SignMethod);
840904

841905
template void applySignsToDistanceField<double>(
842906
SignedDistanceField<double>&,
843907
std::span<const Eigen::Vector3<double>>,
844-
std::span<const Eigen::Vector3i>);
908+
std::span<const Eigen::Vector3i>,
909+
SignMethod);
845910

846911
template BoundingBox<float> computeMeshBounds<float>(std::span<const Eigen::Vector3<float>>);
847912

axel/axel/MeshToSdf.h

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@
2020

2121
namespace axel {
2222

23+
/**
24+
* Method for determining inside/outside classification in SDF sign determination.
25+
*/
26+
enum class SignMethod {
27+
/// Ray casting with 6-direction majority vote. Fast but fails on non-watertight meshes.
28+
RayCasting,
29+
30+
/// Winding number assuming consistent outward-facing normals (wn > 0.5).
31+
/// Will reject meshes with accidentally flipped normals rather than silently
32+
/// accepting them. Use when triangle orientation is known to be correct.
33+
WindingNumber,
34+
35+
/// Winding number tolerant of either winding convention (abs(wn) > 0.5).
36+
/// Handles mesh soups, mixed orientations, and geometry from multiple sources
37+
/// where consistent winding cannot be guaranteed.
38+
WindingNumberPermissive,
39+
};
40+
2341
/**
2442
* Configuration parameters for mesh-to-SDF conversion.
2543
*/
@@ -39,6 +57,9 @@ struct MeshToSdfConfig {
3957

4058
/// Print progress messages to stdout
4159
bool verbose = false;
60+
61+
/// Method for inside/outside classification during sign determination.
62+
SignMethod signMethod = SignMethod::RayCasting;
4263
};
4364

4465
/**
@@ -141,7 +162,8 @@ template <typename ScalarType>
141162
void applySignsToDistanceField(
142163
SignedDistanceField<ScalarType>& sdf,
143164
std::span<const Eigen::Vector3<ScalarType>> vertices,
144-
std::span<const Eigen::Vector3i> triangles);
165+
std::span<const Eigen::Vector3i> triangles,
166+
SignMethod signMethod = SignMethod::RayCasting);
145167

146168
/**
147169
* Compute mesh bounding box from vertex spans.

axel/axel/test/MeshToSdfTest.cpp

Lines changed: 150 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -448,37 +448,97 @@ TEST_F(MeshToSdfTest, Step3_SignDetermination_InsideOutsideAccuracy) {
448448
EXPECT_GT(clearlyOutsideVoxels, 0) << "Should have some clearly outside voxels";
449449
}
450450

451-
TEST_F(MeshToSdfTest, Step3_SignDetermination_WindingNumbers) {
452-
// Test sign determination using winding numbers
451+
TEST_F(MeshToSdfTest, Step3_WindingNumber_OutwardNormals) {
452+
// Test WindingNumber (signed, wn > 0.5) with a cube that has outward-facing normals.
453+
// Flip the test cube's winding order so normals point outward.
454+
std::vector<Eigen::Vector3i> outwardFaces;
455+
outwardFaces.reserve(cubeFaces.size());
456+
for (const auto& f : cubeFaces) {
457+
outwardFaces.emplace_back(f.x(), f.z(), f.y());
458+
}
459+
453460
const BoundingBoxf bounds(
454461
Eigen::Vector3f(-1.0f, -1.0f, -1.0f), Eigen::Vector3f(1.0f, 1.0f, 1.0f));
455462
const Eigen::Vector3<Index> resolution(10, 10, 10);
456463

457464
MeshToSdfConfigf config;
458465
config.narrowBandWidth = 2.0f;
466+
config.signMethod = SignMethod::WindingNumber;
459467

460-
// Generate complete SDF
468+
const auto sdf = meshToSdf<float>(
469+
std::span<const Eigen::Vector3f>(cubeVertices),
470+
std::span<const Eigen::Vector3i>(outwardFaces),
471+
bounds,
472+
resolution,
473+
config);
474+
475+
const float centerValue = sdf.sample(Eigen::Vector3f(0.0f, 0.0f, 0.0f));
476+
EXPECT_LT(centerValue, 0.0f) << "Center should be inside with outward normals";
477+
478+
const float outsideValue = sdf.sample(Eigen::Vector3f(0.9f, 0.9f, 0.9f));
479+
EXPECT_GT(outsideValue, 0.0f) << "Outside point should be positive";
480+
}
481+
482+
TEST_F(MeshToSdfTest, Step3_WindingNumber_RejectsFlippedNormals) {
483+
// WindingNumber (signed) should classify inside as outside when normals point inward,
484+
// since winding number will be -1 (not > 0.5). This is the desired behavior —
485+
// it catches orientation errors rather than silently accepting them.
486+
const BoundingBoxf bounds(
487+
Eigen::Vector3f(-1.0f, -1.0f, -1.0f), Eigen::Vector3f(1.0f, 1.0f, 1.0f));
488+
const Eigen::Vector3<Index> resolution(10, 10, 10);
489+
490+
MeshToSdfConfigf config;
491+
config.narrowBandWidth = 2.0f;
492+
config.signMethod = SignMethod::WindingNumber;
493+
494+
// The test cube has inward-pointing normals
461495
const auto sdf = meshToSdf<float>(
462496
std::span<const Eigen::Vector3f>(cubeVertices),
463497
std::span<const Eigen::Vector3i>(cubeFaces),
464498
bounds,
465499
resolution,
466500
config);
467501

502+
// Center should be classified as OUTSIDE (positive) because normals are flipped
503+
const float centerValue = sdf.sample(Eigen::Vector3f(0.0f, 0.0f, 0.0f));
504+
EXPECT_GT(centerValue, 0.0f) << "Signed winding number should reject inward normals";
505+
}
506+
507+
TEST_F(MeshToSdfTest, Step3_WindingNumberPermissive_AcceptsBothOrientations) {
508+
// WindingNumberPermissive should work regardless of normal orientation.
509+
const BoundingBoxf bounds(
510+
Eigen::Vector3f(-1.0f, -1.0f, -1.0f), Eigen::Vector3f(1.0f, 1.0f, 1.0f));
511+
const Eigen::Vector3<Index> resolution(10, 10, 10);
512+
513+
MeshToSdfConfigf config;
514+
config.narrowBandWidth = 2.0f;
515+
config.signMethod = SignMethod::WindingNumberPermissive;
516+
517+
const float boundaryThreshold = 0.05f;
468518
int correctSigns = 0;
469519
int totalVoxels = 0;
470520

521+
// Test with the inward-normal cube — Permissive should handle it
522+
const auto sdf = meshToSdf<float>(
523+
std::span<const Eigen::Vector3f>(cubeVertices),
524+
std::span<const Eigen::Vector3i>(cubeFaces),
525+
bounds,
526+
resolution,
527+
config);
528+
471529
for (Index i = 0; i < resolution.x(); ++i) {
472530
for (Index j = 0; j < resolution.y(); ++j) {
473531
for (Index k = 0; k < resolution.z(); ++k) {
474-
const float sdfValue = sdf.at(i, j, k);
475-
476532
const Eigen::Vector3f gridPos(
477533
static_cast<float>(i), static_cast<float>(j), static_cast<float>(k));
478534
const Eigen::Vector3f worldPos = sdf.gridToWorld(gridPos);
479535

536+
if (exactDistanceToUnitCube(worldPos) < boundaryThreshold) {
537+
continue;
538+
}
539+
480540
const bool shouldBeInside = isInsideUnitCube(worldPos);
481-
const bool sdfSaysInside = sdfValue < 0.0f;
541+
const bool sdfSaysInside = sdf.at(i, j, k) < 0.0f;
482542

483543
if (shouldBeInside == sdfSaysInside) {
484544
correctSigns++;
@@ -489,10 +549,91 @@ TEST_F(MeshToSdfTest, Step3_SignDetermination_WindingNumbers) {
489549
}
490550

491551
const float accuracy = static_cast<float>(correctSigns) / totalVoxels;
492-
std::cout << "Step 3 (Winding): Sign accuracy: " << accuracy * 100.0f << "% (" << correctSigns
493-
<< "/" << totalVoxels << ")" << std::endl;
552+
EXPECT_GT(accuracy, 0.95f) << "Permissive winding number should handle either orientation";
553+
}
554+
555+
TEST_F(MeshToSdfTest, Step3_WindingNumberPermissive_NonWatertightMesh) {
556+
// Test that permissive winding numbers handle a non-watertight mesh.
557+
std::vector<Eigen::Vector3i> openCubeFaces(cubeFaces.begin(), cubeFaces.end());
558+
openCubeFaces.erase(openCubeFaces.begin() + 2, openCubeFaces.begin() + 4);
559+
560+
const BoundingBoxf bounds(
561+
Eigen::Vector3f(-1.0f, -1.0f, -1.0f), Eigen::Vector3f(1.0f, 1.0f, 1.0f));
562+
const Eigen::Vector3<Index> resolution(10, 10, 10);
563+
564+
MeshToSdfConfigf config;
565+
config.narrowBandWidth = 2.0f;
566+
config.signMethod = SignMethod::WindingNumberPermissive;
567+
568+
const auto sdf = meshToSdf<float>(
569+
std::span<const Eigen::Vector3f>(cubeVertices),
570+
std::span<const Eigen::Vector3i>(openCubeFaces),
571+
bounds,
572+
resolution,
573+
config);
574+
575+
const float centerValue = sdf.sample(Eigen::Vector3f(0.0f, 0.0f, 0.0f));
576+
EXPECT_LT(centerValue, 0.0f) << "Center should be inside even with missing face";
577+
578+
const float outsideValue = sdf.sample(Eigen::Vector3f(0.9f, 0.9f, 0.9f));
579+
EXPECT_GT(outsideValue, 0.0f) << "Clearly outside point should be positive";
580+
}
581+
582+
TEST_F(MeshToSdfTest, Step3_WindingNumberPermissive_MatchesRayCastingOnWatertight) {
583+
// On a watertight mesh, both methods should produce the same sign classification.
584+
const BoundingBoxf bounds(
585+
Eigen::Vector3f(-1.0f, -1.0f, -1.0f), Eigen::Vector3f(1.0f, 1.0f, 1.0f));
586+
const Eigen::Vector3<Index> resolution(10, 10, 10);
587+
588+
MeshToSdfConfigf configRay;
589+
configRay.narrowBandWidth = 2.0f;
590+
configRay.signMethod = SignMethod::RayCasting;
591+
592+
MeshToSdfConfigf configWind;
593+
configWind.narrowBandWidth = 2.0f;
594+
configWind.signMethod = SignMethod::WindingNumberPermissive;
595+
596+
const auto sdfRay = meshToSdf<float>(
597+
std::span<const Eigen::Vector3f>(cubeVertices),
598+
std::span<const Eigen::Vector3i>(cubeFaces),
599+
bounds,
600+
resolution,
601+
configRay);
602+
603+
const auto sdfWind = meshToSdf<float>(
604+
std::span<const Eigen::Vector3f>(cubeVertices),
605+
std::span<const Eigen::Vector3i>(cubeFaces),
606+
bounds,
607+
resolution,
608+
configWind);
609+
610+
const float boundaryThreshold = 0.05f;
611+
int agreements = 0;
612+
int total = 0;
613+
614+
for (Index i = 0; i < resolution.x(); ++i) {
615+
for (Index j = 0; j < resolution.y(); ++j) {
616+
for (Index k = 0; k < resolution.z(); ++k) {
617+
const Eigen::Vector3f gridPos(
618+
static_cast<float>(i), static_cast<float>(j), static_cast<float>(k));
619+
const Eigen::Vector3f worldPos = sdfRay.gridToWorld(gridPos);
620+
621+
if (exactDistanceToUnitCube(worldPos) < boundaryThreshold) {
622+
continue;
623+
}
624+
625+
const bool rayInside = sdfRay.at(i, j, k) < 0.0f;
626+
const bool windInside = sdfWind.at(i, j, k) < 0.0f;
627+
if (rayInside == windInside) {
628+
agreements++;
629+
}
630+
total++;
631+
}
632+
}
633+
}
494634

495-
EXPECT_GT(accuracy, 0.95f) << "Winding number sign determination should be very accurate";
635+
const float agreement = static_cast<float>(agreements) / total;
636+
EXPECT_GT(agreement, 0.99f) << "Both methods should agree on watertight mesh";
496637
}
497638

498639
TEST_F(MeshToSdfTest, IntegratedTest_CubeSDFProperties) {

0 commit comments

Comments
 (0)