Coverage for trimesh/creation.py: 84%

436 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-31 23:55 +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, Seed 

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 # a reflecting transform flips winding so flip the faces 

189 # back to keep normals outward, #2439 

190 if tf.flips_winding(transform): 

191 # fliplr makes arrays non-contiguous so re-pack them 

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

193 

194 # create the mesh from our vertices and faces 

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

196 

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

198 if tol.strict and ( 

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

200 ): 

201 if closed or cap: 

202 # if revolved curve starts and ends with zero radius 

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

204 # reversed on the input linestring 

205 assert mesh.is_volume 

206 assert mesh.body_count == 1 

207 

208 return mesh 

209 

210 

211def extrude_polygon( 

212 polygon: "Polygon", 

213 height: Number, 

214 transform: ArrayLike | None = None, 

215 mid_plane: bool = False, 

216 **kwargs, 

217) -> Trimesh: 

218 """ 

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

220 

221 Parameters 

222 ---------- 

223 polygon : shapely.geometry.Polygon 

224 2D geometry to extrude 

225 height : float 

226 Distance to extrude polygon along Z 

227 transform : None or (4, 4) float 

228 Transform to apply to mesh after construction 

229 triangle_args : str or None 

230 Passed to triangle 

231 **kwargs : dict 

232 Passed to `triangulate_polygon` 

233 

234 Returns 

235 ---------- 

236 mesh : trimesh.Trimesh 

237 Resulting extrusion as watertight body 

238 """ 

239 # create a triangulation from the polygon 

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

241 

242 if mid_plane: 

243 translation = np.eye(4) 

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

245 if transform is None: 

246 transform = translation 

247 else: 

248 transform = np.dot(transform, translation) 

249 

250 # extrude that triangulation along Z 

251 mesh = extrude_triangulation( 

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

253 ) 

254 return mesh 

255 

256 

257def sweep_polygon( 

258 polygon: "Polygon", 

259 path: ArrayLike, 

260 angles: ArrayLike | None = None, 

261 cap: bool = True, 

262 connect: bool = True, 

263 kwargs: dict | None = None, 

264 **triangulation, 

265) -> Trimesh: 

266 """ 

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

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

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

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

271 

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

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

274 

275 Parameters 

276 ---------- 

277 polygon : shapely.geometry.Polygon 

278 Profile to sweep along path 

279 path : (n, 3) float 

280 A path in 3D 

281 angles : (n,) float 

282 Optional rotation angle relative to prior vertex 

283 at each vertex. 

284 cap 

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

286 connect 

287 If a closed path is passed connect the sweep into 

288 a single watertight mesh. 

289 kwargs : dict 

290 Passed to the mesh constructor. 

291 **triangulation 

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

293 

294 Returns 

295 ------- 

296 mesh : trimesh.Trimesh 

297 Geometry of result 

298 """ 

299 

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

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

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

303 

304 if angles is not None: 

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

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

307 raise ValueError(angles.shape) 

308 else: 

309 # set all angles to zero 

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

311 

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

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

314 # Extract 2D vertices and triangulation 

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

316 

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

318 edges = faces_to_edges(faces_2D) 

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

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

321 # shapely polygon, we need to find it again 

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

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

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

325 # take only the vertices in the boundary 

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

327 # products to transform them all over the place 

328 vertices_tf = np.column_stack( 

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

330 ) 

331 # the indices of vertices_tf 

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

333 

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

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

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

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

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

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

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

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

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

343 # collect the vectors into plane normals 

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

345 

346 if closed and connect: 

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

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

349 

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

351 assert normal.shape == path.shape 

352 

353 # get the spherical coordinates for the normal vectors 

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

355 

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

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

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

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

360 

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

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

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

364 # matrix = ( 

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

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

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

368 # ).inv() 

369 # matrix.simplify() 

370 

371 # shorthand for stacking 

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

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

374 

375 # stack initially as one unrolled matrix per row 

376 transforms = np.column_stack( 

377 [ 

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

379 sin_roll * sin_theta + cos_phi * cos_roll * cos_theta, 

380 sin_phi * cos_theta, 

381 path[:, 0], 

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

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

384 sin_phi * sin_theta, 

385 path[:, 1], 

386 sin_phi * sin_roll, 

387 -sin_phi * cos_roll, 

388 cos_phi, 

389 path[:, 2], 

390 zeros, 

391 zeros, 

392 zeros, 

393 ones, 

394 ] 

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

396 

397 if tol.strict: 

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

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

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

401 assert np.allclose(check, n) 

402 

403 # apply transforms to prebaked homogeneous coordinates 

404 vertices_3D = np.concatenate( 

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

406 )[:, :3] 

407 

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

409 stride = len(unique) 

410 boundary_next = boundary + stride 

411 faces_slice = np.column_stack( 

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

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

414 

415 # offset the slices 

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

417 

418 # connect only applies to closed paths 

419 if closed and connect: 

420 # the last slice will not be required 

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

422 # clip off the duplicated vertices 

423 vertices_3D = vertices_3D[:max_vertex] 

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

425 faces[-1] %= max_vertex 

426 elif cap: 

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

428 # which can happen for triangulation algorithms that added vertices 

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

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

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

432 if len(unconsumed) > 0: 

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

434 

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

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

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

438 

439 # now should correspond to the first vertex block 

440 cap_zero = mapped[faces_2D] 

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

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

443 # offset the end cap 

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

445 

446 if kwargs is None: 

447 kwargs = {} 

448 

449 if "process" not in kwargs: 

450 # we should be constructing clean meshes here 

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

452 kwargs["process"] = False 

453 

454 # stack the faces used 

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

456 

457 # generate the mesh from the face data 

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

459 

460 if tol.strict: 

461 # we should not have included any unused vertices 

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

463 

464 if cap: 

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

466 assert mesh.is_volume 

467 

468 if closed and connect: 

469 assert mesh.is_volume 

470 assert mesh.body_count == 1 

471 

472 return mesh 

473 

474 

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

476 """ 

477 Numpy 2.0 depreciated cross products of 2D arrays. 

478 """ 

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

480 

481 

482def extrude_triangulation( 

483 vertices: ArrayLike, 

484 faces: ArrayLike, 

485 height: Number, 

486 transform: ArrayLike | None = None, 

487 **kwargs, 

488) -> Trimesh: 

489 """ 

490 Extrude a 2D triangulation into a watertight mesh. 

491 

492 Parameters 

493 ---------- 

494 vertices : (n, 2) float 

495 2D vertices 

496 faces : (m, 3) int 

497 Triangle indexes of vertices 

498 height : float 

499 Distance to extrude triangulation 

500 transform : None or (4, 4) float 

501 Transform to apply to mesh after construction 

502 **kwargs : dict 

503 Passed to Trimesh constructor 

504 

505 Returns 

506 --------- 

507 mesh : trimesh.Trimesh 

508 Mesh created from extrusion 

509 """ 

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

511 height = float(height) 

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

513 

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

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

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

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

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

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

520 

521 # check the winding of the first few triangles 

522 signs = _cross_2d( 

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

524 ) 

525 

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

527 # the height we've been passed 

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

529 faces = np.fliplr(faces) 

530 

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

532 edges = faces_to_edges(faces) 

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

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

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

536 # shapely polygon, we need to find it again 

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

538 

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

540 boundary = vertices[edges[edges_unique]] 

541 

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

543 # on the boundary of the 2D triangulation 

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

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

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

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

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

549 

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

551 vertices_3D = util.stack_3D(vertices) 

552 

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

554 # with offsets to create the final mesh 

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

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

557 

558 # append sequences into flat nicely indexed arrays 

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

560 if transform is not None: 

561 # apply transform here to avoid later bookkeeping 

562 vertices = tf.transform_points(vertices, transform) 

563 # if the transform flips the winding flip faces back 

564 # so that the normals will be facing outwards 

565 if tf.flips_winding(transform): 

566 # fliplr makes arrays non-contiguous 

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

568 # create mesh object with passed keywords 

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

570 # only check in strict mode (unit tests) 

571 if tol.strict: 

572 assert mesh.volume > 0.0 

573 

574 return mesh 

575 

576 

577def triangulate_polygon( 

578 polygon, 

579 triangle_args: str | None = None, 

580 engine: str | None = None, 

581 force_vertices: bool = False, 

582 **kwargs, 

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

584 """ 

585 Given a shapely polygon create a triangulation using a 

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

587 or the more robust `triangle.c`. 

588 > pip install manifold3d 

589 > pip install triangle 

590 > pip install mapbox_earcut 

591 

592 Parameters 

593 --------- 

594 polygon : Shapely.geometry.Polygon 

595 Polygon object to be triangulated. 

596 triangle_args 

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

598 engine 

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

600 force_vertices 

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

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

603 ValueError if it is unable to do so. 

604 

605 Returns 

606 -------------- 

607 vertices : (n, 2) float 

608 Points in space 

609 faces : (n, 3) int 

610 Index of vertices that make up triangles 

611 """ 

612 

613 if engine is None: 

614 # try getting the first engine that is installed 

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

616 

617 if polygon is None or polygon.is_empty: 

618 return [], [] 

619 

620 vertices = None 

621 

622 if engine == "earcut": 

623 from mapbox_earcut import triangulate_float64 

624 

625 # get vertices as sequence where exterior 

626 # is the first value 

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

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

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

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

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

632 vertices = np.vstack(vertices) 

633 # run triangulation 

634 faces = ( 

635 triangulate_float64(vertices, rings) 

636 .reshape((-1, 3)) 

637 .astype(np.int64) 

638 .reshape((-1, 3)) 

639 ) 

640 

641 elif engine == "manifold": 

642 import manifold3d 

643 

644 # the outer ring is wound counter-clockwise 

645 rings = [ 

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

647 :-1 

648 ] 

649 ] 

650 # wind interiors 

651 rings.extend( 

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

653 ) 

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

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

656 

657 elif engine == "triangle": 

658 from triangle import triangulate 

659 

660 # set default triangulation arguments if not specified 

661 if triangle_args is None: 

662 triangle_args = "p" 

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

664 arg = _polygon_to_kwargs(polygon) 

665 # run the triangulation 

666 blob = triangulate(arg, triangle_args) 

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

668 

669 # triangle may insert vertices 

670 if force_vertices: 

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

672 

673 if vertices is None: 

674 log.warning( 

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

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

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

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

679 ) 

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

681 

682 return vertices, faces 

683 

684 

685def _polygon_to_kwargs(polygon) -> dict: 

686 """ 

687 Given a shapely polygon generate the data to pass to 

688 the triangle mesh generator 

689 

690 Parameters 

691 --------- 

692 polygon : Shapely.geometry.Polygon 

693 Input geometry 

694 

695 Returns 

696 -------- 

697 result : dict 

698 Has keys: vertices, segments, holes 

699 """ 

700 

701 if not polygon.is_valid: 

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

703 

704 def round_trip(start, length): 

705 """ 

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

707 create a closed traversal. 

708 

709 Examples 

710 --------- 

711 start, length = 0, 3 

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

713 """ 

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

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

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

717 return tiled 

718 

719 def add_boundary(boundary, start): 

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

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

722 # guarantees on points not being duplicated (which will 

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

724 coords = np.array(boundary.coords) 

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

726 # to maintain order 

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

728 cleaned = coords[unique] 

729 

730 vertices.append(cleaned) 

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

732 

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

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

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

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

737 # slower. 

738 test = Polygon(cleaned) 

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

740 

741 return len(cleaned) 

742 

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

744 vertices = collections.deque() 

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

746 facets = collections.deque() 

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

748 holes = collections.deque() 

749 

750 start = add_boundary(polygon.exterior, 0) 

751 for interior in polygon.interiors: 

752 try: 

753 start += add_boundary(interior, start) 

754 except BaseException: 

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

756 continue 

757 

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

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

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

761 vertices = np.vstack(vertices) 

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

763 # shapely polygons can include a Z component 

764 # strip it out for the triangulation 

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

766 vertices = vertices[:, :2] 

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

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

769 # which are inside the region of the hole 

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

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

772 if len(holes) > 0: 

773 result["holes"] = holes 

774 return result 

775 

776 

777def box( 

778 extents: ArrayLike | None = None, 

779 transform: ArrayLike | None = None, 

780 bounds: ArrayLike | None = None, 

781 **kwargs, 

782): 

783 """ 

784 Return a cuboid. 

785 

786 Parameters 

787 ------------ 

788 extents : (3,) float 

789 Edge lengths 

790 transform: (4, 4) float 

791 Transformation matrix 

792 bounds : None or (2, 3) float 

793 Corners of AABB, overrides extents and transform. 

794 **kwargs: 

795 passed to Trimesh to create box 

796 

797 Returns 

798 ------------ 

799 geometry : trimesh.Trimesh 

800 Mesh of a cuboid 

801 """ 

802 # vertices of the cube from reference 

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

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

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

806 

807 # resize cube based on passed extents 

808 if bounds is not None: 

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

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

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

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

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

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

815 vertices *= extents 

816 vertices += bounds[0] 

817 elif extents is not None: 

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

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

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

821 vertices -= 0.5 

822 vertices *= extents 

823 else: 

824 vertices -= 0.5 

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

826 

827 if "metadata" not in kwargs: 

828 kwargs["metadata"] = {} 

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

830 

831 box = Trimesh( 

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

833 ) 

834 

835 # do the transform here to preserve face normals 

836 if transform is not None: 

837 box.apply_transform(transform) 

838 

839 return box 

840 

841 

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

843 """ 

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

845 

846 Parameters 

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

848 kwargs : dict 

849 Passed through to `Trimesh` constructor. 

850 

851 Returns 

852 ------------- 

853 ico : trimesh.Trimesh 

854 Icosahederon centered at the origin. 

855 """ 

856 # get stored pre-baked primitive values 

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

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

859 return Trimesh( 

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

861 ) 

862 

863 

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

865 """ 

866 Create an icosphere centered at the origin. 

867 

868 Parameters 

869 ---------- 

870 subdivisions : int 

871 How many times to subdivide the mesh. 

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

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

874 radius : float 

875 Desired radius of sphere 

876 kwargs : dict 

877 Passed through to `Trimesh` constructor. 

878 

879 Returns 

880 --------- 

881 ico : trimesh.Trimesh 

882 Meshed sphere 

883 """ 

884 radius = float(radius) 

885 subdivisions = int(subdivisions) 

886 

887 ico = icosahedron() 

888 ico._validate = False 

889 

890 for _ in range(subdivisions): 

891 ico = ico.subdivide() 

892 vectors = ico.vertices 

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

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

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

896 

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

898 if subdivisions <= 0: 

899 vectors = ico.vertices 

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

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

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

903 

904 if "color" in kwargs: 

905 warnings.warn( 

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

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

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

909 category=DeprecationWarning, 

910 stacklevel=2, 

911 ) 

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

913 

914 return Trimesh( 

915 vertices=ico.vertices, 

916 faces=ico.faces, 

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

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

919 **kwargs, 

920 ) 

921 

922 

923def uv_sphere( 

924 radius: Number = 1.0, 

925 count: ArrayLike | None = None, 

926 transform: ArrayLike | None = None, 

927 **kwargs, 

928) -> Trimesh: 

929 """ 

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

931 origin. Roughly one order of magnitude faster than an 

932 icosphere but slightly uglier. 

933 

934 Parameters 

935 ---------- 

936 radius : float 

937 Radius of sphere 

938 count : (2,) int 

939 Number of latitude and longitude lines 

940 transform : None or (4, 4) float 

941 Transform to apply to mesh after construction 

942 kwargs : dict 

943 Passed thgrough 

944 Returns 

945 ---------- 

946 mesh : trimesh.Trimesh 

947 Mesh of UV sphere with specified parameters 

948 """ 

949 

950 # set the resolution of the uv sphere 

951 if count is None: 

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

953 else: 

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

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

956 count[1] *= 2 

957 

958 # generate the 2D curve for the UV sphere 

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

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

961 

962 # revolve the curve to create a volume 

963 return revolve( 

964 linestring=linestring, 

965 sections=count[1], 

966 transform=transform, 

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

968 **kwargs, 

969 ) 

970 

971 

972def capsule( 

973 height: Number = 1.0, 

974 radius: Number = 1.0, 

975 count: ArrayLike | None = None, 

976 transform: ArrayLike | None = None, 

977 **kwargs, 

978) -> Trimesh: 

979 """ 

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

981 

982 Parameters 

983 ---------- 

984 height : float 

985 Center to center distance of two spheres 

986 radius : float 

987 Radius of the cylinder and hemispheres 

988 count : (2,) int 

989 Number of sections on latitude and longitude 

990 transform : None or (4, 4) float 

991 Transform to apply to mesh after construction 

992 Returns 

993 ---------- 

994 capsule : trimesh.Trimesh 

995 Capsule geometry with: 

996 - cylinder axis is along Z 

997 - one hemisphere is centered at the origin 

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

999 """ 

1000 if count is None: 

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

1002 else: 

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

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

1005 

1006 height = abs(float(height)) 

1007 radius = abs(float(radius)) 

1008 

1009 # two quarter circles sharing an equator vertex 

1010 # each hemisphere reaches full radius symmetrically 

1011 theta = np.concatenate( 

1012 ( 

1013 np.linspace(-np.pi / 2.0, 0.0, count[0] // 2 + 1), 

1014 np.linspace(0.0, np.pi / 2.0, count[0] // 2 + 1), 

1015 ) 

1016 ) 

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

1018 

1019 # offset the top and bottom by half the height 

1020 half = len(linestring) // 2 

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

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

1023 

1024 return revolve( 

1025 linestring, 

1026 sections=count[1], 

1027 transform=transform, 

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

1029 **kwargs, 

1030 ) 

1031 

1032 

1033def cone( 

1034 radius: Number, 

1035 height: Number, 

1036 sections: Integer | None = None, 

1037 transform: ArrayLike | None = None, 

1038 **kwargs, 

1039) -> Trimesh: 

1040 """ 

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

1042 

1043 Parameters 

1044 ---------- 

1045 radius : float 

1046 The radius of the cone at the widest part. 

1047 height : float 

1048 The height of the cone. 

1049 sections : int or None 

1050 How many pie wedges per revolution 

1051 transform : (4, 4) float or None 

1052 Transform to apply after creation 

1053 **kwargs : dict 

1054 Passed to Trimesh constructor 

1055 

1056 Returns 

1057 ---------- 

1058 cone: trimesh.Trimesh 

1059 Resulting mesh of a cone 

1060 """ 

1061 # create the 2D outline of a cone 

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

1063 # revolve the profile to create a cone 

1064 if "metadata" not in kwargs: 

1065 kwargs["metadata"] = {} 

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

1067 cone = revolve( 

1068 linestring=linestring, sections=sections, transform=transform, **kwargs 

1069 ) 

1070 

1071 return cone 

1072 

1073 

1074def cylinder( 

1075 radius: Number, 

1076 height: Number | None = None, 

1077 sections: Integer | None = None, 

1078 segment: ArrayLike | None = None, 

1079 transform: ArrayLike | None = None, 

1080 **kwargs, 

1081): 

1082 """ 

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

1084 

1085 Parameters 

1086 ---------- 

1087 radius : float 

1088 The radius of the cylinder 

1089 height : float or None 

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

1091 sections : int or None 

1092 How many pie wedges should the cylinder have 

1093 segment : (2, 3) float 

1094 Endpoints of axis, overrides transform and height 

1095 transform : None or (4, 4) float 

1096 Transform to apply to mesh after construction 

1097 **kwargs: 

1098 passed to Trimesh to create cylinder 

1099 

1100 Returns 

1101 ---------- 

1102 cylinder: trimesh.Trimesh 

1103 Resulting mesh of a cylinder 

1104 """ 

1105 

1106 if segment is not None: 

1107 # override transform and height with the segment 

1108 transform, height = _segment_to_cylinder(segment=segment) 

1109 

1110 if height is None: 

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

1112 

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

1114 # create a profile to revolve 

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

1116 if "metadata" not in kwargs: 

1117 kwargs["metadata"] = {} 

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

1119 # generate cylinder through simple revolution 

1120 return revolve( 

1121 linestring=linestring, sections=sections, transform=transform, **kwargs 

1122 ) 

1123 

1124 

1125def annulus( 

1126 r_min: Number, 

1127 r_max: Number, 

1128 height: Number | None = None, 

1129 sections: Integer | None = None, 

1130 transform: ArrayLike | None = None, 

1131 segment: ArrayLike | None = None, 

1132 **kwargs, 

1133): 

1134 """ 

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

1136 

1137 Parameters 

1138 ---------- 

1139 r_min : float 

1140 The inner radius of the annular cylinder 

1141 r_max : float 

1142 The outer radius of the annular cylinder 

1143 height : float 

1144 The height of the annular cylinder 

1145 sections : int or None 

1146 How many pie wedges should the annular cylinder have 

1147 transform : (4, 4) float or None 

1148 Transform to apply to move result from the origin 

1149 segment : None or (2, 3) float 

1150 Override transform and height with a line segment 

1151 **kwargs: 

1152 passed to Trimesh to create annulus 

1153 

1154 Returns 

1155 ---------- 

1156 annulus : trimesh.Trimesh 

1157 Mesh of annular cylinder 

1158 """ 

1159 if segment is not None: 

1160 # override transform and height with the segment if passed 

1161 transform, height = _segment_to_cylinder(segment=segment) 

1162 

1163 if height is None: 

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

1165 

1166 r_min = abs(float(r_min)) 

1167 # if center radius is zero this is a cylinder 

1168 if r_min < tol.merge: 

1169 return cylinder( 

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

1171 ) 

1172 r_max = abs(float(r_max)) 

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

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

1175 # create counter-clockwise rectangle 

1176 linestring = [ 

1177 [r_min, -half], 

1178 [r_max, -half], 

1179 [r_max, half], 

1180 [r_min, half], 

1181 [r_min, -half], 

1182 ] 

1183 

1184 if "metadata" not in kwargs: 

1185 kwargs["metadata"] = {} 

1186 kwargs["metadata"].update( 

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

1188 ) 

1189 

1190 # revolve the curve 

1191 annulus = revolve( 

1192 linestring=linestring, sections=sections, transform=transform, **kwargs 

1193 ) 

1194 

1195 return annulus 

1196 

1197 

1198def _segment_to_cylinder(segment: ArrayLike): 

1199 """ 

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

1201 or cylinder-like primitive. 

1202 

1203 Parameters 

1204 ----------- 

1205 segment : (2, 3) float 

1206 3D line segment in space 

1207 

1208 Returns 

1209 ----------- 

1210 transform : (4, 4) float 

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

1212 height : float 

1213 The height of the cylinder needed 

1214 """ 

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

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

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

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

1219 # override height with segment length 

1220 height = np.linalg.norm(vector) 

1221 # point in middle of line 

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

1223 # align Z with our desired direction 

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

1225 # translate to midpoint of segment 

1226 translation = tf.translation_matrix(midpoint) 

1227 # compound the rotation and translation 

1228 transform = np.dot(translation, rotation) 

1229 return transform, height 

1230 

1231 

1232def random_soup(face_count: Integer = 100, seed: Seed = None): 

1233 """ 

1234 Return random triangles as a Trimesh 

1235 

1236 Parameters 

1237 ----------- 

1238 face_count : int 

1239 Number of faces desired in mesh 

1240 seed : None or int 

1241 Seed for deterministic results, otherwise OS entropy. 

1242 

1243 Returns 

1244 ----------- 

1245 soup : trimesh.Trimesh 

1246 Geometry with face_count random faces 

1247 """ 

1248 vertices = util.random_generator(seed).random((face_count * 3, 3)) - 0.5 

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

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

1251 return soup 

1252 

1253 

1254def axis( 

1255 origin_size: Number = 0.04, 

1256 transform: ArrayLike | None = None, 

1257 origin_color: ArrayLike | None = None, 

1258 axis_radius: Number | None = None, 

1259 axis_length: Number | None = None, 

1260): 

1261 """ 

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

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

1264 will be set relative to it. 

1265 

1266 Parameters 

1267 ---------- 

1268 origin_size : float 

1269 Radius of sphere that represents the origin 

1270 transform : (4, 4) float 

1271 Transformation matrix 

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

1273 Color of the origin 

1274 axis_radius : float 

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

1276 axis_length: float 

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

1278 

1279 Returns 

1280 ------- 

1281 marker : trimesh.Trimesh 

1282 Mesh geometry of axis indicators 

1283 """ 

1284 # the size of the ball representing the origin 

1285 origin_size = float(origin_size) 

1286 

1287 # set the transform and use origin-relative 

1288 # sized for other parameters if not specified 

1289 if transform is None: 

1290 transform = np.eye(4) 

1291 if origin_color is None: 

1292 origin_color = [255, 255, 255, 255] 

1293 if axis_radius is None: 

1294 axis_radius = origin_size / 5.0 

1295 if axis_length is None: 

1296 axis_length = origin_size * 10.0 

1297 

1298 # generate a ball for the origin 

1299 axis_origin = icosphere(radius=origin_size) 

1300 axis_origin.apply_transform(transform) 

1301 

1302 # apply color to the origin ball 

1303 axis_origin.visual.face_colors = origin_color 

1304 

1305 # create the cylinder for the z-axis 

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

1307 z_axis = cylinder( 

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

1309 ) 

1310 # XYZ->RGB, Z is blue 

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

1312 

1313 # create the cylinder for the y-axis 

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

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

1316 y_axis = cylinder( 

1317 radius=axis_radius, 

1318 height=axis_length, 

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

1320 ) 

1321 # XYZ->RGB, Y is green 

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

1323 

1324 # create the cylinder for the x-axis 

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

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

1327 x_axis = cylinder( 

1328 radius=axis_radius, 

1329 height=axis_length, 

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

1331 ) 

1332 # XYZ->RGB, X is red 

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

1334 

1335 # append the sphere and three cylinders 

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

1337 return marker 

1338 

1339 

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

1341 """ 

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

1343 

1344 Parameters 

1345 --------------- 

1346 camera : trimesh.scene.Camera 

1347 Camera object with FOV and transform defined 

1348 marker_height : float 

1349 How far along the camera Z should FOV indicators be 

1350 origin_size : float 

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

1352 

1353 Returns 

1354 ------------ 

1355 meshes : list 

1356 Contains Trimesh and Path3D objects which can be visualized 

1357 """ 

1358 

1359 # create sane origin size from marker height 

1360 if origin_size is None: 

1361 origin_size = marker_height / 10.0 

1362 

1363 # append the visualizations to an array 

1364 meshes = [axis(origin_size=origin_size)] 

1365 

1366 try: 

1367 # path is a soft dependency 

1368 from .path.exchange.load import load_path 

1369 except ImportError: 

1370 # they probably don't have shapely installed 

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

1372 return meshes 

1373 

1374 # calculate vertices from camera FOV angles 

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

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

1377 z = marker_height 

1378 

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

1380 points = np.array( 

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

1382 ) 

1383 

1384 # create line segments for the FOV visualization 

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

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

1387 

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

1389 # the whole thing into multiple line segments 

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

1391 

1392 # add a single Path3D object for all line segments 

1393 meshes.append(load_path(segments)) 

1394 

1395 return meshes 

1396 

1397 

1398def truncated_prisms( 

1399 tris: ArrayLike, 

1400 origin: ArrayLike | None = None, 

1401 normal: ArrayLike | None = None, 

1402): 

1403 """ 

1404 Return a mesh consisting of multiple watertight prisms below 

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

1406 

1407 Parameters 

1408 ------------- 

1409 triangles : (n, 3, 3) float 

1410 Triangles in space 

1411 origin : None or (3,) float 

1412 Origin of truncation plane 

1413 normal : None or (3,) float 

1414 Unit normal vector of truncation plane 

1415 

1416 Returns 

1417 ----------- 

1418 mesh : trimesh.Trimesh 

1419 Triangular mesh 

1420 """ 

1421 if origin is None: 

1422 transform = np.eye(4) 

1423 else: 

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

1425 

1426 # transform the triangles to the specified plane 

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

1428 

1429 # stack triangles such that every other one is repeated 

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

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

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

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

1434 # original frame 

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

1436 

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

1438 f = np.array( 

1439 [ 

1440 [2, 1, 0], 

1441 [3, 4, 5], 

1442 [0, 1, 4], 

1443 [1, 2, 5], 

1444 [2, 0, 3], 

1445 [4, 3, 0], 

1446 [5, 4, 1], 

1447 [3, 5, 2], 

1448 ] 

1449 ) 

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

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

1452 # stack faces into one prism per triangle 

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

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

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

1456 # offset stacked faces to create correct indices 

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

1458 

1459 # create a mesh from the data 

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

1461 

1462 return mesh 

1463 

1464 

1465def torus( 

1466 major_radius: Number, 

1467 minor_radius: Number, 

1468 major_sections: Integer = 32, 

1469 minor_sections: Integer = 32, 

1470 transform: ArrayLike | None = None, 

1471 **kwargs, 

1472): 

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

1474 

1475 Parameters 

1476 ------------ 

1477 major_radius: (float) 

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

1479 minor_radius: (float) 

1480 Radius of the tube. 

1481 major_sections: int 

1482 Number of sections around major radius result should have 

1483 If not specified default is 32 per revolution 

1484 minor_sections: int 

1485 Number of sections around minor radius result should have 

1486 If not specified default is 32 per revolution 

1487 transform : (4, 4) float 

1488 Transformation matrix 

1489 

1490 **kwargs: 

1491 passed to Trimesh to create torus 

1492 

1493 Returns 

1494 ------------ 

1495 geometry : trimesh.Trimesh 

1496 Mesh of a torus 

1497 """ 

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

1499 linestring = np.column_stack( 

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

1501 ) + [major_radius, 0] 

1502 

1503 if "metadata" not in kwargs: 

1504 kwargs["metadata"] = {} 

1505 kwargs["metadata"].update( 

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

1507 ) 

1508 

1509 # generate torus through simple revolution 

1510 return revolve( 

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

1512 )