Coverage for trimesh/sample.py: 99%

73 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-31 18:21 +0000

1""" 

2sample.py 

3------------ 

4 

5Randomly sample surface and volume of meshes. 

6""" 

7 

8from logging import getLogger 

9 

10import numpy as np 

11 

12from . import transformations 

13from .typed import ArrayLike, Integer, NDArray, Number, Seed, float64 

14from .util import random_generator, spherical_to_vector 

15from .visual import uv_to_interpolated_color 

16 

17log = getLogger(__name__) 

18 

19 

20def sample_surface( 

21 mesh, 

22 count: Integer, 

23 face_weight: ArrayLike | None = None, 

24 sample_color=False, 

25 return_barycentric: bool = False, 

26 seed: Seed = None, 

27): 

28 """ 

29 Sample the surface of a mesh, returning the specified 

30 number of points 

31 

32 For individual triangle sampling uses this method: 

33 http://mathworld.wolfram.com/TrianglePointPicking.html 

34 

35 Parameters 

36 ----------- 

37 mesh : trimesh.Trimesh 

38 Geometry to sample the surface of 

39 count : int 

40 Number of points to return 

41 face_weight : None or len(mesh.faces) float 

42 Weight faces by a factor other than face area. 

43 If None will be the same as face_weight=mesh.area 

44 sample_color : bool 

45 Option to calculate the color of the sampled points. 

46 Default is False. 

47 return_barycentric : bool 

48 If True will also return the barycentric coordinates 

49 of each sampled point. 

50 seed : None or int 

51 Seed for deterministic results, otherwise OS entropy. 

52 

53 Returns 

54 --------- 

55 samples : (count, 3) float 

56 Points in space on the surface of mesh 

57 face_index : (count,) int 

58 Indices of faces for each sampled point 

59 colors : (count, 4) float 

60 Colors of each sampled point 

61 Returns only when the sample_color is True 

62 barycentric : (count, 3) float 

63 Coordinates on `mesh.faces[face_index]` 

64 Returned only when return_barycentric is True 

65 """ 

66 

67 if face_weight is None: 

68 # len(mesh.faces) float, array of the areas 

69 # of each face of the mesh 

70 face_weight = mesh.area_faces 

71 

72 # cumulative sum of weights (len(mesh.faces)) 

73 weight_cum = np.cumsum(face_weight) 

74 

75 random = random_generator(seed).random 

76 

77 # last value of cumulative sum is total summed weight/area 

78 face_pick = random(count) * weight_cum[-1] 

79 # get the index of the selected faces 

80 face_index = np.searchsorted(weight_cum, face_pick) 

81 

82 # pull triangles into the form of an origin + 2 vectors 

83 tri_origins = mesh.vertices[mesh.faces[:, 0]] 

84 tri_vectors = mesh.vertices[mesh.faces[:, 1:]].copy() 

85 tri_vectors -= np.tile(tri_origins, (1, 2)).reshape((-1, 2, 3)) 

86 

87 # pull the vectors for the faces we are going to sample from 

88 tri_origins = tri_origins[face_index] 

89 tri_vectors = tri_vectors[face_index] 

90 

91 if sample_color and hasattr(mesh.visual, "uv"): 

92 uv_origins = mesh.visual.uv[mesh.faces[:, 0]] 

93 uv_vectors = mesh.visual.uv[mesh.faces[:, 1:]].copy() 

94 uv_origins_tile = np.tile(uv_origins, (1, 2)).reshape((-1, 2, 2)) 

95 uv_vectors -= uv_origins_tile 

96 uv_origins = uv_origins[face_index] 

97 uv_vectors = uv_vectors[face_index] 

98 

99 # randomly generate two 0-1 scalar components to multiply edge vectors b 

100 random_lengths = random((len(tri_vectors), 2, 1)) 

101 

102 # points will be distributed on a quadrilateral if we use 2 0-1 samples 

103 # if the two scalar components sum less than 1.0 the point will be 

104 # inside the triangle, so we find vectors longer than 1.0 and 

105 # transform them to be inside the triangle 

106 random_test = random_lengths.sum(axis=1).reshape(-1) > 1.0 

107 random_lengths[random_test] -= 1.0 

108 random_lengths = np.abs(random_lengths) 

109 

110 if return_barycentric: 

111 # the two random lengths are the barycentric coordinates of the 

112 # second and third vertex - the first vertex is what remains 

113 barycentric = np.hstack((1 - random_lengths.sum(axis=1), random_lengths[:, :, 0])) 

114 

115 # multiply triangle edge vectors by the random lengths and sum 

116 sample_vector = (tri_vectors * random_lengths).sum(axis=1) 

117 

118 # finally, offset by the origin to generate 

119 # (n,3) points in space on the triangle 

120 samples = sample_vector + tri_origins 

121 

122 if sample_color: 

123 if hasattr(mesh.visual, "uv"): 

124 sample_uv_vector = (uv_vectors * random_lengths).sum(axis=1) 

125 uv_samples = sample_uv_vector + uv_origins 

126 texture = mesh.visual.material.image 

127 colors = uv_to_interpolated_color(uv_samples, texture) 

128 else: 

129 colors = mesh.visual.face_colors[face_index] 

130 

131 if return_barycentric: 

132 return samples, face_index, colors, barycentric 

133 

134 return samples, face_index, colors 

135 

136 if return_barycentric: 

137 return samples, face_index, barycentric 

138 

139 return samples, face_index 

140 

141 

142def volume_mesh(mesh, count: Integer, seed: Seed = None) -> NDArray[float64]: 

143 """ 

144 Use rejection sampling to produce points randomly 

145 distributed in the volume of a mesh. 

146 

147 

148 Parameters 

149 ----------- 

150 mesh : trimesh.Trimesh 

151 Geometry to sample 

152 count : int 

153 Number of points to return 

154 seed : None or int 

155 Seed for deterministic results, otherwise OS entropy. 

156 

157 Returns 

158 --------- 

159 samples : (n, 3) float 

160 Points in the volume of the mesh where n <= count 

161 """ 

162 random = random_generator(seed).random 

163 points = (random((count, 3)) * mesh.extents) + mesh.bounds[0] 

164 contained = mesh.contains(points) 

165 samples = points[contained][:count] 

166 return samples 

167 

168 

169def volume_rectangular( 

170 extents, 

171 count: Integer, 

172 transform: ArrayLike | None = None, 

173 seed: Seed = None, 

174) -> NDArray[float64]: 

175 """ 

176 Return random samples inside a rectangular volume, 

177 useful for sampling inside oriented bounding boxes. 

178 

179 Parameters 

180 ----------- 

181 extents : (3,) float 

182 Side lengths of rectangular solid 

183 count : int 

184 Number of points to return 

185 transform : (4, 4) float 

186 Homogeneous transformation matrix 

187 seed : None or int 

188 Seed for deterministic results, otherwise OS entropy. 

189 

190 Returns 

191 --------- 

192 samples : (count, 3) float 

193 Points in requested volume 

194 """ 

195 samples = (random_generator(seed).random((count, 3)) - 0.5) * extents 

196 if transform is not None: 

197 samples = transformations.transform_points(samples, transform) 

198 return samples 

199 

200 

201def sample_surface_even( 

202 mesh, count: Integer, radius: Number | None = None, seed: Seed = None 

203): 

204 """ 

205 Sample the surface of a mesh, returning samples which are 

206 VERY approximately evenly spaced. This is accomplished by 

207 sampling and then rejecting pairs that are too close together. 

208 

209 Note that since it is using rejection sampling it may return 

210 fewer points than requested (i.e. n < count). If this is the 

211 case a log.warning will be emitted. 

212 

213 Parameters 

214 ----------- 

215 mesh : trimesh.Trimesh 

216 Geometry to sample the surface of 

217 count : int 

218 Number of points to return 

219 radius : None or float 

220 Removes samples below this radius 

221 seed : None or int 

222 Provides deterministic values 

223 

224 Returns 

225 --------- 

226 samples : (n, 3) float 

227 Points in space on the surface of mesh 

228 face_index : (n,) int 

229 Indices of faces for each sampled point 

230 """ 

231 from .points import remove_close 

232 

233 # guess radius from area 

234 if radius is None: 

235 radius = np.sqrt(mesh.area / (3 * count)) 

236 

237 # get points on the surface 

238 points, index = sample_surface(mesh, count * 3, seed=seed) 

239 

240 # remove the points closer than radius 

241 points, mask = remove_close(points, radius) 

242 

243 # we got all the samples we expect 

244 if len(points) >= count: 

245 return points[:count], index[mask][:count] 

246 

247 # warn if we didn't get all the samples we expect 

248 log.warning(f"only got {len(points)}/{count} samples!") 

249 

250 return points, index[mask] 

251 

252 

253def sample_surface_sphere(count: int, seed: Seed = None) -> NDArray[float64]: 

254 """ 

255 Correctly pick random points on the surface of a unit sphere 

256 

257 Uses this method: 

258 http://mathworld.wolfram.com/SpherePointPicking.html 

259 

260 Parameters 

261 ----------- 

262 count : int 

263 Number of points to return 

264 seed : None or int 

265 Seed for deterministic results, otherwise OS entropy. 

266 

267 Returns 

268 ---------- 

269 points : (count, 3) float 

270 Random points on the surface of a unit sphere 

271 """ 

272 # get random values 0.0-1.0 

273 u, v = random_generator(seed).random((2, count)) 

274 # convert to two angles 

275 theta = np.pi * 2 * u 

276 phi = np.arccos((2 * v) - 1) 

277 # convert spherical coordinates to cartesian 

278 return spherical_to_vector(np.column_stack((theta, phi)))