Coverage for trimesh/creation.py: 84%
436 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
1"""
2creation.py
3--------------
5Create meshes from primitives, or with operations.
6"""
8import collections
9import warnings
11import numpy as np
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
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)
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]
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.
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.
58 Note that if your linestring is closed, it needs to be counterclockwise
59 if you would like face winding and normals facing outwards.
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
79 Returns
80 --------------
81 revolved : Trimesh
82 Mesh representing revolved result
83 """
84 linestring = np.asanyarray(linestring, dtype=np.float64)
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!")
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)
98 if sections is None:
99 # default to 32 sections for a full revolution
100 sections = int(angle / (np.pi * 2) * 32)
102 # change to face count
103 sections += 1
104 # create equally spaced angles
105 theta = np.linspace(0, angle, sections)
107 # 2D points around the revolution
108 points = np.column_stack((np.cos(theta), np.sin(theta)))
110 # how many points per slice
111 per = len(linestring)
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 )
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)
131 # chop off duplicate vertices
132 vertices = vertices[:-per]
134 # how many slices of the pie
135 slices = len(theta) - 1
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]
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))
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)
159 # offset stacked and wrap vertices
160 faces = (stacked + offset) % len(vertices)
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 )
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 )
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
182 # Append cap faces to the face array
183 faces = np.vstack([faces, cap_0_faces, flipped_cap_angle_faces])
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))
194 # create the mesh from our vertices and faces
195 mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
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
208 return mesh
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
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`
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)
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)
250 # extrude that triangulation along Z
251 mesh = extrude_triangulation(
252 vertices=vertices, faces=faces, height=height, transform=transform, **kwargs
253 )
254 return mesh
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.
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)`
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'`
294 Returns
295 -------
296 mesh : trimesh.Trimesh
297 Geometry of result
298 """
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)!")
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)
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)
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))
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)
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))
350 # planes should have one unit normal and one vertex each
351 assert normal.shape == path.shape
353 # get the spherical coordinates for the normal vectors
354 theta, phi = util.vector_to_spherical(normal).T
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)
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()
371 # shorthand for stacking
372 zeros = np.zeros(len(theta))
373 ones = np.ones(len(theta))
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))
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)
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]
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))
415 # offset the slices
416 faces = [faces_slice + offset for offset in np.arange(len(path) - 1) * stride]
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!")
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))
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))
446 if kwargs is None:
447 kwargs = {}
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
454 # stack the faces used
455 faces = np.concatenate(faces, axis=0)
457 # generate the mesh from the face data
458 mesh = Trimesh(vertices=vertices_3D, faces=faces, **kwargs)
460 if tol.strict:
461 # we should not have included any unused vertices
462 assert len(np.unique(faces)) == len(vertices_3D)
464 if cap:
465 # mesh should always be a volume if cap is true
466 assert mesh.is_volume
468 if closed and connect:
469 assert mesh.is_volume
470 assert mesh.body_count == 1
472 return mesh
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]
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.
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
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)
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!")
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 )
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)
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)
539 # (n, 2, 2) set of line segments (positions, not references)
540 boundary = vertices[edges[edges_unique]]
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))
550 # stack the (n,2) vertices with zeros to make them (n, 3)
551 vertices_3D = util.stack_3D(vertices)
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]
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
574 return mesh
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
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.
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 """
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)
617 if polygon is None or polygon.is_empty:
618 return [], []
620 vertices = None
622 if engine == "earcut":
623 from mapbox_earcut import triangulate_float64
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 )
641 elif engine == "manifold":
642 import manifold3d
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)
657 elif engine == "triangle":
658 from triangle import triangulate
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)
669 # triangle may insert vertices
670 if force_vertices:
671 assert np.allclose(arg["vertices"], vertices)
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!")
682 return vertices, faces
685def _polygon_to_kwargs(polygon) -> dict:
686 """
687 Given a shapely polygon generate the data to pass to
688 the triangle mesh generator
690 Parameters
691 ---------
692 polygon : Shapely.geometry.Polygon
693 Input geometry
695 Returns
696 --------
697 result : dict
698 Has keys: vertices, segments, holes
699 """
701 if not polygon.is_valid:
702 raise ValueError("invalid shapely polygon passed!")
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.
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
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]
730 vertices.append(cleaned)
731 facets.append(round_trip(start, len(cleaned)))
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])
741 return len(cleaned)
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()
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
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
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.
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
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)
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)
827 if "metadata" not in kwargs:
828 kwargs["metadata"] = {}
829 kwargs["metadata"].update({"shape": "box", "extents": extents})
831 box = Trimesh(
832 vertices=vertices, faces=faces, face_normals=face_normals, process=False, **kwargs
833 )
835 # do the transform here to preserve face normals
836 if transform is not None:
837 box.apply_transform(transform)
839 return box
842def icosahedron(**kwargs) -> Trimesh:
843 """
844 Create an icosahedron, one of the platonic solids which is has 20 faces.
846 Parameters
847 ------------
848 kwargs : dict
849 Passed through to `Trimesh` constructor.
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 )
864def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
865 """
866 Create an icosphere centered at the origin.
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.
879 Returns
880 ---------
881 ico : trimesh.Trimesh
882 Meshed sphere
883 """
884 radius = float(radius)
885 subdivisions = int(subdivisions)
887 ico = icosahedron()
888 ico._validate = False
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))
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))
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")
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 )
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.
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 """
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
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
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 )
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.
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)
1006 height = abs(float(height))
1007 radius = abs(float(radius))
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
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
1024 return revolve(
1025 linestring,
1026 sections=count[1],
1027 transform=transform,
1028 metadata={"shape": "capsule", "height": height, "radius": radius},
1029 **kwargs,
1030 )
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.
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
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 )
1071 return cone
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.
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
1100 Returns
1101 ----------
1102 cylinder: trimesh.Trimesh
1103 Resulting mesh of a cylinder
1104 """
1106 if segment is not None:
1107 # override transform and height with the segment
1108 transform, height = _segment_to_cylinder(segment=segment)
1110 if height is None:
1111 raise ValueError("either `height` or `segment` must be passed!")
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 )
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.
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
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)
1163 if height is None:
1164 raise ValueError("either `height` or `segment` must be passed!")
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 ]
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 )
1190 # revolve the curve
1191 annulus = revolve(
1192 linestring=linestring, sections=sections, transform=transform, **kwargs
1193 )
1195 return annulus
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.
1203 Parameters
1204 -----------
1205 segment : (2, 3) float
1206 3D line segment in space
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
1232def random_soup(face_count: Integer = 100, seed: Seed = None):
1233 """
1234 Return random triangles as a Trimesh
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.
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
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.
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
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)
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
1298 # generate a ball for the origin
1299 axis_origin = icosphere(radius=origin_size)
1300 axis_origin.apply_transform(transform)
1302 # apply color to the origin ball
1303 axis_origin.visual.face_colors = origin_color
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]
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]
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]
1335 # append the sphere and three cylinders
1336 marker = util.concatenate([axis_origin, x_axis, y_axis, z_axis])
1337 return marker
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.
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)
1353 Returns
1354 ------------
1355 meshes : list
1356 Contains Trimesh and Path3D objects which can be visualized
1357 """
1359 # create sane origin size from marker height
1360 if origin_size is None:
1361 origin_size = marker_height / 10.0
1363 # append the visualizations to an array
1364 meshes = [axis(origin_size=origin_size)]
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
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
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 )
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))
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))
1392 # add a single Path3D object for all line segments
1393 meshes.append(load_path(segments))
1395 return meshes
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.
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
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)
1426 # transform the triangles to the specified plane
1427 transformed = tf.transform_points(tris.reshape((-1, 3)), transform).reshape((-1, 9))
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))
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))
1459 # create a mesh from the data
1460 mesh = Trimesh(vertices=vertices, faces=faces, process=False)
1462 return mesh
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.
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
1490 **kwargs:
1491 passed to Trimesh to create torus
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]
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 )
1509 # generate torus through simple revolution
1510 return revolve(
1511 linestring=linestring, sections=major_sections, transform=transform, **kwargs
1512 )