@@ -216,6 +216,23 @@ def test_gaussian_finalize_forwards_bvh_constructor_to_warp_bvh(self):
216216
217217
218218class 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