Coverage for trimesh/bounds.py: 77%

199 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import numpy as np 

2 

3from . import convex, geometry, grouping, nsphere, transformations, util 

4from .constants import log, now 

5from .typed import ArrayLike, NDArray 

6 

7try: 

8 # scipy is a soft dependency 

9 from scipy import optimize 

10 from scipy.spatial import ConvexHull 

11except BaseException as E: 

12 # raise the exception when someone tries to use it 

13 from . import exceptions 

14 

15 ConvexHull = exceptions.ExceptionWrapper(E) 

16 optimize = exceptions.ExceptionWrapper(E) 

17 

18try: 

19 from scipy.spatial import QhullError 

20except BaseException: 

21 QhullError = BaseException 

22 

23# a 90 degree rotation 

24_flip = transformations.planar_matrix(theta=np.pi / 2) 

25_flip.flags.writeable = False 

26 

27 

28def oriented_bounds_2D(points, qhull_options="QbB"): 

29 """ 

30 Find an oriented bounding box for an array of 2D points. 

31 

32 Details on qhull options: 

33 http://www.qhull.org/html/qh-quick.htm#options 

34 

35 Parameters 

36 ---------- 

37 points : (n,2) float 

38 Points in 2D. 

39 

40 Returns 

41 ---------- 

42 transform : (3,3) float 

43 Homogeneous 2D transformation matrix to move the 

44 input points so that the axis aligned bounding box 

45 is CENTERED AT THE ORIGIN. 

46 rectangle : (2,) float 

47 Size of extents once input points are transformed 

48 by transform 

49 """ 

50 # create a convex hull object of our points 

51 # 'QbB' is a qhull option which has it scale the input to unit 

52 # box to avoid precision issues with very large/small meshes 

53 convex = ConvexHull(points, qhull_options=qhull_options) 

54 

55 # (n,2,3) line segments 

56 hull_edges = convex.points[convex.simplices] 

57 # (n,2) points on the convex hull 

58 hull_points = convex.points[convex.vertices] 

59 

60 # unit vector direction of the edges of the hull polygon 

61 # filter out zero- magnitude edges via check_valid 

62 edge_vectors = hull_edges[:, 1] - hull_edges[:, 0] 

63 edge_norm = np.sqrt(np.dot(edge_vectors**2, [1, 1])) 

64 edge_nonzero = edge_norm > 1e-10 

65 edge_vectors = edge_vectors[edge_nonzero] / edge_norm[edge_nonzero].reshape((-1, 1)) 

66 

67 # create a set of perpendicular vectors 

68 perp_vectors = np.fliplr(edge_vectors) * [-1.0, 1.0] 

69 

70 # find the projection of every hull point on every edge vector 

71 # this does create a potentially gigantic n^2 array in memory, 

72 # and there is the 'rotating calipers' algorithm which avoids this 

73 # however, we have reduced n with a convex hull and numpy dot products 

74 # are extremely fast so in practice this usually ends up being fine 

75 x = np.dot(edge_vectors, hull_points.T) 

76 y = np.dot(perp_vectors, hull_points.T) 

77 

78 # reduce the projections to maximum and minimum per edge vector 

79 bounds = np.column_stack((x.min(axis=1), y.min(axis=1), x.max(axis=1), y.max(axis=1))) 

80 

81 # calculate the extents and area for each edge vector pair 

82 extents = np.diff(bounds.reshape((-1, 2, 2)), axis=1).reshape((-1, 2)) 

83 area = np.prod(extents, axis=1) 

84 area_min = area.argmin() 

85 

86 # (2,) float of smallest rectangle size 

87 rectangle = extents[area_min] 

88 

89 # find the (3,3) homogeneous transformation which moves the input 

90 # points to have a bounding box centered at the origin 

91 offset = -bounds[area_min][:2] - (rectangle * 0.5) 

92 theta = np.arctan2(*edge_vectors[area_min][::-1]) 

93 transform = transformations.planar_matrix(offset, theta) 

94 

95 # we would like to consistently return an OBB with 

96 # the largest dimension along the X axis rather than 

97 # the long axis being arbitrarily X or Y. 

98 if rectangle[1] > rectangle[0]: 

99 # apply the rotation 

100 transform = np.dot(_flip, transform) 

101 # switch X and Y in the OBB extents 

102 rectangle = rectangle[::-1] 

103 

104 return transform, rectangle 

105 

106 

107def oriented_bounds(obj, angle_digits=1, ordered=True, normal=None, coplanar_tol=1e-12): 

108 """ 

109 Find the oriented bounding box for a Trimesh 

110 

111 Parameters 

112 ---------- 

113 obj : trimesh.Trimesh, (n, 2) float, or (n, 3) float 

114 Mesh object or points in 2D or 3D space 

115 angle_digits : int 

116 How much angular precision do we want on our result. 

117 Even with less precision the returned extents will cover 

118 the mesh albeit with larger than minimal volume, and may 

119 experience substantial speedups. 

120 ordered : bool 

121 Return a consistent order for bounds 

122 normal : None or (3,) float 

123 Override search for normal on 3D meshes. 

124 coplanar_tol : float 

125 If a convex hull fails and we are checking to see if the 

126 points are coplanar this is the maximum deviation from 

127 a plane where the points will be considered coplanar. 

128 

129 Returns 

130 ---------- 

131 to_origin : (4,4) float 

132 Transformation matrix which will move the center of the 

133 bounding box of the input mesh to the origin. 

134 extents: (3,) float 

135 The extents of the mesh once transformed with to_origin 

136 """ 

137 

138 def oriented_bounds_coplanar(points): 

139 """ 

140 Find an oriented bounding box for an array of coplanar 3D points. 

141 

142 Parameters 

143 ---------- 

144 points : (n, 3) float 

145 Points in 3D that occupy a 2D subspace. 

146 

147 Returns 

148 ---------- 

149 to_origin : (4, 4) float 

150 Transformation matrix which will move the center of the 

151 bounding box of the input mesh to the origin. 

152 extents : (3,) float 

153 The extents of the mesh once transformed with to_origin 

154 """ 

155 # Shift points about the origin and rotate into the xy plane 

156 points_mean = np.mean(points, axis=0) 

157 points_demeaned = points - points_mean 

158 _, _, vh = np.linalg.svd(points_demeaned, full_matrices=False) 

159 points_2d = np.matmul(points_demeaned, vh.T) 

160 if np.any(np.abs(points_2d[:, 2]) > coplanar_tol): 

161 raise ValueError("Points must be coplanar") 

162 

163 # Construct a homogeneous matrix representing the transformation above 

164 to_2d = np.eye(4) 

165 to_2d[:3, :3] = vh 

166 to_2d[:3, 3] = -np.matmul(vh, points_mean) 

167 

168 # Find the 2D bounding box using the polygon 

169 to_origin_2d, extents_2d = oriented_bounds_2D(points_2d[:, :2]) 

170 # Make extents 3D 

171 extents = np.append(extents_2d, 0.0) 

172 # convert transformation from 2D to 3D and combine 

173 to_origin = np.matmul(transformations.planar_matrix_to_3D(to_origin_2d), to_2d) 

174 return to_origin, extents 

175 

176 try: 

177 # extract a set of convex hull vertices and normals from the input 

178 # we bother to do this to avoid recomputing the full convex hull if 

179 # possible 

180 if hasattr(obj, "convex_hull"): 

181 # if we have been passed a mesh, use its existing convex hull to pull from 

182 # cache rather than recomputing. This version of the cached convex hull has 

183 # normals pointing in arbitrary directions (straight from qhull) 

184 # using this avoids having to compute the expensive corrected normals 

185 # that mesh.convex_hull uses since normal directions don't matter 

186 # here 

187 hull = obj.convex_hull 

188 elif util.is_sequence(obj): 

189 # we've been passed a list of points 

190 points = np.asanyarray(obj) 

191 if util.is_shape(points, (-1, 2)): 

192 return oriented_bounds_2D(points) 

193 elif util.is_shape(points, (-1, 3)): 

194 hull = convex.convex_hull(points, repair=True) 

195 else: 

196 raise ValueError("Points are not (n,3) or (n,2)!") 

197 else: 

198 raise ValueError("Oriented bounds must be passed a mesh or a set of points!") 

199 except QhullError: 

200 # Try to recover from Qhull error if due to mesh being less than 3 

201 # dimensional 

202 if hasattr(obj, "vertices"): 

203 points = obj.vertices.view(np.ndarray) 

204 elif util.is_sequence(obj): 

205 points = np.asanyarray(obj) 

206 else: 

207 raise 

208 return oriented_bounds_coplanar(points) 

209 

210 vertices = hull.vertices 

211 hull_adj = hull.face_adjacency.T 

212 hull_edge = hull.face_adjacency_edges 

213 hull_normals = hull.face_normals 

214 

215 # matrices which will rotate each hull normal to [0,0,1] 

216 if normal is None: 

217 # convert face normals to spherical coordinates on the upper hemisphere 

218 # the vector_hemisphere call effectively merges negative but otherwise 

219 # identical vectors 

220 spherical_coords = util.vector_to_spherical(util.vector_hemisphere(hull_normals)) 

221 # the unique_rows call on merge angles gets unique spherical directions to check 

222 # we get a substantial speedup in the transformation matrix creation 

223 # inside the loop by converting to angles ahead of time 

224 spherical_unique = grouping.unique_rows(spherical_coords, digits=angle_digits)[0] 

225 matrices = [ 

226 transformations.spherical_matrix(*s).T 

227 for s in spherical_coords[spherical_unique] 

228 ] 

229 normals = util.spherical_to_vector(spherical_coords[spherical_unique]) 

230 else: 

231 # if explicit normal was passed use it and skip the grouping 

232 matrices = [geometry.align_vectors(normal, [0, 0, 1])] 

233 normals = [normal] 

234 

235 tic = now() 

236 min_2D = None 

237 min_volume = np.inf 

238 

239 # we now need to loop through all the possible candidate 

240 # directions for aligning our oriented bounding box. 

241 for normal, to_2D in zip(normals, matrices): 

242 # we could compute the hull in 2D for every direction 

243 # but since we know we're dealing with a convex blob 

244 # we can do back-face culling and then take the boundary 

245 # start by picking the normal direction with fewer edges 

246 side = np.dot(hull_normals, normal) > -1e-10 

247 # for coplanar points this could be empty 

248 if not side.any(): 

249 continue 

250 # this line is a heavy lift as it is finding the pairs of 

251 # adjacent faces where *exactly one* out of two of the faces 

252 # is visible (xor) and then using the index to get the edge 

253 edges = hull_edge[np.bitwise_xor(*side[hull_adj])] 

254 

255 # project the 3D convex hull vertices onto the plane 

256 projected = np.dot(to_2D[:3, :3], vertices.T).T[:, :3] 

257 # get the line segments of edges in 2D 

258 edge_vert = projected[:, :2][edges] 

259 # now get them as unit vectors 

260 edge_vectors = edge_vert[:, 1, :] - edge_vert[:, 0, :] 

261 edge_norm = np.sqrt(np.dot(edge_vectors**2, [1, 1])) 

262 edge_nonzero = edge_norm > 1e-10 

263 edge_vectors = edge_vectors[edge_nonzero] / edge_norm[edge_nonzero].reshape( 

264 (-1, 1) 

265 ) 

266 # create a set of perpendicular vectors 

267 perp_vectors = np.fliplr(edge_vectors) * [-1.0, 1.0] 

268 

269 # find the projection of every hull point on every edge vector 

270 # this does create a potentially gigantic n^2 array in memory 

271 # and there is the 'rotating calipers' algorithm which avoids this 

272 # however, we have reduced n with a convex hull and numpy dot products 

273 # are extremely fast so in practice this usually ends up being fine 

274 x = np.dot(edge_vectors, edge_vert[:, 0, :2].T) 

275 y = np.dot(perp_vectors, edge_vert[:, 0, :2].T) 

276 area = ((x.max(axis=1) - x.min(axis=1)) * (y.max(axis=1) - y.min(axis=1))).min() 

277 

278 # the volume is 2D area plus the projected height 

279 volume = area * np.ptp(projected[:, 2]) 

280 

281 # store this transform if it's better than one we've seen 

282 if volume < min_volume: 

283 min_volume = volume 

284 min_2D = to_2D 

285 

286 # we know the minimum volume transform which should be the expensive 

287 # part so now we need to do the bookkeeping to find the box 

288 vert_ones = np.column_stack((vertices, np.ones(len(vertices)))).T 

289 projected = np.dot(min_2D, vert_ones).T[:, :3] 

290 height = np.ptp(projected[:, 2]) 

291 rotation_2D, box = oriented_bounds_2D(projected[:, :2]) 

292 min_extents = np.append(box, height) 

293 rotation_2D[:2, 2] = 0.0 

294 rotation_Z = transformations.planar_matrix_to_3D(rotation_2D) 

295 

296 # combine the 2D OBB transformation with the 2D projection transform 

297 to_origin = np.dot(rotation_Z, min_2D) 

298 

299 # transform points using our matrix to find the translation 

300 transformed = transformations.transform_points(vertices, to_origin) 

301 box_center = transformed.min(axis=0) + np.ptp(transformed, axis=0) * 0.5 

302 to_origin[:3, 3] = -box_center 

303 

304 # return ordered 3D extents 

305 if ordered: 

306 # sort the three extents 

307 order = min_extents.argsort() 

308 # generate a matrix which will flip transform 

309 # to match the new ordering 

310 flip = np.eye(4) 

311 flip[:3, :3] = -np.eye(3)[order] 

312 

313 # make sure transform isn't mangling triangles 

314 # by reversing windings on triangles 

315 if not np.isclose(np.linalg.det(flip[:3, :3]), 1.0): 

316 flip[:3, :3] = np.dot(flip[:3, :3], -np.eye(3)) 

317 

318 # apply the flip to the OBB transform 

319 to_origin = np.dot(flip, to_origin) 

320 # apply the order to the extents 

321 min_extents = min_extents[order] 

322 

323 log.debug("oriented_bounds checked %d vectors in %0.4fs", len(matrices), now() - tic) 

324 

325 return to_origin, min_extents 

326 

327 

328def minimum_cylinder(obj, sample_count=6, angle_tol=0.001): 

329 """ 

330 Find the approximate minimum volume cylinder which contains 

331 a mesh or a a list of points. 

332 

333 Samples a hemisphere then uses scipy.optimize to pick the 

334 final orientation of the cylinder. 

335 

336 A nice discussion about better ways to implement this is here: 

337 https://www.staff.uni-mainz.de/schoemer/publications/ALGO00.pdf 

338 

339 

340 Parameters 

341 ---------- 

342 obj : trimesh.Trimesh, or (n, 3) float 

343 Mesh object or points in space 

344 sample_count : int 

345 How densely should we sample the hemisphere. 

346 Angular spacing is 180 degrees / this number 

347 

348 Returns 

349 ---------- 

350 result : dict 

351 With keys: 

352 'radius' : float, radius of cylinder 

353 'height' : float, height of cylinder 

354 'transform' : (4,4) float, transform from the origin 

355 to centered cylinder 

356 """ 

357 

358 def volume_from_angles(spherical, return_data=False): 

359 """ 

360 Takes spherical coordinates and calculates the volume 

361 of a cylinder along that vector 

362 

363 Parameters 

364 --------- 

365 spherical : (2,) float 

366 Theta and phi 

367 return_data : bool 

368 Flag for returned 

369 

370 Returns 

371 -------- 

372 if return_data: 

373 transform ((4,4) float) 

374 radius (float) 

375 height (float) 

376 else: 

377 volume (float) 

378 """ 

379 to_2D = transformations.spherical_matrix(*spherical, axes="rxyz") 

380 projected = transformations.transform_points(hull, matrix=to_2D) 

381 height = np.ptp(projected[:, 2]) 

382 

383 try: 

384 center_2D, radius = nsphere.minimum_nsphere(projected[:, :2]) 

385 except ValueError: 

386 return np.inf 

387 

388 volume = np.pi * height * (radius**2) 

389 if return_data: 

390 center_3D = np.append(center_2D, projected[:, 2].min() + (height * 0.5)) 

391 transform = np.dot( 

392 np.linalg.inv(to_2D), transformations.translation_matrix(center_3D) 

393 ) 

394 return transform, radius, height 

395 return volume 

396 

397 # we've been passed a mesh with radial symmetry 

398 # use center mass and symmetry axis and go home early 

399 if hasattr(obj, "symmetry") and obj.symmetry == "radial": 

400 # find our origin 

401 if obj.is_watertight: 

402 # set origin to center of mass 

403 origin = obj.center_mass 

404 else: 

405 # convex hull should be watertight 

406 origin = obj.convex_hull.center_mass 

407 # will align symmetry axis with Z and move origin to zero 

408 to_2D = geometry.plane_transform(origin=origin, normal=obj.symmetry_axis) 

409 # transform vertices to plane to check 

410 on_plane = transformations.transform_points(obj.vertices, to_2D) 

411 # cylinder height is overall Z span 

412 height = np.ptp(on_plane[:, 2]) 

413 # center mass is correct on plane, but position 

414 # along symmetry axis may be wrong so slide it 

415 slide = transformations.translation_matrix( 

416 [0, 0, (height / 2.0) - on_plane[:, 2].max()] 

417 ) 

418 to_2D = np.dot(slide, to_2D) 

419 # radius is maximum radius 

420 radius = (on_plane[:, :2] ** 2).sum(axis=1).max() ** 0.5 

421 # save kwargs 

422 result = {"height": height, "radius": radius, "transform": np.linalg.inv(to_2D)} 

423 return result 

424 

425 # get the points on the convex hull of the result 

426 hull = convex.hull_points(obj) 

427 if not util.is_shape(hull, (-1, 3)): 

428 raise ValueError("Input must be reducable to 3D points!") 

429 

430 # sample a hemisphere so local hill climbing can do its thing 

431 samples = util.grid_linspace([[0, 0], [np.pi, np.pi]], sample_count) 

432 

433 # if it's rotationally symmetric the bounding cylinder 

434 # is almost certainly along one of the PCI vectors 

435 if hasattr(obj, "principal_inertia_vectors"): 

436 # add the principal inertia vectors if we have a mesh 

437 samples = np.vstack( 

438 (samples, util.vector_to_spherical(obj.principal_inertia_vectors)) 

439 ) 

440 

441 tic = [now()] 

442 # the projected volume at each sample 

443 volumes = np.array([volume_from_angles(i) for i in samples]) 

444 # the best vector in (2,) spherical coordinates 

445 best = samples[volumes.argmin()] 

446 tic.append(now()) 

447 

448 # since we already explored the global space, set the bounds to be 

449 # just around the sample that had the lowest volume 

450 step = 2 * np.pi / sample_count 

451 bounds = [(best[0] - step, best[0] + step), (best[1] - step, best[1] + step)] 

452 # run the local optimization 

453 r = optimize.minimize( 

454 volume_from_angles, best, tol=angle_tol, method="SLSQP", bounds=bounds 

455 ) 

456 

457 tic.append(now()) 

458 log.debug("Performed search in %f and minimize in %f", *np.diff(tic)) 

459 

460 # actually chunk the information about the cylinder 

461 transform, radius, height = volume_from_angles(r["x"], return_data=True) 

462 

463 result = {"transform": transform, "radius": radius, "height": height} 

464 return result 

465 

466 

467def to_extents(bounds): 

468 """ 

469 Convert an axis aligned bounding box to extents and 

470 transform. 

471 

472 Parameters 

473 ------------ 

474 bounds : (2, 3) float 

475 Axis aligned bounds in space 

476 

477 Returns 

478 ------------ 

479 extents : (3,) float 

480 Extents of the bounding box 

481 transform : (4, 4) float 

482 Homogeneous transform moving extents to bounds 

483 """ 

484 bounds = np.asanyarray(bounds, dtype=np.float64) 

485 if bounds.shape != (2, 3): 

486 raise ValueError("bounds must be (2, 3)") 

487 

488 extents = np.ptp(bounds, axis=0) 

489 transform = np.eye(4) 

490 transform[:3, 3] = bounds.mean(axis=0) 

491 

492 return extents, transform 

493 

494 

495def corners(bounds): 

496 """ 

497 Given a pair of axis aligned bounds, return all 

498 8 corners of the bounding box. 

499 

500 Parameters 

501 ---------- 

502 bounds : (2,3) or (2,2) float 

503 Axis aligned bounds 

504 

505 Returns 

506 ---------- 

507 corners : (8,3) float 

508 Corner vertices of the cube 

509 """ 

510 

511 bounds = np.asanyarray(bounds, dtype=np.float64) 

512 

513 if util.is_shape(bounds, (2, 2)): 

514 bounds = np.column_stack((bounds, [0, 0])) 

515 elif not util.is_shape(bounds, (2, 3)): 

516 raise ValueError("bounds must be (2,2) or (2,3)!") 

517 

518 minx, miny, minz, maxx, maxy, maxz = np.arange(6) 

519 corner_index = np.array( 

520 [ 

521 minx, 

522 miny, 

523 minz, 

524 maxx, 

525 miny, 

526 minz, 

527 maxx, 

528 maxy, 

529 minz, 

530 minx, 

531 maxy, 

532 minz, 

533 minx, 

534 miny, 

535 maxz, 

536 maxx, 

537 miny, 

538 maxz, 

539 maxx, 

540 maxy, 

541 maxz, 

542 minx, 

543 maxy, 

544 maxz, 

545 ] 

546 ).reshape((-1, 3)) 

547 

548 corners = bounds.reshape(-1)[corner_index] 

549 return corners 

550 

551 

552def contains(bounds: ArrayLike, points: ArrayLike) -> NDArray[np.bool_]: 

553 """ 

554 Do an axis aligned bounding box check on an array of points. 

555 

556 Parameters 

557 ----------- 

558 bounds : (2, dimension) float 

559 Axis aligned bounding box 

560 points : (n, dimension) float 

561 Points in space 

562 

563 Returns 

564 ----------- 

565 points_inside : (n,) bool 

566 True if points are inside the AABB 

567 """ 

568 # make sure we have correct input types 

569 bounds = np.asanyarray(bounds, dtype=np.float64) 

570 points = np.asanyarray(points, dtype=np.float64) 

571 

572 if len(bounds) != 2: 

573 raise ValueError("bounds must be (2,dimension)!") 

574 if not util.is_shape(points, (-1, bounds.shape[1])): 

575 raise ValueError("bounds shape must match points!") 

576 

577 # run the simple check 

578 points_inside = np.logical_and( 

579 (points > bounds[0]).all(axis=1), (points < bounds[1]).all(axis=1) 

580 ) 

581 

582 return points_inside