Coverage for trimesh/creation.py: 84%

434 statements  

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

1""" 

2creation.py 

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

4 

5Create meshes from primitives, or with operations. 

6""" 

7 

8import collections 

9import warnings 

10 

11import numpy as np 

12 

13from . import exceptions, grouping, triangles, util 

14from . import transformations as tf 

15from .base import Trimesh 

16from .constants import log, tol 

17from .geometry import align_vectors, faces_to_edges, plane_transform 

18from .resources import get_json 

19from .typed import ArrayLike, Integer, NDArray, Number 

20 

21try: 

22 # shapely is a soft dependency 

23 from shapely.geometry import Polygon 

24 from shapely.wkb import loads as load_wkb 

25except BaseException as E: 

26 # re-raise the exception when someone tries 

27 # to use the module that they don't have 

28 Polygon = exceptions.ExceptionWrapper(E) 

29 load_wkb = exceptions.ExceptionWrapper(E) 

30 

31# get stored values for simple box and icosahedron primitives 

32_data = get_json("creation.json") 

33# check available triangulation engines without importing them 

34_engines = [ 

35 ("earcut", util.has_module("mapbox_earcut")), 

36 ("manifold", util.has_module("manifold3d")), 

37 ("triangle", util.has_module("triangle")), 

38] 

39 

40 

41def revolve( 

42 linestring: ArrayLike, 

43 angle: Number | None = None, 

44 cap: bool = False, 

45 sections: Integer | None = None, 

46 transform: ArrayLike | None = None, 

47 **kwargs, 

48) -> Trimesh: 

49 """ 

50 Revolve a 2D line string around the 2D Y axis, with a result with 

51 the 2D Y axis pointing along the 3D Z axis. 

52 

53 This function is intended to handle the complexity of indexing 

54 and is intended to be used to create all radially symmetric primitives, 

55 eventually including cylinders, annular cylinders, capsules, cones, 

56 and UV spheres. 

57 

58 Note that if your linestring is closed, it needs to be counterclockwise 

59 if you would like face winding and normals facing outwards. 

60 

61 Parameters 

62 ------------- 

63 linestring : (n, 2) float 

64 Lines in 2D which will be revolved 

65 angle 

66 Angle in radians to revolve curve by or if not 

67 passed will be a full revolution (`angle = 2*pi`) 

68 cap 

69 If not a full revolution (`0.0 < angle < 2 * pi`) 

70 and cap is True attempt to add a tessellated cap. 

71 sections 

72 Number of sections result should have 

73 If not specified default is 32 per revolution 

74 transform : None or (4, 4) float 

75 Transform to apply to mesh after construction 

76 **kwargs : dict 

77 Passed to Trimesh constructor 

78 

79 Returns 

80 -------------- 

81 revolved : Trimesh 

82 Mesh representing revolved result 

83 """ 

84 linestring = np.asanyarray(linestring, dtype=np.float64) 

85 

86 # linestring must be ordered 2D points 

87 if len(linestring.shape) != 2 or linestring.shape[1] != 2: 

88 raise ValueError("linestring must be 2D!") 

89 

90 if angle is None: 

91 # default to closing the revolution 

92 angle = np.pi * 2.0 

93 closed = True 

94 else: 

95 # check passed angle value 

96 closed = util.isclose(angle, np.pi * 2, atol=1e-10) 

97 

98 if sections is None: 

99 # default to 32 sections for a full revolution 

100 sections = int(angle / (np.pi * 2) * 32) 

101 

102 # change to face count 

103 sections += 1 

104 # create equally spaced angles 

105 theta = np.linspace(0, angle, sections) 

106 

107 # 2D points around the revolution 

108 points = np.column_stack((np.cos(theta), np.sin(theta))) 

109 

110 # how many points per slice 

111 per = len(linestring) 

112 

113 # use the 2D X component as radius 

114 radius = linestring[:, 0] 

115 # use the 2D Y component as the height along revolution 

116 height = linestring[:, 1] 

117 # a lot of tiling to get our 3D vertices 

118 vertices = np.column_stack( 

119 ( 

120 np.tile(points, (1, per)).reshape((-1, 2)) 

121 * np.tile(radius, len(points)).reshape((-1, 1)), 

122 np.tile(height, len(points)), 

123 ) 

124 ) 

125 

126 if closed: 

127 # should be a duplicate set of vertices 

128 if tol.strict: 

129 assert util.allclose(vertices[:per], vertices[-per:], atol=1e-8) 

130 

131 # chop off duplicate vertices 

132 vertices = vertices[:-per] 

133 

134 # how many slices of the pie 

135 slices = len(theta) - 1 

136 

137 # start with a quad for every segment 

138 # this is a superset which will then be reduced 

139 quad = np.array([0, per, 1, 1, per, per + 1]) 

140 # stack the faces for a single slice of the revolution 

141 single = np.tile(quad, per - 1).reshape((-1, 3)) 

142 # `per` is basically the stride of the vertices 

143 single += np.tile(np.arange(per - 1), (2, 1)).T.reshape((-1, 1)) 

144 # remove any zero-area triangle 

145 # this covers many cases without having to think too much 

146 single = single[triangles.area(vertices[single]) > tol.merge] 

147 

148 # how much to offset each slice 

149 # note arange multiplied by vertex stride 

150 # but tiled by the number of faces we actually have 

151 offset = np.tile(np.arange(slices) * per, (len(single), 1)).T.reshape((-1, 1)) 

152 # stack a single slice into N slices 

153 stacked = np.tile(single.ravel(), slices).reshape((-1, 3)) 

154 

155 if tol.strict: 

156 # make sure we didn't screw up stacking operation 

157 assert np.allclose(stacked.reshape((-1, single.shape[0], 3)) - single, 0) 

158 

159 # offset stacked and wrap vertices 

160 faces = (stacked + offset) % len(vertices) 

161 

162 # Handle capping before applying any transformation 

163 if not closed and cap: 

164 # Use the triangulated linestring as the base cap faces (cap_0), assuming no new vertices 

165 # are added, indices defining triangles of cap_0 should be reusable for cap_angle 

166 cap_0_vertices, cap_0_faces = triangulate_polygon( 

167 Polygon(linestring), force_vertices=True 

168 ) 

169 

170 if tol.strict: 

171 # make sure we didn't screw up triangulation 

172 unique = grouping.unique_rows(cap_0_vertices)[0] 

173 assert set(unique) == set(range(len(linestring))), ( 

174 "Triangulation added vertices!" 

175 ) 

176 

177 # Use the last set of vertices as the top cap contour (cap_angle) 

178 offset = len(vertices) - per 

179 cap_angle_faces = cap_0_faces + offset 

180 flipped_cap_angle_faces = np.fliplr(cap_angle_faces) # reverse the winding 

181 

182 # Append cap faces to the face array 

183 faces = np.vstack([faces, cap_0_faces, flipped_cap_angle_faces]) 

184 

185 if transform is not None: 

186 # apply transform to vertices 

187 vertices = tf.transform_points(vertices, transform) 

188 

189 # create the mesh from our vertices and faces 

190 mesh = Trimesh(vertices=vertices, faces=faces, **kwargs) 

191 

192 # strict checks run only in unit tests and when cap is True 

193 if tol.strict and ( 

194 np.allclose(radius[[0, -1]], 0.0) or np.allclose(linestring[0], linestring[-1]) 

195 ): 

196 if closed or cap: 

197 # if revolved curve starts and ends with zero radius 

198 # it should really be a valid volume, unless the sign 

199 # reversed on the input linestring 

200 assert mesh.is_volume 

201 assert mesh.body_count == 1 

202 

203 return mesh 

204 

205 

206def extrude_polygon( 

207 polygon: "Polygon", 

208 height: Number, 

209 transform: ArrayLike | None = None, 

210 mid_plane: bool = False, 

211 **kwargs, 

212) -> Trimesh: 

213 """ 

214 Extrude a 2D shapely polygon into a 3D mesh 

215 

216 Parameters 

217 ---------- 

218 polygon : shapely.geometry.Polygon 

219 2D geometry to extrude 

220 height : float 

221 Distance to extrude polygon along Z 

222 transform : None or (4, 4) float 

223 Transform to apply to mesh after construction 

224 triangle_args : str or None 

225 Passed to triangle 

226 **kwargs : dict 

227 Passed to `triangulate_polygon` 

228 

229 Returns 

230 ---------- 

231 mesh : trimesh.Trimesh 

232 Resulting extrusion as watertight body 

233 """ 

234 # create a triangulation from the polygon 

235 vertices, faces = triangulate_polygon(polygon, **kwargs) 

236 

237 if mid_plane: 

238 translation = np.eye(4) 

239 translation[2, 3] = abs(float(height)) / -2.0 

240 if transform is None: 

241 transform = translation 

242 else: 

243 transform = np.dot(transform, translation) 

244 

245 # extrude that triangulation along Z 

246 mesh = extrude_triangulation( 

247 vertices=vertices, faces=faces, height=height, transform=transform, **kwargs 

248 ) 

249 return mesh 

250 

251 

252def sweep_polygon( 

253 polygon: "Polygon", 

254 path: ArrayLike, 

255 angles: ArrayLike | None = None, 

256 cap: bool = True, 

257 connect: bool = True, 

258 kwargs: dict | None = None, 

259 **triangulation, 

260) -> Trimesh: 

261 """ 

262 Extrude a 2D polygon into a 3D mesh along a 3D path. Note that this 

263 does *not* handle the case where there is very sharp curvature leading 

264 the polygon to intersect the plane of a previous slice, and does *not* 

265 scale the polygon along the induced normal to result in a constant cross section. 

266 

267 You may want to resample your path with a B-spline, i.e: 

268 `trimesh.path.simplify.resample_spline(path, smooth=0.2, count=100)` 

269 

270 Parameters 

271 ---------- 

272 polygon : shapely.geometry.Polygon 

273 Profile to sweep along path 

274 path : (n, 3) float 

275 A path in 3D 

276 angles : (n,) float 

277 Optional rotation angle relative to prior vertex 

278 at each vertex. 

279 cap 

280 If an open path is passed apply a cap to both ends. 

281 connect 

282 If a closed path is passed connect the sweep into 

283 a single watertight mesh. 

284 kwargs : dict 

285 Passed to the mesh constructor. 

286 **triangulation 

287 Passed to `triangulate_polygon`, i.e. `engine='triangle'` 

288 

289 Returns 

290 ------- 

291 mesh : trimesh.Trimesh 

292 Geometry of result 

293 """ 

294 

295 path = np.asanyarray(path, dtype=np.float64) 

296 if not util.is_shape(path, (-1, 3)): 

297 raise ValueError("Path must be (n, 3)!") 

298 

299 if angles is not None: 

300 angles = np.asanyarray(angles, dtype=np.float64) 

301 if angles.shape != (len(path),): 

302 raise ValueError(angles.shape) 

303 else: 

304 # set all angles to zero 

305 angles = np.zeros(len(path), dtype=np.float64) 

306 

307 # check to see if path is closed i.e. first and last vertex are the same 

308 closed = np.linalg.norm(path[0] - path[-1]) < tol.merge 

309 # Extract 2D vertices and triangulation 

310 vertices_2D, faces_2D = triangulate_polygon(polygon, **triangulation) 

311 

312 # stack the `(n, 3)` faces into `(3 * n, 2)` edges 

313 edges = faces_to_edges(faces_2D) 

314 # edges which only occur once are on the boundary of the polygon 

315 # since the triangulation may have subdivided the boundary of the 

316 # shapely polygon, we need to find it again 

317 edges_unique = grouping.group_rows(np.sort(edges, axis=1), require_count=1) 

318 # subset the vertices to only ones included in the boundary 

319 unique, inverse = np.unique(edges[edges_unique].reshape(-1), return_inverse=True) 

320 # take only the vertices in the boundary 

321 # and stack them with zeros and ones so we can use dot 

322 # products to transform them all over the place 

323 vertices_tf = np.column_stack( 

324 (vertices_2D[unique], np.zeros(len(unique)), np.ones(len(unique))) 

325 ) 

326 # the indices of vertices_tf 

327 boundary = inverse.reshape((-1, 2)) 

328 

329 # now create the normals for the plane each slice will lie on 

330 # consider the simple path with 3 vertices and therefore 2 vectors: 

331 # - the first plane will be exactly along the first vector 

332 # - the second plane will be the average of the two vectors 

333 # - the last plane will be exactly along the last vector 

334 # and each plane origin will be the corresponding vertex on the path 

335 vector = util.unitize(path[1:] - path[:-1]) 

336 # unitize instead of / 2 as they may be degenerate / zero 

337 vector_mean = util.unitize(vector[1:] + vector[:-1]) 

338 # collect the vectors into plane normals 

339 normal = np.concatenate([[vector[0]], vector_mean, [vector[-1]]], axis=0) 

340 

341 if closed and connect: 

342 # if we have a closed loop average the first and last planes 

343 normal[0] = util.unitize(normal[[0, -1]].mean(axis=0)) 

344 

345 # planes should have one unit normal and one vertex each 

346 assert normal.shape == path.shape 

347 

348 # get the spherical coordinates for the normal vectors 

349 theta, phi = util.vector_to_spherical(normal).T 

350 

351 # collect the trig values into numpy arrays we can compose into matrices 

352 cos_theta, sin_theta = np.cos(theta), np.sin(theta) 

353 cos_phi, sin_phi = np.cos(phi), np.sin(phi) 

354 cos_roll, sin_roll = np.cos(angles), np.sin(angles) 

355 

356 # we want a rotation which will be the identity for a Z+ vector 

357 # this was constructed and unrolled from the following sympy block 

358 # theta, phi, roll = sp.symbols("theta phi roll") 

359 # matrix = ( 

360 # tf.rotation_matrix(roll, [0, 0, 1]) @ 

361 # tf.rotation_matrix(phi, [1, 0, 0]) @ 

362 # tf.rotation_matrix((sp.pi / 2) - theta, [0, 0, 1]) 

363 # ).inv() 

364 # matrix.simplify() 

365 

366 # shorthand for stacking 

367 zeros = np.zeros(len(theta)) 

368 ones = np.ones(len(theta)) 

369 

370 # stack initially as one unrolled matrix per row 

371 transforms = np.column_stack( 

372 [ 

373 -sin_roll * cos_phi * cos_theta + sin_theta * cos_roll, 

374 sin_roll * sin_theta + cos_phi * cos_roll * cos_theta, 

375 sin_phi * cos_theta, 

376 path[:, 0], 

377 -sin_roll * sin_theta * cos_phi - cos_roll * cos_theta, 

378 -sin_roll * cos_theta + sin_theta * cos_phi * cos_roll, 

379 sin_phi * sin_theta, 

380 path[:, 1], 

381 sin_phi * sin_roll, 

382 -sin_phi * cos_roll, 

383 cos_phi, 

384 path[:, 2], 

385 zeros, 

386 zeros, 

387 zeros, 

388 ones, 

389 ] 

390 ).reshape((-1, 4, 4)) 

391 

392 if tol.strict: 

393 # make sure that each transform moves the Z+ vector to the requested normal 

394 for n, matrix in zip(normal, transforms): 

395 check = tf.transform_points([[0.0, 0.0, 1.0]], matrix, translate=False)[0] 

396 assert np.allclose(check, n) 

397 

398 # apply transforms to prebaked homogeneous coordinates 

399 vertices_3D = np.concatenate( 

400 [np.dot(vertices_tf, matrix.T) for matrix in transforms], axis=0 

401 )[:, :3] 

402 

403 # now construct the faces with one group of boundary faces per slice 

404 stride = len(unique) 

405 boundary_next = boundary + stride 

406 faces_slice = np.column_stack( 

407 [boundary, boundary_next[:, :1], boundary_next[:, ::-1], boundary[:, 1:]] 

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

409 

410 # offset the slices 

411 faces = [faces_slice + offset for offset in np.arange(len(path) - 1) * stride] 

412 

413 # connect only applies to closed paths 

414 if closed and connect: 

415 # the last slice will not be required 

416 max_vertex = (len(path) - 1) * stride 

417 # clip off the duplicated vertices 

418 vertices_3D = vertices_3D[:max_vertex] 

419 # apply the modulus in-place to a conservative subset 

420 faces[-1] %= max_vertex 

421 elif cap: 

422 # these are indices of `vertices_2D` that were not on the boundary 

423 # which can happen for triangulation algorithms that added vertices 

424 # we don't currently support that but you could append the unconsumed 

425 # vertices and then update the mapping below to reflect that 

426 unconsumed = set(unique).difference(faces_2D.ravel()) 

427 if len(unconsumed) > 0: 

428 raise NotImplementedError("triangulation added vertices: no logic to cap!") 

429 

430 # map the 2D faces to the order we used 

431 mapped = np.zeros(unique.max() + 2, dtype=np.int64) 

432 mapped[unique] = np.arange(len(unique)) 

433 

434 # now should correspond to the first vertex block 

435 cap_zero = mapped[faces_2D] 

436 # winding will be along +Z so flip for the bottom cap 

437 faces.append(np.fliplr(cap_zero)) 

438 # offset the end cap 

439 faces.append(cap_zero + stride * (len(path) - 1)) 

440 

441 if kwargs is None: 

442 kwargs = {} 

443 

444 if "process" not in kwargs: 

445 # we should be constructing clean meshes here 

446 # so we don't need to run an expensive verex merge 

447 kwargs["process"] = False 

448 

449 # stack the faces used 

450 faces = np.concatenate(faces, axis=0) 

451 

452 # generate the mesh from the face data 

453 mesh = Trimesh(vertices=vertices_3D, faces=faces, **kwargs) 

454 

455 if tol.strict: 

456 # we should not have included any unused vertices 

457 assert len(np.unique(faces)) == len(vertices_3D) 

458 

459 if cap: 

460 # mesh should always be a volume if cap is true 

461 assert mesh.is_volume 

462 

463 if closed and connect: 

464 assert mesh.is_volume 

465 assert mesh.body_count == 1 

466 

467 return mesh 

468 

469 

470def _cross_2d(a: NDArray, b: NDArray) -> NDArray: 

471 """ 

472 Numpy 2.0 depreciated cross products of 2D arrays. 

473 """ 

474 return a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0] 

475 

476 

477def extrude_triangulation( 

478 vertices: ArrayLike, 

479 faces: ArrayLike, 

480 height: Number, 

481 transform: ArrayLike | None = None, 

482 **kwargs, 

483) -> Trimesh: 

484 """ 

485 Extrude a 2D triangulation into a watertight mesh. 

486 

487 Parameters 

488 ---------- 

489 vertices : (n, 2) float 

490 2D vertices 

491 faces : (m, 3) int 

492 Triangle indexes of vertices 

493 height : float 

494 Distance to extrude triangulation 

495 transform : None or (4, 4) float 

496 Transform to apply to mesh after construction 

497 **kwargs : dict 

498 Passed to Trimesh constructor 

499 

500 Returns 

501 --------- 

502 mesh : trimesh.Trimesh 

503 Mesh created from extrusion 

504 """ 

505 vertices = np.asanyarray(vertices, dtype=np.float64) 

506 height = float(height) 

507 faces = np.asanyarray(faces, dtype=np.int64) 

508 

509 if not util.is_shape(vertices, (-1, 2)): 

510 raise ValueError("Vertices must be (n,2)") 

511 if not util.is_shape(faces, (-1, 3)): 

512 raise ValueError("Faces must be (n,3)") 

513 if np.abs(height) < tol.merge: 

514 raise ValueError("Height must be nonzero!") 

515 

516 # check the winding of the first few triangles 

517 signs = _cross_2d( 

518 np.subtract(*vertices[faces[:10, :2].T]), np.subtract(*vertices[faces[:10, 1:].T]) 

519 ) 

520 

521 # make sure the triangulation is aligned with the sign of 

522 # the height we've been passed 

523 if len(signs) > 0 and np.sign(signs.mean()) != np.sign(height): 

524 faces = np.fliplr(faces) 

525 

526 # stack the (n,3) faces into (3*n, 2) edges 

527 edges = faces_to_edges(faces) 

528 edges_sorted = np.sort(edges, axis=1) 

529 # edges which only occur once are on the boundary of the polygon 

530 # since the triangulation may have subdivided the boundary of the 

531 # shapely polygon, we need to find it again 

532 edges_unique = grouping.group_rows(edges_sorted, require_count=1) 

533 

534 # (n, 2, 2) set of line segments (positions, not references) 

535 boundary = vertices[edges[edges_unique]] 

536 

537 # we are creating two vertical triangles for every 2D line segment 

538 # on the boundary of the 2D triangulation 

539 vertical = np.tile(boundary.reshape((-1, 2)), 2).reshape((-1, 2)) 

540 vertical = np.column_stack((vertical, np.tile([0, height, 0, height], len(boundary)))) 

541 vertical_faces = np.tile([3, 1, 2, 2, 1, 0], (len(boundary), 1)) 

542 vertical_faces += np.arange(len(boundary)).reshape((-1, 1)) * 4 

543 vertical_faces = vertical_faces.reshape((-1, 3)) 

544 

545 # stack the (n,2) vertices with zeros to make them (n, 3) 

546 vertices_3D = util.stack_3D(vertices) 

547 

548 # a sequence of zero- indexed faces, which will then be appended 

549 # with offsets to create the final mesh 

550 faces_seq = [faces[:, ::-1], faces.copy(), vertical_faces] 

551 vertices_seq = [vertices_3D, vertices_3D.copy() + [0.0, 0, height], vertical] 

552 

553 # append sequences into flat nicely indexed arrays 

554 vertices, faces = util.append_faces(vertices_seq, faces_seq) 

555 if transform is not None: 

556 # apply transform here to avoid later bookkeeping 

557 vertices = tf.transform_points(vertices, transform) 

558 # if the transform flips the winding flip faces back 

559 # so that the normals will be facing outwards 

560 if tf.flips_winding(transform): 

561 # fliplr makes arrays non-contiguous 

562 faces = np.ascontiguousarray(np.fliplr(faces)) 

563 # create mesh object with passed keywords 

564 mesh = Trimesh(vertices=vertices, faces=faces, **kwargs) 

565 # only check in strict mode (unit tests) 

566 if tol.strict: 

567 assert mesh.volume > 0.0 

568 

569 return mesh 

570 

571 

572def triangulate_polygon( 

573 polygon, 

574 triangle_args: str | None = None, 

575 engine: str | None = None, 

576 force_vertices: bool = False, 

577 **kwargs, 

578) -> tuple[NDArray[np.float64], NDArray[np.int64]]: 

579 """ 

580 Given a shapely polygon create a triangulation using a 

581 python interface to the permissively licensed `mapbox-earcut` 

582 or the more robust `triangle.c`. 

583 > pip install manifold3d 

584 > pip install triangle 

585 > pip install mapbox_earcut 

586 

587 Parameters 

588 --------- 

589 polygon : Shapely.geometry.Polygon 

590 Polygon object to be triangulated. 

591 triangle_args 

592 Passed to triangle.triangulate i.e: 'p', 'pq30', 'pY'="don't insert vert" 

593 engine 

594 None or 'earcut' will use earcut, 'triangle' will use triangle 

595 force_vertices 

596 Many operations can't handle new vertices being inserted, so this will 

597 attempt to generate a triangulation without new vertices and raise a 

598 ValueError if it is unable to do so. 

599 

600 Returns 

601 -------------- 

602 vertices : (n, 2) float 

603 Points in space 

604 faces : (n, 3) int 

605 Index of vertices that make up triangles 

606 """ 

607 

608 if engine is None: 

609 # try getting the first engine that is installed 

610 engine = next((name for name, exists in _engines if exists), None) 

611 

612 if polygon is None or polygon.is_empty: 

613 return [], [] 

614 

615 vertices = None 

616 

617 if engine == "earcut": 

618 from mapbox_earcut import triangulate_float64 

619 

620 # get vertices as sequence where exterior 

621 # is the first value 

622 vertices = [np.array(polygon.exterior.coords)] 

623 vertices.extend(np.array(i.coords) for i in polygon.interiors) 

624 # record the index from the length of each vertex array 

625 rings = np.cumsum([len(v) for v in vertices]) 

626 # stack vertices into (n, 2) float array 

627 vertices = np.vstack(vertices) 

628 # run triangulation 

629 faces = ( 

630 triangulate_float64(vertices, rings) 

631 .reshape((-1, 3)) 

632 .astype(np.int64) 

633 .reshape((-1, 3)) 

634 ) 

635 

636 elif engine == "manifold": 

637 import manifold3d 

638 

639 # the outer ring is wound counter-clockwise 

640 rings = [ 

641 np.array(polygon.exterior.coords)[:: (1 if polygon.exterior.is_ccw else -1)][ 

642 :-1 

643 ] 

644 ] 

645 # wind interiors 

646 rings.extend( 

647 np.array(b.coords)[:: (-1 if b.is_ccw else 1)][:-1] for b in polygon.interiors 

648 ) 

649 faces = manifold3d.triangulate(rings).astype(np.int64) 

650 vertices = np.vstack(rings, dtype=np.float64) 

651 

652 elif engine == "triangle": 

653 from triangle import triangulate 

654 

655 # set default triangulation arguments if not specified 

656 if triangle_args is None: 

657 triangle_args = "p" 

658 # turn the polygon in to vertices, segments, and holes 

659 arg = _polygon_to_kwargs(polygon) 

660 # run the triangulation 

661 blob = triangulate(arg, triangle_args) 

662 vertices, faces = blob["vertices"], blob["triangles"].astype(np.int64) 

663 

664 # triangle may insert vertices 

665 if force_vertices: 

666 assert np.allclose(arg["vertices"], vertices) 

667 

668 if vertices is None: 

669 log.warning( 

670 "try running `pip install mapbox-earcut manifold3d`" 

671 + "or `triangle`, `mapbox_earcut`, then explicitly pass:\n" 

672 + '`triangulate_polygon(*args, engine="triangle")`\n' 

673 + "to use the non-FSF-approved-license triangle engine" 

674 ) 

675 raise ValueError("No available triangulation engine!") 

676 

677 return vertices, faces 

678 

679 

680def _polygon_to_kwargs(polygon) -> dict: 

681 """ 

682 Given a shapely polygon generate the data to pass to 

683 the triangle mesh generator 

684 

685 Parameters 

686 --------- 

687 polygon : Shapely.geometry.Polygon 

688 Input geometry 

689 

690 Returns 

691 -------- 

692 result : dict 

693 Has keys: vertices, segments, holes 

694 """ 

695 

696 if not polygon.is_valid: 

697 raise ValueError("invalid shapely polygon passed!") 

698 

699 def round_trip(start, length): 

700 """ 

701 Given a start index and length, create a series of (n, 2) edges which 

702 create a closed traversal. 

703 

704 Examples 

705 --------- 

706 start, length = 0, 3 

707 returns: [(0,1), (1,2), (2,0)] 

708 """ 

709 tiled = np.tile(np.arange(start, start + length).reshape((-1, 1)), 2) 

710 tiled = tiled.reshape(-1)[1:-1].reshape((-1, 2)) 

711 tiled = np.vstack((tiled, [tiled[-1][-1], tiled[0][0]])) 

712 return tiled 

713 

714 def add_boundary(boundary, start): 

715 # coords is an (n, 2) ordered list of points on the polygon boundary 

716 # the first and last points are the same, and there are no 

717 # guarantees on points not being duplicated (which will 

718 # later cause meshpy/triangle to shit a brick) 

719 coords = np.array(boundary.coords) 

720 # find indices points which occur only once, and sort them 

721 # to maintain order 

722 unique = np.sort(grouping.unique_rows(coords)[0]) 

723 cleaned = coords[unique] 

724 

725 vertices.append(cleaned) 

726 facets.append(round_trip(start, len(cleaned))) 

727 

728 # holes require points inside the region of the hole, which we find 

729 # by creating a polygon from the cleaned boundary region, and then 

730 # using a representative point. You could do things like take the mean of 

731 # the points, but this is more robust (to things like concavity), if 

732 # slower. 

733 test = Polygon(cleaned) 

734 holes.append(np.array(test.representative_point().coords)[0]) 

735 

736 return len(cleaned) 

737 

738 # sequence of (n,2) points in space 

739 vertices = collections.deque() 

740 # sequence of (n,2) indices of vertices 

741 facets = collections.deque() 

742 # list of (2) vertices in interior of hole regions 

743 holes = collections.deque() 

744 

745 start = add_boundary(polygon.exterior, 0) 

746 for interior in polygon.interiors: 

747 try: 

748 start += add_boundary(interior, start) 

749 except BaseException: 

750 log.warning("invalid interior, continuing") 

751 continue 

752 

753 # create clean (n,2) float array of vertices 

754 # and (m, 2) int array of facets 

755 # by stacking the sequence of (p,2) arrays 

756 vertices = np.vstack(vertices) 

757 facets = np.vstack(facets).tolist() 

758 # shapely polygons can include a Z component 

759 # strip it out for the triangulation 

760 if vertices.shape[1] == 3: 

761 vertices = vertices[:, :2] 

762 result = {"vertices": vertices, "segments": facets} 

763 # holes in meshpy lingo are a (h, 2) list of (x,y) points 

764 # which are inside the region of the hole 

765 # we added a hole for the exterior, which we slice away here 

766 holes = np.array(holes)[1:] 

767 if len(holes) > 0: 

768 result["holes"] = holes 

769 return result 

770 

771 

772def box( 

773 extents: ArrayLike | None = None, 

774 transform: ArrayLike | None = None, 

775 bounds: ArrayLike | None = None, 

776 **kwargs, 

777): 

778 """ 

779 Return a cuboid. 

780 

781 Parameters 

782 ------------ 

783 extents : (3,) float 

784 Edge lengths 

785 transform: (4, 4) float 

786 Transformation matrix 

787 bounds : None or (2, 3) float 

788 Corners of AABB, overrides extents and transform. 

789 **kwargs: 

790 passed to Trimesh to create box 

791 

792 Returns 

793 ------------ 

794 geometry : trimesh.Trimesh 

795 Mesh of a cuboid 

796 """ 

797 # vertices of the cube from reference 

798 vertices = np.array(_data["box"]["vertices"], order="C", dtype=np.float64) 

799 faces = np.array(_data["box"]["faces"], order="C", dtype=np.int64) 

800 face_normals = np.array(_data["box"]["face_normals"], order="C", dtype=np.float64) 

801 

802 # resize cube based on passed extents 

803 if bounds is not None: 

804 if transform is not None or extents is not None: 

805 raise ValueError("`bounds` overrides `extents`/`transform`!") 

806 bounds = np.array(bounds, dtype=np.float64) 

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

808 raise ValueError("`bounds` must be (2, 3) float!") 

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

810 vertices *= extents 

811 vertices += bounds[0] 

812 elif extents is not None: 

813 extents = np.asanyarray(extents, dtype=np.float64) 

814 if extents.shape != (3,): 

815 raise ValueError("Extents must be (3,)!") 

816 vertices -= 0.5 

817 vertices *= extents 

818 else: 

819 vertices -= 0.5 

820 extents = np.asarray((1.0, 1.0, 1.0), dtype=np.float64) 

821 

822 if "metadata" not in kwargs: 

823 kwargs["metadata"] = {} 

824 kwargs["metadata"].update({"shape": "box", "extents": extents}) 

825 

826 box = Trimesh( 

827 vertices=vertices, faces=faces, face_normals=face_normals, process=False, **kwargs 

828 ) 

829 

830 # do the transform here to preserve face normals 

831 if transform is not None: 

832 box.apply_transform(transform) 

833 

834 return box 

835 

836 

837def icosahedron(**kwargs) -> Trimesh: 

838 """ 

839 Create an icosahedron, one of the platonic solids which is has 20 faces. 

840 

841 Parameters 

842 ------------ 

843 kwargs : dict 

844 Passed through to `Trimesh` constructor. 

845 

846 Returns 

847 ------------- 

848 ico : trimesh.Trimesh 

849 Icosahederon centered at the origin. 

850 """ 

851 # get stored pre-baked primitive values 

852 vertices = np.array(_data["icosahedron"]["vertices"], dtype=np.float64) 

853 faces = np.array(_data["icosahedron"]["faces"], dtype=np.int64) 

854 return Trimesh( 

855 vertices=vertices, faces=faces, process=kwargs.pop("process", False), **kwargs 

856 ) 

857 

858 

859def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs): 

860 """ 

861 Create an icosphere centered at the origin. 

862 

863 Parameters 

864 ---------- 

865 subdivisions : int 

866 How many times to subdivide the mesh. 

867 Note that the number of faces will grow as function of 

868 4 ** subdivisions, so you probably want to keep this under ~5 

869 radius : float 

870 Desired radius of sphere 

871 kwargs : dict 

872 Passed through to `Trimesh` constructor. 

873 

874 Returns 

875 --------- 

876 ico : trimesh.Trimesh 

877 Meshed sphere 

878 """ 

879 radius = float(radius) 

880 subdivisions = int(subdivisions) 

881 

882 ico = icosahedron() 

883 ico._validate = False 

884 

885 for _ in range(subdivisions): 

886 ico = ico.subdivide() 

887 vectors = ico.vertices 

888 scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1])) 

889 unit = vectors / scalar.reshape((-1, 1)) 

890 ico.vertices += unit * (radius - scalar).reshape((-1, 1)) 

891 

892 # if we didn't subdivide we still need to refine the radius 

893 if subdivisions <= 0: 

894 vectors = ico.vertices 

895 scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1])) 

896 unit = vectors / scalar.reshape((-1, 1)) 

897 ico.vertices += unit * (radius - scalar).reshape((-1, 1)) 

898 

899 if "color" in kwargs: 

900 warnings.warn( 

901 "`icosphere(color=...)` is deprecated and will " 

902 + "be removed in June 2024: replace with Trimesh constructor " 

903 + "kewyword argument `icosphere(face_colors=...)`", 

904 category=DeprecationWarning, 

905 stacklevel=2, 

906 ) 

907 kwargs["face_colors"] = kwargs.pop("color") 

908 

909 return Trimesh( 

910 vertices=ico.vertices, 

911 faces=ico.faces, 

912 metadata={"shape": "sphere", "radius": radius}, 

913 process=kwargs.pop("process", False), 

914 **kwargs, 

915 ) 

916 

917 

918def uv_sphere( 

919 radius: Number = 1.0, 

920 count: ArrayLike | None = None, 

921 transform: ArrayLike | None = None, 

922 **kwargs, 

923) -> Trimesh: 

924 """ 

925 Create a UV sphere (latitude + longitude) centered at the 

926 origin. Roughly one order of magnitude faster than an 

927 icosphere but slightly uglier. 

928 

929 Parameters 

930 ---------- 

931 radius : float 

932 Radius of sphere 

933 count : (2,) int 

934 Number of latitude and longitude lines 

935 transform : None or (4, 4) float 

936 Transform to apply to mesh after construction 

937 kwargs : dict 

938 Passed thgrough 

939 Returns 

940 ---------- 

941 mesh : trimesh.Trimesh 

942 Mesh of UV sphere with specified parameters 

943 """ 

944 

945 # set the resolution of the uv sphere 

946 if count is None: 

947 count = np.array([32, 64], dtype=np.int64) 

948 else: 

949 count = np.array(count, dtype=np.int64) 

950 count += np.mod(count, 2) 

951 count[1] *= 2 

952 

953 # generate the 2D curve for the UV sphere 

954 theta = np.linspace(0.0, np.pi, num=count[0]) 

955 linestring = np.column_stack((np.sin(theta), -np.cos(theta))) * radius 

956 

957 # revolve the curve to create a volume 

958 return revolve( 

959 linestring=linestring, 

960 sections=count[1], 

961 transform=transform, 

962 metadata={"shape": "sphere", "radius": radius}, 

963 **kwargs, 

964 ) 

965 

966 

967def capsule( 

968 height: Number = 1.0, 

969 radius: Number = 1.0, 

970 count: ArrayLike | None = None, 

971 transform: ArrayLike | None = None, 

972 **kwargs, 

973) -> Trimesh: 

974 """ 

975 Create a mesh of a capsule, or a cylinder with hemispheric ends. 

976 

977 Parameters 

978 ---------- 

979 height : float 

980 Center to center distance of two spheres 

981 radius : float 

982 Radius of the cylinder and hemispheres 

983 count : (2,) int 

984 Number of sections on latitude and longitude 

985 transform : None or (4, 4) float 

986 Transform to apply to mesh after construction 

987 Returns 

988 ---------- 

989 capsule : trimesh.Trimesh 

990 Capsule geometry with: 

991 - cylinder axis is along Z 

992 - one hemisphere is centered at the origin 

993 - other hemisphere is centered along the Z axis at height 

994 """ 

995 if count is None: 

996 count = np.array([32, 64], dtype=np.int64) 

997 else: 

998 count = np.array(count, dtype=np.int64) 

999 count += np.mod(count, 2) 

1000 

1001 height = abs(float(height)) 

1002 radius = abs(float(radius)) 

1003 

1004 # create a half circle 

1005 theta = np.linspace(-np.pi / 2.0, np.pi / 2.0, count[0]) 

1006 linestring = np.column_stack((np.cos(theta), np.sin(theta))) * radius 

1007 

1008 # offset the top and bottom by half the height 

1009 half = len(linestring) // 2 

1010 linestring[:half][:, 1] -= height / 2.0 

1011 linestring[half:][:, 1] += height / 2.0 

1012 

1013 return revolve( 

1014 linestring, 

1015 sections=count[1], 

1016 transform=transform, 

1017 metadata={"shape": "capsule", "height": height, "radius": radius}, 

1018 **kwargs, 

1019 ) 

1020 

1021 

1022def cone( 

1023 radius: Number, 

1024 height: Number, 

1025 sections: Integer | None = None, 

1026 transform: ArrayLike | None = None, 

1027 **kwargs, 

1028) -> Trimesh: 

1029 """ 

1030 Create a mesh of a cone along Z centered at the origin. 

1031 

1032 Parameters 

1033 ---------- 

1034 radius : float 

1035 The radius of the cone at the widest part. 

1036 height : float 

1037 The height of the cone. 

1038 sections : int or None 

1039 How many pie wedges per revolution 

1040 transform : (4, 4) float or None 

1041 Transform to apply after creation 

1042 **kwargs : dict 

1043 Passed to Trimesh constructor 

1044 

1045 Returns 

1046 ---------- 

1047 cone: trimesh.Trimesh 

1048 Resulting mesh of a cone 

1049 """ 

1050 # create the 2D outline of a cone 

1051 linestring = [[0, 0], [radius, 0], [0, height]] 

1052 # revolve the profile to create a cone 

1053 if "metadata" not in kwargs: 

1054 kwargs["metadata"] = {} 

1055 kwargs["metadata"].update({"shape": "cone", "radius": radius, "height": height}) 

1056 cone = revolve( 

1057 linestring=linestring, sections=sections, transform=transform, **kwargs 

1058 ) 

1059 

1060 return cone 

1061 

1062 

1063def cylinder( 

1064 radius: Number, 

1065 height: Number | None = None, 

1066 sections: Integer | None = None, 

1067 segment: ArrayLike | None = None, 

1068 transform: ArrayLike | None = None, 

1069 **kwargs, 

1070): 

1071 """ 

1072 Create a mesh of a cylinder along Z centered at the origin. 

1073 

1074 Parameters 

1075 ---------- 

1076 radius : float 

1077 The radius of the cylinder 

1078 height : float or None 

1079 The height of the cylinder, or None if `segment` has been passed. 

1080 sections : int or None 

1081 How many pie wedges should the cylinder have 

1082 segment : (2, 3) float 

1083 Endpoints of axis, overrides transform and height 

1084 transform : None or (4, 4) float 

1085 Transform to apply to mesh after construction 

1086 **kwargs: 

1087 passed to Trimesh to create cylinder 

1088 

1089 Returns 

1090 ---------- 

1091 cylinder: trimesh.Trimesh 

1092 Resulting mesh of a cylinder 

1093 """ 

1094 

1095 if segment is not None: 

1096 # override transform and height with the segment 

1097 transform, height = _segment_to_cylinder(segment=segment) 

1098 

1099 if height is None: 

1100 raise ValueError("either `height` or `segment` must be passed!") 

1101 

1102 half = abs(float(height)) / 2.0 

1103 # create a profile to revolve 

1104 linestring = [[0, -half], [radius, -half], [radius, half], [0, half]] 

1105 if "metadata" not in kwargs: 

1106 kwargs["metadata"] = {} 

1107 kwargs["metadata"].update({"shape": "cylinder", "height": height, "radius": radius}) 

1108 # generate cylinder through simple revolution 

1109 return revolve( 

1110 linestring=linestring, sections=sections, transform=transform, **kwargs 

1111 ) 

1112 

1113 

1114def annulus( 

1115 r_min: Number, 

1116 r_max: Number, 

1117 height: Number | None = None, 

1118 sections: Integer | None = None, 

1119 transform: ArrayLike | None = None, 

1120 segment: ArrayLike | None = None, 

1121 **kwargs, 

1122): 

1123 """ 

1124 Create a mesh of an annular cylinder along Z centered at the origin. 

1125 

1126 Parameters 

1127 ---------- 

1128 r_min : float 

1129 The inner radius of the annular cylinder 

1130 r_max : float 

1131 The outer radius of the annular cylinder 

1132 height : float 

1133 The height of the annular cylinder 

1134 sections : int or None 

1135 How many pie wedges should the annular cylinder have 

1136 transform : (4, 4) float or None 

1137 Transform to apply to move result from the origin 

1138 segment : None or (2, 3) float 

1139 Override transform and height with a line segment 

1140 **kwargs: 

1141 passed to Trimesh to create annulus 

1142 

1143 Returns 

1144 ---------- 

1145 annulus : trimesh.Trimesh 

1146 Mesh of annular cylinder 

1147 """ 

1148 if segment is not None: 

1149 # override transform and height with the segment if passed 

1150 transform, height = _segment_to_cylinder(segment=segment) 

1151 

1152 if height is None: 

1153 raise ValueError("either `height` or `segment` must be passed!") 

1154 

1155 r_min = abs(float(r_min)) 

1156 # if center radius is zero this is a cylinder 

1157 if r_min < tol.merge: 

1158 return cylinder( 

1159 radius=r_max, height=height, sections=sections, transform=transform, **kwargs 

1160 ) 

1161 r_max = abs(float(r_max)) 

1162 # we're going to center at XY plane so take half the height 

1163 half = abs(float(height)) / 2.0 

1164 # create counter-clockwise rectangle 

1165 linestring = [ 

1166 [r_min, -half], 

1167 [r_max, -half], 

1168 [r_max, half], 

1169 [r_min, half], 

1170 [r_min, -half], 

1171 ] 

1172 

1173 if "metadata" not in kwargs: 

1174 kwargs["metadata"] = {} 

1175 kwargs["metadata"].update( 

1176 {"shape": "annulus", "r_min": r_min, "r_max": r_max, "height": height} 

1177 ) 

1178 

1179 # revolve the curve 

1180 annulus = revolve( 

1181 linestring=linestring, sections=sections, transform=transform, **kwargs 

1182 ) 

1183 

1184 return annulus 

1185 

1186 

1187def _segment_to_cylinder(segment: ArrayLike): 

1188 """ 

1189 Convert a line segment to a transform and height for a cylinder 

1190 or cylinder-like primitive. 

1191 

1192 Parameters 

1193 ----------- 

1194 segment : (2, 3) float 

1195 3D line segment in space 

1196 

1197 Returns 

1198 ----------- 

1199 transform : (4, 4) float 

1200 Matrix to move a Z-extruded origin cylinder to segment 

1201 height : float 

1202 The height of the cylinder needed 

1203 """ 

1204 segment = np.asanyarray(segment, dtype=np.float64) 

1205 if segment.shape != (2, 3): 

1206 raise ValueError("segment must be 2 3D points!") 

1207 vector = segment[1] - segment[0] 

1208 # override height with segment length 

1209 height = np.linalg.norm(vector) 

1210 # point in middle of line 

1211 midpoint = segment[0] + (vector * 0.5) 

1212 # align Z with our desired direction 

1213 rotation = align_vectors([0, 0, 1], vector) 

1214 # translate to midpoint of segment 

1215 translation = tf.translation_matrix(midpoint) 

1216 # compound the rotation and translation 

1217 transform = np.dot(translation, rotation) 

1218 return transform, height 

1219 

1220 

1221def random_soup(face_count: Integer = 100): 

1222 """ 

1223 Return random triangles as a Trimesh 

1224 

1225 Parameters 

1226 ----------- 

1227 face_count : int 

1228 Number of faces desired in mesh 

1229 

1230 Returns 

1231 ----------- 

1232 soup : trimesh.Trimesh 

1233 Geometry with face_count random faces 

1234 """ 

1235 vertices = np.random.random((face_count * 3, 3)) - 0.5 

1236 faces = np.arange(face_count * 3).reshape((-1, 3)) 

1237 soup = Trimesh(vertices=vertices, faces=faces) 

1238 return soup 

1239 

1240 

1241def axis( 

1242 origin_size: Number = 0.04, 

1243 transform: ArrayLike | None = None, 

1244 origin_color: ArrayLike | None = None, 

1245 axis_radius: Number | None = None, 

1246 axis_length: Number | None = None, 

1247): 

1248 """ 

1249 Return an XYZ axis marker as a Trimesh, which represents position 

1250 and orientation. If you set the origin size the other parameters 

1251 will be set relative to it. 

1252 

1253 Parameters 

1254 ---------- 

1255 origin_size : float 

1256 Radius of sphere that represents the origin 

1257 transform : (4, 4) float 

1258 Transformation matrix 

1259 origin_color : (3,) float or int, uint8 or float 

1260 Color of the origin 

1261 axis_radius : float 

1262 Radius of cylinder that represents x, y, z axis 

1263 axis_length: float 

1264 Length of cylinder that represents x, y, z axis 

1265 

1266 Returns 

1267 ------- 

1268 marker : trimesh.Trimesh 

1269 Mesh geometry of axis indicators 

1270 """ 

1271 # the size of the ball representing the origin 

1272 origin_size = float(origin_size) 

1273 

1274 # set the transform and use origin-relative 

1275 # sized for other parameters if not specified 

1276 if transform is None: 

1277 transform = np.eye(4) 

1278 if origin_color is None: 

1279 origin_color = [255, 255, 255, 255] 

1280 if axis_radius is None: 

1281 axis_radius = origin_size / 5.0 

1282 if axis_length is None: 

1283 axis_length = origin_size * 10.0 

1284 

1285 # generate a ball for the origin 

1286 axis_origin = icosphere(radius=origin_size) 

1287 axis_origin.apply_transform(transform) 

1288 

1289 # apply color to the origin ball 

1290 axis_origin.visual.face_colors = origin_color 

1291 

1292 # create the cylinder for the z-axis 

1293 translation = tf.translation_matrix([0, 0, axis_length / 2]) 

1294 z_axis = cylinder( 

1295 radius=axis_radius, height=axis_length, transform=transform.dot(translation) 

1296 ) 

1297 # XYZ->RGB, Z is blue 

1298 z_axis.visual.face_colors = [0, 0, 255] 

1299 

1300 # create the cylinder for the y-axis 

1301 translation = tf.translation_matrix([0, 0, axis_length / 2]) 

1302 rotation = tf.rotation_matrix(np.radians(-90), [1, 0, 0]) 

1303 y_axis = cylinder( 

1304 radius=axis_radius, 

1305 height=axis_length, 

1306 transform=transform.dot(rotation).dot(translation), 

1307 ) 

1308 # XYZ->RGB, Y is green 

1309 y_axis.visual.face_colors = [0, 255, 0] 

1310 

1311 # create the cylinder for the x-axis 

1312 translation = tf.translation_matrix([0, 0, axis_length / 2]) 

1313 rotation = tf.rotation_matrix(np.radians(90), [0, 1, 0]) 

1314 x_axis = cylinder( 

1315 radius=axis_radius, 

1316 height=axis_length, 

1317 transform=transform.dot(rotation).dot(translation), 

1318 ) 

1319 # XYZ->RGB, X is red 

1320 x_axis.visual.face_colors = [255, 0, 0] 

1321 

1322 # append the sphere and three cylinders 

1323 marker = util.concatenate([axis_origin, x_axis, y_axis, z_axis]) 

1324 return marker 

1325 

1326 

1327def camera_marker(camera, marker_height: Number = 0.4, origin_size: Number | None = None): 

1328 """ 

1329 Create a visual marker for a camera object, including an axis and FOV. 

1330 

1331 Parameters 

1332 --------------- 

1333 camera : trimesh.scene.Camera 

1334 Camera object with FOV and transform defined 

1335 marker_height : float 

1336 How far along the camera Z should FOV indicators be 

1337 origin_size : float 

1338 Sphere radius of the origin (default: marker_height / 10.0) 

1339 

1340 Returns 

1341 ------------ 

1342 meshes : list 

1343 Contains Trimesh and Path3D objects which can be visualized 

1344 """ 

1345 

1346 # create sane origin size from marker height 

1347 if origin_size is None: 

1348 origin_size = marker_height / 10.0 

1349 

1350 # append the visualizations to an array 

1351 meshes = [axis(origin_size=origin_size)] 

1352 

1353 try: 

1354 # path is a soft dependency 

1355 from .path.exchange.load import load_path 

1356 except ImportError: 

1357 # they probably don't have shapely installed 

1358 log.warning("unable to create FOV visualization!", exc_info=True) 

1359 return meshes 

1360 

1361 # calculate vertices from camera FOV angles 

1362 x = marker_height * np.tan(np.deg2rad(camera.fov[0]) / 2.0) 

1363 y = marker_height * np.tan(np.deg2rad(camera.fov[1]) / 2.0) 

1364 z = marker_height 

1365 

1366 # combine the points into the vertices of an FOV visualization 

1367 points = np.array( 

1368 [(0, 0, 0), (-x, -y, -z), (x, -y, -z), (x, y, -z), (-x, y, -z)], dtype=float 

1369 ) 

1370 

1371 # create line segments for the FOV visualization 

1372 # a segment from the origin to each bound of the FOV 

1373 segments = np.column_stack((np.zeros_like(points), points)).reshape((-1, 3)) 

1374 

1375 # add a loop for the outside of the FOV then reshape 

1376 # the whole thing into multiple line segments 

1377 segments = np.vstack((segments, points[[1, 2, 2, 3, 3, 4, 4, 1]])).reshape((-1, 2, 3)) 

1378 

1379 # add a single Path3D object for all line segments 

1380 meshes.append(load_path(segments)) 

1381 

1382 return meshes 

1383 

1384 

1385def truncated_prisms( 

1386 tris: ArrayLike, 

1387 origin: ArrayLike | None = None, 

1388 normal: ArrayLike | None = None, 

1389): 

1390 """ 

1391 Return a mesh consisting of multiple watertight prisms below 

1392 a list of triangles, truncated by a specified plane. 

1393 

1394 Parameters 

1395 ------------- 

1396 triangles : (n, 3, 3) float 

1397 Triangles in space 

1398 origin : None or (3,) float 

1399 Origin of truncation plane 

1400 normal : None or (3,) float 

1401 Unit normal vector of truncation plane 

1402 

1403 Returns 

1404 ----------- 

1405 mesh : trimesh.Trimesh 

1406 Triangular mesh 

1407 """ 

1408 if origin is None: 

1409 transform = np.eye(4) 

1410 else: 

1411 transform = plane_transform(origin=origin, normal=normal) 

1412 

1413 # transform the triangles to the specified plane 

1414 transformed = tf.transform_points(tris.reshape((-1, 3)), transform).reshape((-1, 9)) 

1415 

1416 # stack triangles such that every other one is repeated 

1417 vs = np.column_stack((transformed, transformed)).reshape((-1, 3, 3)) 

1418 # set the Z value of the second triangle to zero 

1419 vs[1::2, :, 2] = 0 

1420 # reshape triangles to a flat array of points and transform back to 

1421 # original frame 

1422 vertices = tf.transform_points(vs.reshape((-1, 3)), matrix=np.linalg.inv(transform)) 

1423 

1424 # face indexes for a *single* truncated triangular prism 

1425 f = np.array( 

1426 [ 

1427 [2, 1, 0], 

1428 [3, 4, 5], 

1429 [0, 1, 4], 

1430 [1, 2, 5], 

1431 [2, 0, 3], 

1432 [4, 3, 0], 

1433 [5, 4, 1], 

1434 [3, 5, 2], 

1435 ] 

1436 ) 

1437 # find the projection of each triangle with the normal vector 

1438 cross = np.dot([0, 0, 1], triangles.cross(transformed.reshape((-1, 3, 3))).T) 

1439 # stack faces into one prism per triangle 

1440 f_seq = np.tile(f, (len(transformed), 1)).reshape((-1, len(f), 3)) 

1441 # if the normal of the triangle was positive flip the winding 

1442 f_seq[cross > 0] = np.fliplr(f) 

1443 # offset stacked faces to create correct indices 

1444 faces = (f_seq + (np.arange(len(f_seq)) * 6).reshape((-1, 1, 1))).reshape((-1, 3)) 

1445 

1446 # create a mesh from the data 

1447 mesh = Trimesh(vertices=vertices, faces=faces, process=False) 

1448 

1449 return mesh 

1450 

1451 

1452def torus( 

1453 major_radius: Number, 

1454 minor_radius: Number, 

1455 major_sections: Integer = 32, 

1456 minor_sections: Integer = 32, 

1457 transform: ArrayLike | None = None, 

1458 **kwargs, 

1459): 

1460 """Create a mesh of a torus around Z centered at the origin. 

1461 

1462 Parameters 

1463 ------------ 

1464 major_radius: (float) 

1465 Radius from the center of the torus to the center of the tube. 

1466 minor_radius: (float) 

1467 Radius of the tube. 

1468 major_sections: int 

1469 Number of sections around major radius result should have 

1470 If not specified default is 32 per revolution 

1471 minor_sections: int 

1472 Number of sections around minor radius result should have 

1473 If not specified default is 32 per revolution 

1474 transform : (4, 4) float 

1475 Transformation matrix 

1476 

1477 **kwargs: 

1478 passed to Trimesh to create torus 

1479 

1480 Returns 

1481 ------------ 

1482 geometry : trimesh.Trimesh 

1483 Mesh of a torus 

1484 """ 

1485 phi = np.linspace(0, 2 * np.pi, minor_sections + 1, endpoint=True) 

1486 linestring = np.column_stack( 

1487 (minor_radius * np.cos(phi), minor_radius * np.sin(phi)) 

1488 ) + [major_radius, 0] 

1489 

1490 if "metadata" not in kwargs: 

1491 kwargs["metadata"] = {} 

1492 kwargs["metadata"].update( 

1493 {"shape": "torus", "major_radius": major_radius, "minor_radius": minor_radius} 

1494 ) 

1495 

1496 # generate torus through simple revolution 

1497 return revolve( 

1498 linestring=linestring, sections=major_sections, transform=transform, **kwargs 

1499 )