From 426205bd9ffdc359b3d78fdeb945af7f72aeea68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:42:17 +0000 Subject: [PATCH 1/6] Fix inverted tetrahedra in FlexiCubes volume mesh output FlexiCubes' _tetrahedralize builds tets from two sub-procedures (surface pyramids and interior edges) whose vertex orderings do not share a consistent winding. As a result a large fraction of elements came out inverted (negative signed volume) - nearly all surface tets and ~40% of interior tets - which breaks FEA solvers that require a positive signed volume / Jacobian on every element. Add _orient_tets to normalize every tet to positive orientation by swapping two vertices where the signed volume is negative. This only reorders integer indices, so element geometry, |volume|, and gradients to the vertices are all preserved (the extractor stays differentiable). Add regression tests asserting no inverted tets are produced. --- DeepSDFStruct/flexicubes/flexicubes.py | 30 ++++++++++++ tests/test_tet_orientation.py | 67 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/test_tet_orientation.py diff --git a/DeepSDFStruct/flexicubes/flexicubes.py b/DeepSDFStruct/flexicubes/flexicubes.py index bdd39ec9..23e38591 100644 --- a/DeepSDFStruct/flexicubes/flexicubes.py +++ b/DeepSDFStruct/flexicubes/flexicubes.py @@ -1002,4 +1002,34 @@ def _tetrahedralize( tets = torch.cat([tets_surface, tets_inside]) vertices = torch.cat([vertices, inside_verts, inside_cubes_center]) + # The surface (pyramid) and interior sub-procedures above emit tets + # with inconsistent winding, so a large fraction come out inverted + # (negative signed volume). FEA solvers require a positive signed + # volume / Jacobian, so normalize every tet to positive orientation. + tets = self._orient_tets(vertices, tets) return vertices, tets + + @staticmethod + def _orient_tets(vertices, tets): + """Return ``tets`` with a consistent, positive signed volume. + + For a tetrahedron with vertices ``(v0, v1, v2, v3)`` the signed volume + is proportional to ``det([v1 - v0, v2 - v0, v3 - v0])``. Elements with + a negative determinant are inverted; swapping the last two vertices + flips the orientation so the signed volume becomes positive without + changing the element's geometry. The vertex indices are merely + reordered, so gradients to ``vertices`` are unaffected. + """ + if tets.shape[0] == 0: + return tets + v0 = vertices[tets[:, 0]] + v1 = vertices[tets[:, 1]] + v2 = vertices[tets[:, 2]] + v3 = vertices[tets[:, 3]] + signed_vol = torch.einsum( + "ij,ij->i", v1 - v0, torch.linalg.cross(v2 - v0, v3 - v0, dim=1) + ) + inverted = signed_vol < 0 + tets = tets.clone() + tets[inverted] = tets[inverted][:, [0, 1, 3, 2]] + return tets diff --git a/tests/test_tet_orientation.py b/tests/test_tet_orientation.py new file mode 100644 index 00000000..bf46b574 --- /dev/null +++ b/tests/test_tet_orientation.py @@ -0,0 +1,67 @@ +"""Regression tests for tetrahedron orientation in FlexiCubes output. + +FlexiCubes' ``_tetrahedralize`` builds tets from two sub-procedures (surface +pyramids and interior edges) whose vertex orderings do not share a consistent +winding, so a large fraction of the elements used to come out inverted +(negative signed volume). That breaks FEA solvers, which require a positive +signed volume / Jacobian on every element. These tests pin the fix: every +non-degenerate tet returned by FlexiCubes must have a positive signed volume. +""" + +import torch + +from DeepSDFStruct.flexicubes.flexicubes import FlexiCubes +from DeepSDFStruct.optimization import tet_signed_vol + + +def _sphere_volume_mesh(res=16, radius=0.7, center=(0.0, 0.0, 0.0)): + fc = FlexiCubes(device="cpu") + x_nx3, cube_fx8 = fc.construct_voxel_grid(res) + # Spread the unit grid over [-1, 1] so the sphere sits inside the domain. + x_nx3 = x_nx3 * 2.0 + c = torch.tensor(center, dtype=x_nx3.dtype) + s_n = torch.linalg.norm(x_nx3 - c, dim=1) - radius + verts, tets, _ = fc(x_nx3, s_n, cube_fx8, res, output_tetmesh=True) + return verts, tets + + +def test_orient_tets_flips_inverted_element(): + """A single inverted tet is flipped to positive signed volume.""" + verts = torch.tensor( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + # [0, 1, 2, 3] is positively oriented; [0, 1, 3, 2] is its inversion. + tets = torch.tensor([[0, 1, 2, 3], [0, 1, 3, 2]]) + + assert (tet_signed_vol(verts, tets) < 0).any(), "fixture should contain an inversion" + + oriented = FlexiCubes._orient_tets(verts, tets) + vols = tet_signed_vol(verts, oriented) + assert (vols > 0).all(), f"expected all positive volumes, got {vols.tolist()}" + # Orientation is fixed by reordering indices only: |volume| is preserved. + assert torch.allclose(vols.abs(), tet_signed_vol(verts, tets).abs()) + + +def test_orient_tets_handles_empty(): + verts = torch.zeros((0, 3)) + tets = torch.zeros((0, 4), dtype=torch.long) + assert FlexiCubes._orient_tets(verts, tets).shape == (0, 4) + + +def test_flexicubes_volume_mesh_has_no_inverted_tets(): + """End-to-end: extracted volume mesh contains no negatively-oriented tets.""" + for center in [(0.0, 0.0, 0.0), (0.13, 0.07, 0.21)]: + verts, tets = _sphere_volume_mesh(center=center) + assert tets.shape[0] > 0 + vols = tet_signed_vol(verts, tets) + # Degenerate (exactly coplanar) tets are a separate FlexiCubes issue; + # the orientation guarantee is that none are negatively oriented. + n_negative = int((vols < -1e-9).sum()) + assert n_negative == 0, f"{n_negative} inverted tets for center {center}" + + +if __name__ == "__main__": + test_orient_tets_flips_inverted_element() + test_orient_tets_handles_empty() + test_flexicubes_volume_mesh_has_no_inverted_tets() + print("ok") From 9160c55c449affe140c318f375cd3d5bc2d94f38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:05:37 +0000 Subject: [PATCH 2/6] Remove degenerate zero-volume tets from FlexiCubes output The interior tetrahedralization sub-procedure can emit elements whose four vertices are exactly coplanar (the two dual-mesh vertices land symmetric about the grid edge), yielding zero-volume tets that fail the positive-Jacobian requirement of FEA solvers just like inverted ones. Drop these elements in _orient_tets and log 'removed x elements with 0 volume'. Coplanarity is detected with a tolerance relative to the Hadamard bound of the determinant rather than an exact zero compare, since the rounding of an exactly-degenerate triple product depends on association order. The measured relative volumes are cleanly bimodal (degenerates at <=1e-7, real elements at >=1e-5), so the 1e-5 cutoff removes only degenerate elements. --- DeepSDFStruct/flexicubes/flexicubes.py | 27 +++++++++++++---- tests/test_tet_orientation.py | 41 ++++++++++++++++++++------ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/DeepSDFStruct/flexicubes/flexicubes.py b/DeepSDFStruct/flexicubes/flexicubes.py index 23e38591..7ddb9f31 100644 --- a/DeepSDFStruct/flexicubes/flexicubes.py +++ b/DeepSDFStruct/flexicubes/flexicubes.py @@ -28,10 +28,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import logging + import torch import warnings from DeepSDFStruct.flexicubes.tables import * +logger = logging.getLogger(__name__) + __all__ = ["FlexiCubes"] @@ -1005,13 +1009,14 @@ def _tetrahedralize( # The surface (pyramid) and interior sub-procedures above emit tets # with inconsistent winding, so a large fraction come out inverted # (negative signed volume). FEA solvers require a positive signed - # volume / Jacobian, so normalize every tet to positive orientation. + # volume / Jacobian, so normalize every tet to positive orientation + # and drop degenerate (zero-volume) elements. tets = self._orient_tets(vertices, tets) return vertices, tets @staticmethod def _orient_tets(vertices, tets): - """Return ``tets`` with a consistent, positive signed volume. + """Return ``tets`` with a consistent, strictly positive signed volume. For a tetrahedron with vertices ``(v0, v1, v2, v3)`` the signed volume is proportional to ``det([v1 - v0, v2 - v0, v3 - v0])``. Elements with @@ -1019,6 +1024,10 @@ def _orient_tets(vertices, tets): flips the orientation so the signed volume becomes positive without changing the element's geometry. The vertex indices are merely reordered, so gradients to ``vertices`` are unaffected. + + Degenerate elements (four coplanar vertices, zero volume) cannot be + repaired by reordering and are removed instead. Vertices are left + untouched, so indices of the remaining tets stay valid. """ if tets.shape[0] == 0: return tets @@ -1026,10 +1035,18 @@ def _orient_tets(vertices, tets): v1 = vertices[tets[:, 1]] v2 = vertices[tets[:, 2]] v3 = vertices[tets[:, 3]] - signed_vol = torch.einsum( - "ij,ij->i", v1 - v0, torch.linalg.cross(v2 - v0, v3 - v0, dim=1) - ) + e1, e2, e3 = v1 - v0, v2 - v0, v3 - v0 + signed_vol = torch.einsum("ij,ij->i", e1, torch.linalg.cross(e2, e3, dim=1)) inverted = signed_vol < 0 tets = tets.clone() tets[inverted] = tets[inverted][:, [0, 1, 3, 2]] + # Coplanar tets only evaluate to exactly zero up to floating-point + # rounding (which depends on the association order of the triple + # product), so compare against a tolerance relative to the Hadamard + # bound |e1||e2||e3| of the determinant instead of zero itself. + scale = e1.norm(dim=1) * e2.norm(dim=1) * e3.norm(dim=1) + degenerate = signed_vol.abs() <= 1e-5 * scale + if degenerate.any(): + logger.info(f"removed {int(degenerate.sum())} elements with 0 volume") + tets = tets[~degenerate] return tets diff --git a/tests/test_tet_orientation.py b/tests/test_tet_orientation.py index bf46b574..25536f9d 100644 --- a/tests/test_tet_orientation.py +++ b/tests/test_tet_orientation.py @@ -4,10 +4,14 @@ pyramids and interior edges) whose vertex orderings do not share a consistent winding, so a large fraction of the elements used to come out inverted (negative signed volume). That breaks FEA solvers, which require a positive -signed volume / Jacobian on every element. These tests pin the fix: every -non-degenerate tet returned by FlexiCubes must have a positive signed volume. +signed volume / Jacobian on every element. The interior sub-procedure can +additionally emit degenerate (exactly coplanar, zero-volume) tets, which are +removed during extraction. These tests pin both fixes: every tet returned by +FlexiCubes must have a strictly positive signed volume. """ +import logging + import torch from DeepSDFStruct.flexicubes.flexicubes import FlexiCubes @@ -48,20 +52,39 @@ def test_orient_tets_handles_empty(): assert FlexiCubes._orient_tets(verts, tets).shape == (0, 4) -def test_flexicubes_volume_mesh_has_no_inverted_tets(): - """End-to-end: extracted volume mesh contains no negatively-oriented tets.""" +def test_orient_tets_removes_degenerate_elements(caplog): + """Zero-volume (coplanar) tets are dropped and the removal is logged.""" + verts = torch.tensor( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 0.0], # coplanar with the first three (z = 0 plane) + ] + ) + tets = torch.tensor([[0, 1, 2, 3], [0, 1, 2, 4]]) + + with caplog.at_level(logging.INFO, logger="DeepSDFStruct.flexicubes.flexicubes"): + oriented = FlexiCubes._orient_tets(verts, tets) + + assert oriented.shape == (1, 4) + assert (tet_signed_vol(verts, oriented) > 0).all() + assert "removed 1 elements with 0 volume" in caplog.text + + +def test_flexicubes_volume_mesh_has_only_positive_tets(): + """End-to-end: extracted volume mesh has strictly positive signed volumes.""" for center in [(0.0, 0.0, 0.0), (0.13, 0.07, 0.21)]: verts, tets = _sphere_volume_mesh(center=center) assert tets.shape[0] > 0 vols = tet_signed_vol(verts, tets) - # Degenerate (exactly coplanar) tets are a separate FlexiCubes issue; - # the orientation guarantee is that none are negatively oriented. - n_negative = int((vols < -1e-9).sum()) - assert n_negative == 0, f"{n_negative} inverted tets for center {center}" + n_bad = int((vols <= 0).sum()) + assert n_bad == 0, f"{n_bad} non-positive tets for center {center}" if __name__ == "__main__": test_orient_tets_flips_inverted_element() test_orient_tets_handles_empty() - test_flexicubes_volume_mesh_has_no_inverted_tets() + test_flexicubes_volume_mesh_has_only_positive_tets() print("ok") From efe750dc4c01282faf43bb72cf6d7d158bf539c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:23:35 +0000 Subject: [PATCH 3/6] Drop caplog assertion from degenerate-tet test --- tests/test_tet_orientation.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_tet_orientation.py b/tests/test_tet_orientation.py index 25536f9d..b12c3ad9 100644 --- a/tests/test_tet_orientation.py +++ b/tests/test_tet_orientation.py @@ -10,8 +10,6 @@ FlexiCubes must have a strictly positive signed volume. """ -import logging - import torch from DeepSDFStruct.flexicubes.flexicubes import FlexiCubes @@ -52,8 +50,8 @@ def test_orient_tets_handles_empty(): assert FlexiCubes._orient_tets(verts, tets).shape == (0, 4) -def test_orient_tets_removes_degenerate_elements(caplog): - """Zero-volume (coplanar) tets are dropped and the removal is logged.""" +def test_orient_tets_removes_degenerate_elements(): + """Zero-volume (coplanar) tets are dropped.""" verts = torch.tensor( [ [0.0, 0.0, 0.0], @@ -65,12 +63,10 @@ def test_orient_tets_removes_degenerate_elements(caplog): ) tets = torch.tensor([[0, 1, 2, 3], [0, 1, 2, 4]]) - with caplog.at_level(logging.INFO, logger="DeepSDFStruct.flexicubes.flexicubes"): - oriented = FlexiCubes._orient_tets(verts, tets) + oriented = FlexiCubes._orient_tets(verts, tets) assert oriented.shape == (1, 4) assert (tet_signed_vol(verts, oriented) > 0).all() - assert "removed 1 elements with 0 volume" in caplog.text def test_flexicubes_volume_mesh_has_only_positive_tets(): From 53a04b0943ced9ae6294979b03833cafa3a674ff Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:57:10 +0000 Subject: [PATCH 4/6] style: format code with Black This commit fixes the style issues introduced in efe750d according to the output from Black. Details: https://github.com/mkofler96/DeepSDFStruct/pull/63 --- tests/test_tet_orientation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_tet_orientation.py b/tests/test_tet_orientation.py index b12c3ad9..564341c1 100644 --- a/tests/test_tet_orientation.py +++ b/tests/test_tet_orientation.py @@ -35,7 +35,9 @@ def test_orient_tets_flips_inverted_element(): # [0, 1, 2, 3] is positively oriented; [0, 1, 3, 2] is its inversion. tets = torch.tensor([[0, 1, 2, 3], [0, 1, 3, 2]]) - assert (tet_signed_vol(verts, tets) < 0).any(), "fixture should contain an inversion" + assert ( + tet_signed_vol(verts, tets) < 0 + ).any(), "fixture should contain an inversion" oriented = FlexiCubes._orient_tets(verts, tets) vols = tet_signed_vol(verts, oriented) From ae287457d065737b541278bfb5cca682bbcc143e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 12:23:26 +0000 Subject: [PATCH 5/6] Address review: explicit grid bounds in test, no_grad in _orient_tets construct_voxel_grid defaults to bounds [-0.05, 1.05] in this repo, so scaling by 2 did not produce the [-1, 1] domain the test comment claimed; pass explicit bounds instead. Also wrap the orientation classification in torch.no_grad() since it only derives integer index masks. --- DeepSDFStruct/flexicubes/flexicubes.py | 31 +++++++++++++++----------- tests/test_tet_orientation.py | 6 ++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/DeepSDFStruct/flexicubes/flexicubes.py b/DeepSDFStruct/flexicubes/flexicubes.py index 7ddb9f31..364b36b5 100644 --- a/DeepSDFStruct/flexicubes/flexicubes.py +++ b/DeepSDFStruct/flexicubes/flexicubes.py @@ -1031,21 +1031,26 @@ def _orient_tets(vertices, tets): """ if tets.shape[0] == 0: return tets - v0 = vertices[tets[:, 0]] - v1 = vertices[tets[:, 1]] - v2 = vertices[tets[:, 2]] - v3 = vertices[tets[:, 3]] - e1, e2, e3 = v1 - v0, v2 - v0, v3 - v0 - signed_vol = torch.einsum("ij,ij->i", e1, torch.linalg.cross(e2, e3, dim=1)) - inverted = signed_vol < 0 + # Only integer index masks are derived here, so skip autograd + # recording even when ``vertices`` requires gradients. + with torch.no_grad(): + v0 = vertices[tets[:, 0]] + v1 = vertices[tets[:, 1]] + v2 = vertices[tets[:, 2]] + v3 = vertices[tets[:, 3]] + e1, e2, e3 = v1 - v0, v2 - v0, v3 - v0 + signed_vol = torch.einsum( + "ij,ij->i", e1, torch.linalg.cross(e2, e3, dim=1) + ) + inverted = signed_vol < 0 + # Coplanar tets only evaluate to exactly zero up to floating-point + # rounding (which depends on the association order of the triple + # product), so compare against a tolerance relative to the Hadamard + # bound |e1||e2||e3| of the determinant instead of zero itself. + scale = e1.norm(dim=1) * e2.norm(dim=1) * e3.norm(dim=1) + degenerate = signed_vol.abs() <= 1e-5 * scale tets = tets.clone() tets[inverted] = tets[inverted][:, [0, 1, 3, 2]] - # Coplanar tets only evaluate to exactly zero up to floating-point - # rounding (which depends on the association order of the triple - # product), so compare against a tolerance relative to the Hadamard - # bound |e1||e2||e3| of the determinant instead of zero itself. - scale = e1.norm(dim=1) * e2.norm(dim=1) * e3.norm(dim=1) - degenerate = signed_vol.abs() <= 1e-5 * scale if degenerate.any(): logger.info(f"removed {int(degenerate.sum())} elements with 0 volume") tets = tets[~degenerate] diff --git a/tests/test_tet_orientation.py b/tests/test_tet_orientation.py index 564341c1..58658420 100644 --- a/tests/test_tet_orientation.py +++ b/tests/test_tet_orientation.py @@ -18,9 +18,9 @@ def _sphere_volume_mesh(res=16, radius=0.7, center=(0.0, 0.0, 0.0)): fc = FlexiCubes(device="cpu") - x_nx3, cube_fx8 = fc.construct_voxel_grid(res) - # Spread the unit grid over [-1, 1] so the sphere sits inside the domain. - x_nx3 = x_nx3 * 2.0 + x_nx3, cube_fx8 = fc.construct_voxel_grid( + res, bounds=[[-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]] + ) c = torch.tensor(center, dtype=x_nx3.dtype) s_n = torch.linalg.norm(x_nx3 - c, dim=1) - radius verts, tets, _ = fc(x_nx3, s_n, cube_fx8, res, output_tetmesh=True) From 56a5ae0120ffd71897b30bb01f997088c02ac8ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:23:51 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- DeepSDFStruct/flexicubes/flexicubes.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/DeepSDFStruct/flexicubes/flexicubes.py b/DeepSDFStruct/flexicubes/flexicubes.py index 364b36b5..83e1debf 100644 --- a/DeepSDFStruct/flexicubes/flexicubes.py +++ b/DeepSDFStruct/flexicubes/flexicubes.py @@ -1039,9 +1039,7 @@ def _orient_tets(vertices, tets): v2 = vertices[tets[:, 2]] v3 = vertices[tets[:, 3]] e1, e2, e3 = v1 - v0, v2 - v0, v3 - v0 - signed_vol = torch.einsum( - "ij,ij->i", e1, torch.linalg.cross(e2, e3, dim=1) - ) + signed_vol = torch.einsum("ij,ij->i", e1, torch.linalg.cross(e2, e3, dim=1)) inverted = signed_vol < 0 # Coplanar tets only evaluate to exactly zero up to floating-point # rounding (which depends on the association order of the triple