Skip to content

Commit 9d755a3

Browse files
authored
Handle empty mesh decomposition results (#4078)
1 parent dedc81b commit 9d755a3

3 files changed

Lines changed: 147 additions & 0 deletions

File tree

changelog/3507.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Treat empty CoACD and V-HACD decompositions as failures so mesh approximation raises or uses the documented fallbacks.

newton/_src/sim/builder.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7843,6 +7843,8 @@ def get_shape_custom_attributes(shape: int) -> dict[str, Any] | None:
78437843
remeshed_shapes = set()
78447844

78457845
if method == "coacd" or method == "vhacd":
7846+
empty_decomposition_shape = None
7847+
decomposition_failed = False
78467848
try:
78477849
if method == "coacd":
78487850
# convex decomposition using CoACD
@@ -7896,6 +7898,15 @@ def get_shape_custom_attributes(shape: int) -> dict[str, Any] | None:
78967898
decomposition.extend((d["vertices"], d["faces"]) for d in component_decomposition)
78977899
decompositions[hash_m] = decomposition
78987900
if len(decomposition) == 0:
7901+
if raise_on_failure:
7902+
empty_decomposition_shape = shape
7903+
break
7904+
warnings.warn(
7905+
f"Remeshing with method '{method}' failed for shape {shape}: the backend returned no "
7906+
"convex parts. Falling back to convex_hull.",
7907+
stacklevel=2,
7908+
)
7909+
decomposition_failed = True
78997910
continue
79007911
# note we need to copy the mesh to avoid modifying the original mesh
79017912
replacement_mesh = self.shape_source[shape].copy(
@@ -7964,6 +7975,15 @@ def get_shape_custom_attributes(shape: int) -> dict[str, Any] | None:
79647975
method = "convex_hull"
79657976
# kwargs were addressed to the failed decomposition method
79667977
remeshing_kwargs = {}
7978+
if empty_decomposition_shape is not None:
7979+
raise RuntimeError(
7980+
f"Remeshing with method '{method}' failed for shape {empty_decomposition_shape}: "
7981+
"the backend returned no convex parts."
7982+
)
7983+
if decomposition_failed:
7984+
method = "convex_hull"
7985+
# kwargs were addressed to the failed decomposition method
7986+
remeshing_kwargs = {}
79677987

79687988
if method in RemeshingMethod.__args__:
79697989
# remeshing of the individual meshes

newton/tests/test_model.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ def test_gaussian_finalize_forwards_bvh_constructor_to_warp_bvh(self):
216216

217217

218218
class TestModelMesh(unittest.TestCase):
219+
class _FakeDecompositionMesh:
220+
"""Store mesh data passed to a fake decomposition backend."""
221+
222+
def __init__(self, vertices, faces):
223+
self.vertices = vertices
224+
self.faces = faces
225+
226+
@classmethod
227+
def _make_fake_decomposition_backend(cls, method, decompose):
228+
"""Create a stub module and import name for a decomposition backend."""
229+
if method == "coacd":
230+
return "coacd", SimpleNamespace(Mesh=cls._FakeDecompositionMesh, run_coacd=decompose)
231+
return "trimesh", SimpleNamespace(
232+
Trimesh=cls._FakeDecompositionMesh,
233+
decomposition=SimpleNamespace(convex_decomposition=decompose),
234+
)
235+
219236
def test_mesh_rejects_invalid_triangle_indices(self):
220237
"""Reject malformed and out-of-range mesh triangle indices."""
221238
vertices = np.array(
@@ -1011,6 +1028,115 @@ def test_mesh_approximation_coacd_unavailable_falls_back_to_convex_hull(self):
10111028
# the documented threshold migration must keep working without coacd installed
10121029
self.assertEqual(builder.shape_type[shape], newton.GeoType.CONVEX_MESH)
10131030

1031+
def test_mesh_approximation_empty_convex_decomposition_raises(self):
1032+
"""Raise when a convex decomposition backend returns no parts."""
1033+
1034+
mesh = newton.Mesh.create_box(
1035+
1.0,
1036+
duplicate_vertices=False,
1037+
compute_normals=False,
1038+
compute_uvs=False,
1039+
compute_inertia=False,
1040+
)
1041+
for method in ("coacd", "vhacd"):
1042+
with self.subTest(method=method):
1043+
builder = ModelBuilder()
1044+
shape = builder.add_shape_mesh(body=-1, mesh=mesh)
1045+
module_name, fake_backend = self._make_fake_decomposition_backend(method, lambda _mesh, **_kwargs: [])
1046+
with (
1047+
patch_sys_module(module_name, fake_backend),
1048+
self.assertRaisesRegex(RuntimeError, rf"Remeshing with method '{method}' failed"),
1049+
):
1050+
builder.approximate_meshes(method=method, shape_indices=[shape], raise_on_failure=True)
1051+
1052+
self.assertEqual(builder.shape_type[shape], newton.GeoType.MESH)
1053+
1054+
def test_mesh_approximation_empty_convex_decomposition_falls_back_per_shape(self):
1055+
"""Fall back only empty-result shapes while preserving successful decompositions."""
1056+
1057+
empty_mesh = newton.Mesh.create_box(
1058+
1.0,
1059+
duplicate_vertices=False,
1060+
compute_normals=False,
1061+
compute_uvs=False,
1062+
compute_inertia=False,
1063+
)
1064+
successful_mesh = newton.Mesh.create_box(
1065+
2.0,
1066+
duplicate_vertices=False,
1067+
compute_normals=False,
1068+
compute_uvs=False,
1069+
compute_inertia=False,
1070+
)
1071+
for method in ("coacd", "vhacd"):
1072+
with self.subTest(method=method):
1073+
builder = ModelBuilder()
1074+
empty_shape = builder.add_shape_mesh(body=-1, mesh=empty_mesh)
1075+
successful_shape = builder.add_shape_mesh(body=-1, mesh=successful_mesh)
1076+
fallback_meshes = []
1077+
1078+
def fake_decompose(backend_mesh, _method=method, **_kwargs):
1079+
vertices = np.asarray(backend_mesh.vertices)
1080+
if np.isclose(np.ptp(vertices[:, 0]), 2.0):
1081+
return []
1082+
faces = np.asarray(backend_mesh.faces)
1083+
if _method == "coacd":
1084+
return [(vertices.copy(), faces.copy())]
1085+
return [{"vertices": vertices.copy(), "faces": faces.copy()}]
1086+
1087+
def fake_convex_hull(mesh, _fallback_meshes=fallback_meshes, **_kwargs):
1088+
_fallback_meshes.append(mesh)
1089+
return mesh.copy()
1090+
1091+
module_name, fake_backend = self._make_fake_decomposition_backend(method, fake_decompose)
1092+
with (
1093+
patch_sys_module(module_name, fake_backend),
1094+
mock.patch("newton._src.sim.builder.remesh_mesh", side_effect=fake_convex_hull),
1095+
self.assertWarnsRegex(
1096+
UserWarning,
1097+
rf"Remeshing with method '{method}' failed for shape {empty_shape}.*Falling back to convex_hull",
1098+
),
1099+
):
1100+
remeshed = builder.approximate_meshes(
1101+
method=method,
1102+
shape_indices=[empty_shape, successful_shape],
1103+
)
1104+
1105+
self.assertEqual(remeshed, {empty_shape, successful_shape})
1106+
self.assertEqual(builder.shape_type[empty_shape], newton.GeoType.CONVEX_MESH)
1107+
self.assertEqual(builder.shape_type[successful_shape], newton.GeoType.CONVEX_MESH)
1108+
self.assertEqual(fallback_meshes, [empty_mesh])
1109+
1110+
def test_mesh_approximation_empty_convex_decomposition_reaches_bounding_box_fallback(self):
1111+
"""Reach the bounding-box fallback when an empty decomposition is followed by a hull failure."""
1112+
1113+
mesh = newton.Mesh.create_box(
1114+
1.0,
1115+
duplicate_vertices=False,
1116+
compute_normals=False,
1117+
compute_uvs=False,
1118+
compute_inertia=False,
1119+
)
1120+
for method in ("coacd", "vhacd"):
1121+
with self.subTest(method=method):
1122+
builder = ModelBuilder()
1123+
shape = builder.add_shape_mesh(body=-1, mesh=mesh)
1124+
module_name, fake_backend = self._make_fake_decomposition_backend(method, lambda _mesh, **_kwargs: [])
1125+
with (
1126+
patch_sys_module(module_name, fake_backend),
1127+
mock.patch("newton._src.sim.builder.remesh_mesh", side_effect=RuntimeError("qhull failed")),
1128+
warnings.catch_warnings(record=True) as caught,
1129+
):
1130+
warnings.simplefilter("always")
1131+
remeshed = builder.approximate_meshes(method=method, shape_indices=[shape])
1132+
1133+
self.assertEqual(len(caught), 2)
1134+
self.assertRegex(str(caught[0].message), "the backend returned no convex parts")
1135+
self.assertRegex(str(caught[1].message), "Falling back to bounding_box")
1136+
self.assertEqual(remeshed, {shape})
1137+
self.assertEqual(builder.shape_type[shape], newton.GeoType.BOX)
1138+
self.assertIsNone(builder.shape_source[shape])
1139+
10141140
def test_mesh_approximation_ignores_non_mesh_shapes(self):
10151141
builder = ModelBuilder()
10161142
box_prim = builder.add_shape_box(body=-1)

0 commit comments

Comments
 (0)