Coverage for trimesh/creation.py: 84%
434 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +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
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)
189 # create the mesh from our vertices and faces
190 mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
192 # strict checks run only in unit tests and when cap is True
193 if tol.strict and (
194 np.allclose(radius[[0, -1]], 0.0) or np.allclose(linestring[0], linestring[-1])
195 ):
196 if closed or cap:
197 # if revolved curve starts and ends with zero radius
198 # it should really be a valid volume, unless the sign
199 # reversed on the input linestring
200 assert mesh.is_volume
201 assert mesh.body_count == 1
203 return mesh
206def extrude_polygon(
207 polygon: "Polygon",
208 height: Number,
209 transform: ArrayLike | None = None,
210 mid_plane: bool = False,
211 **kwargs,
212) -> Trimesh:
213 """
214 Extrude a 2D shapely polygon into a 3D mesh
216 Parameters
217 ----------
218 polygon : shapely.geometry.Polygon
219 2D geometry to extrude
220 height : float
221 Distance to extrude polygon along Z
222 transform : None or (4, 4) float
223 Transform to apply to mesh after construction
224 triangle_args : str or None
225 Passed to triangle
226 **kwargs : dict
227 Passed to `triangulate_polygon`
229 Returns
230 ----------
231 mesh : trimesh.Trimesh
232 Resulting extrusion as watertight body
233 """
234 # create a triangulation from the polygon
235 vertices, faces = triangulate_polygon(polygon, **kwargs)
237 if mid_plane:
238 translation = np.eye(4)
239 translation[2, 3] = abs(float(height)) / -2.0
240 if transform is None:
241 transform = translation
242 else:
243 transform = np.dot(transform, translation)
245 # extrude that triangulation along Z
246 mesh = extrude_triangulation(
247 vertices=vertices, faces=faces, height=height, transform=transform, **kwargs
248 )
249 return mesh
252def sweep_polygon(
253 polygon: "Polygon",
254 path: ArrayLike,
255 angles: ArrayLike | None = None,
256 cap: bool = True,
257 connect: bool = True,
258 kwargs: dict | None = None,
259 **triangulation,
260) -> Trimesh:
261 """
262 Extrude a 2D polygon into a 3D mesh along a 3D path. Note that this
263 does *not* handle the case where there is very sharp curvature leading
264 the polygon to intersect the plane of a previous slice, and does *not*
265 scale the polygon along the induced normal to result in a constant cross section.
267 You may want to resample your path with a B-spline, i.e:
268 `trimesh.path.simplify.resample_spline(path, smooth=0.2, count=100)`
270 Parameters
271 ----------
272 polygon : shapely.geometry.Polygon
273 Profile to sweep along path
274 path : (n, 3) float
275 A path in 3D
276 angles : (n,) float
277 Optional rotation angle relative to prior vertex
278 at each vertex.
279 cap
280 If an open path is passed apply a cap to both ends.
281 connect
282 If a closed path is passed connect the sweep into
283 a single watertight mesh.
284 kwargs : dict
285 Passed to the mesh constructor.
286 **triangulation
287 Passed to `triangulate_polygon`, i.e. `engine='triangle'`
289 Returns
290 -------
291 mesh : trimesh.Trimesh
292 Geometry of result
293 """
295 path = np.asanyarray(path, dtype=np.float64)
296 if not util.is_shape(path, (-1, 3)):
297 raise ValueError("Path must be (n, 3)!")
299 if angles is not None:
300 angles = np.asanyarray(angles, dtype=np.float64)
301 if angles.shape != (len(path),):
302 raise ValueError(angles.shape)
303 else:
304 # set all angles to zero
305 angles = np.zeros(len(path), dtype=np.float64)
307 # check to see if path is closed i.e. first and last vertex are the same
308 closed = np.linalg.norm(path[0] - path[-1]) < tol.merge
309 # Extract 2D vertices and triangulation
310 vertices_2D, faces_2D = triangulate_polygon(polygon, **triangulation)
312 # stack the `(n, 3)` faces into `(3 * n, 2)` edges
313 edges = faces_to_edges(faces_2D)
314 # edges which only occur once are on the boundary of the polygon
315 # since the triangulation may have subdivided the boundary of the
316 # shapely polygon, we need to find it again
317 edges_unique = grouping.group_rows(np.sort(edges, axis=1), require_count=1)
318 # subset the vertices to only ones included in the boundary
319 unique, inverse = np.unique(edges[edges_unique].reshape(-1), return_inverse=True)
320 # take only the vertices in the boundary
321 # and stack them with zeros and ones so we can use dot
322 # products to transform them all over the place
323 vertices_tf = np.column_stack(
324 (vertices_2D[unique], np.zeros(len(unique)), np.ones(len(unique)))
325 )
326 # the indices of vertices_tf
327 boundary = inverse.reshape((-1, 2))
329 # now create the normals for the plane each slice will lie on
330 # consider the simple path with 3 vertices and therefore 2 vectors:
331 # - the first plane will be exactly along the first vector
332 # - the second plane will be the average of the two vectors
333 # - the last plane will be exactly along the last vector
334 # and each plane origin will be the corresponding vertex on the path
335 vector = util.unitize(path[1:] - path[:-1])
336 # unitize instead of / 2 as they may be degenerate / zero
337 vector_mean = util.unitize(vector[1:] + vector[:-1])
338 # collect the vectors into plane normals
339 normal = np.concatenate([[vector[0]], vector_mean, [vector[-1]]], axis=0)
341 if closed and connect:
342 # if we have a closed loop average the first and last planes
343 normal[0] = util.unitize(normal[[0, -1]].mean(axis=0))
345 # planes should have one unit normal and one vertex each
346 assert normal.shape == path.shape
348 # get the spherical coordinates for the normal vectors
349 theta, phi = util.vector_to_spherical(normal).T
351 # collect the trig values into numpy arrays we can compose into matrices
352 cos_theta, sin_theta = np.cos(theta), np.sin(theta)
353 cos_phi, sin_phi = np.cos(phi), np.sin(phi)
354 cos_roll, sin_roll = np.cos(angles), np.sin(angles)
356 # we want a rotation which will be the identity for a Z+ vector
357 # this was constructed and unrolled from the following sympy block
358 # theta, phi, roll = sp.symbols("theta phi roll")
359 # matrix = (
360 # tf.rotation_matrix(roll, [0, 0, 1]) @
361 # tf.rotation_matrix(phi, [1, 0, 0]) @
362 # tf.rotation_matrix((sp.pi / 2) - theta, [0, 0, 1])
363 # ).inv()
364 # matrix.simplify()
366 # shorthand for stacking
367 zeros = np.zeros(len(theta))
368 ones = np.ones(len(theta))
370 # stack initially as one unrolled matrix per row
371 transforms = np.column_stack(
372 [
373 -sin_roll * cos_phi * cos_theta + sin_theta * cos_roll,
374 sin_roll * sin_theta + cos_phi * cos_roll * cos_theta,
375 sin_phi * cos_theta,
376 path[:, 0],
377 -sin_roll * sin_theta * cos_phi - cos_roll * cos_theta,
378 -sin_roll * cos_theta + sin_theta * cos_phi * cos_roll,
379 sin_phi * sin_theta,
380 path[:, 1],
381 sin_phi * sin_roll,
382 -sin_phi * cos_roll,
383 cos_phi,
384 path[:, 2],
385 zeros,
386 zeros,
387 zeros,
388 ones,
389 ]
390 ).reshape((-1, 4, 4))
392 if tol.strict:
393 # make sure that each transform moves the Z+ vector to the requested normal
394 for n, matrix in zip(normal, transforms):
395 check = tf.transform_points([[0.0, 0.0, 1.0]], matrix, translate=False)[0]
396 assert np.allclose(check, n)
398 # apply transforms to prebaked homogeneous coordinates
399 vertices_3D = np.concatenate(
400 [np.dot(vertices_tf, matrix.T) for matrix in transforms], axis=0
401 )[:, :3]
403 # now construct the faces with one group of boundary faces per slice
404 stride = len(unique)
405 boundary_next = boundary + stride
406 faces_slice = np.column_stack(
407 [boundary, boundary_next[:, :1], boundary_next[:, ::-1], boundary[:, 1:]]
408 ).reshape((-1, 3))
410 # offset the slices
411 faces = [faces_slice + offset for offset in np.arange(len(path) - 1) * stride]
413 # connect only applies to closed paths
414 if closed and connect:
415 # the last slice will not be required
416 max_vertex = (len(path) - 1) * stride
417 # clip off the duplicated vertices
418 vertices_3D = vertices_3D[:max_vertex]
419 # apply the modulus in-place to a conservative subset
420 faces[-1] %= max_vertex
421 elif cap:
422 # these are indices of `vertices_2D` that were not on the boundary
423 # which can happen for triangulation algorithms that added vertices
424 # we don't currently support that but you could append the unconsumed
425 # vertices and then update the mapping below to reflect that
426 unconsumed = set(unique).difference(faces_2D.ravel())
427 if len(unconsumed) > 0:
428 raise NotImplementedError("triangulation added vertices: no logic to cap!")
430 # map the 2D faces to the order we used
431 mapped = np.zeros(unique.max() + 2, dtype=np.int64)
432 mapped[unique] = np.arange(len(unique))
434 # now should correspond to the first vertex block
435 cap_zero = mapped[faces_2D]
436 # winding will be along +Z so flip for the bottom cap
437 faces.append(np.fliplr(cap_zero))
438 # offset the end cap
439 faces.append(cap_zero + stride * (len(path) - 1))
441 if kwargs is None:
442 kwargs = {}
444 if "process" not in kwargs:
445 # we should be constructing clean meshes here
446 # so we don't need to run an expensive verex merge
447 kwargs["process"] = False
449 # stack the faces used
450 faces = np.concatenate(faces, axis=0)
452 # generate the mesh from the face data
453 mesh = Trimesh(vertices=vertices_3D, faces=faces, **kwargs)
455 if tol.strict:
456 # we should not have included any unused vertices
457 assert len(np.unique(faces)) == len(vertices_3D)
459 if cap:
460 # mesh should always be a volume if cap is true
461 assert mesh.is_volume
463 if closed and connect:
464 assert mesh.is_volume
465 assert mesh.body_count == 1
467 return mesh
470def _cross_2d(a: NDArray, b: NDArray) -> NDArray:
471 """
472 Numpy 2.0 depreciated cross products of 2D arrays.
473 """
474 return a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0]
477def extrude_triangulation(
478 vertices: ArrayLike,
479 faces: ArrayLike,
480 height: Number,
481 transform: ArrayLike | None = None,
482 **kwargs,
483) -> Trimesh:
484 """
485 Extrude a 2D triangulation into a watertight mesh.
487 Parameters
488 ----------
489 vertices : (n, 2) float
490 2D vertices
491 faces : (m, 3) int
492 Triangle indexes of vertices
493 height : float
494 Distance to extrude triangulation
495 transform : None or (4, 4) float
496 Transform to apply to mesh after construction
497 **kwargs : dict
498 Passed to Trimesh constructor
500 Returns
501 ---------
502 mesh : trimesh.Trimesh
503 Mesh created from extrusion
504 """
505 vertices = np.asanyarray(vertices, dtype=np.float64)
506 height = float(height)
507 faces = np.asanyarray(faces, dtype=np.int64)
509 if not util.is_shape(vertices, (-1, 2)):
510 raise ValueError("Vertices must be (n,2)")
511 if not util.is_shape(faces, (-1, 3)):
512 raise ValueError("Faces must be (n,3)")
513 if np.abs(height) < tol.merge:
514 raise ValueError("Height must be nonzero!")
516 # check the winding of the first few triangles
517 signs = _cross_2d(
518 np.subtract(*vertices[faces[:10, :2].T]), np.subtract(*vertices[faces[:10, 1:].T])
519 )
521 # make sure the triangulation is aligned with the sign of
522 # the height we've been passed
523 if len(signs) > 0 and np.sign(signs.mean()) != np.sign(height):
524 faces = np.fliplr(faces)
526 # stack the (n,3) faces into (3*n, 2) edges
527 edges = faces_to_edges(faces)
528 edges_sorted = np.sort(edges, axis=1)
529 # edges which only occur once are on the boundary of the polygon
530 # since the triangulation may have subdivided the boundary of the
531 # shapely polygon, we need to find it again
532 edges_unique = grouping.group_rows(edges_sorted, require_count=1)
534 # (n, 2, 2) set of line segments (positions, not references)
535 boundary = vertices[edges[edges_unique]]
537 # we are creating two vertical triangles for every 2D line segment
538 # on the boundary of the 2D triangulation
539 vertical = np.tile(boundary.reshape((-1, 2)), 2).reshape((-1, 2))
540 vertical = np.column_stack((vertical, np.tile([0, height, 0, height], len(boundary))))
541 vertical_faces = np.tile([3, 1, 2, 2, 1, 0], (len(boundary), 1))
542 vertical_faces += np.arange(len(boundary)).reshape((-1, 1)) * 4
543 vertical_faces = vertical_faces.reshape((-1, 3))
545 # stack the (n,2) vertices with zeros to make them (n, 3)
546 vertices_3D = util.stack_3D(vertices)
548 # a sequence of zero- indexed faces, which will then be appended
549 # with offsets to create the final mesh
550 faces_seq = [faces[:, ::-1], faces.copy(), vertical_faces]
551 vertices_seq = [vertices_3D, vertices_3D.copy() + [0.0, 0, height], vertical]
553 # append sequences into flat nicely indexed arrays
554 vertices, faces = util.append_faces(vertices_seq, faces_seq)
555 if transform is not None:
556 # apply transform here to avoid later bookkeeping
557 vertices = tf.transform_points(vertices, transform)
558 # if the transform flips the winding flip faces back
559 # so that the normals will be facing outwards
560 if tf.flips_winding(transform):
561 # fliplr makes arrays non-contiguous
562 faces = np.ascontiguousarray(np.fliplr(faces))
563 # create mesh object with passed keywords
564 mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
565 # only check in strict mode (unit tests)
566 if tol.strict:
567 assert mesh.volume > 0.0
569 return mesh
572def triangulate_polygon(
573 polygon,
574 triangle_args: str | None = None,
575 engine: str | None = None,
576 force_vertices: bool = False,
577 **kwargs,
578) -> tuple[NDArray[np.float64], NDArray[np.int64]]:
579 """
580 Given a shapely polygon create a triangulation using a
581 python interface to the permissively licensed `mapbox-earcut`
582 or the more robust `triangle.c`.
583 > pip install manifold3d
584 > pip install triangle
585 > pip install mapbox_earcut
587 Parameters
588 ---------
589 polygon : Shapely.geometry.Polygon
590 Polygon object to be triangulated.
591 triangle_args
592 Passed to triangle.triangulate i.e: 'p', 'pq30', 'pY'="don't insert vert"
593 engine
594 None or 'earcut' will use earcut, 'triangle' will use triangle
595 force_vertices
596 Many operations can't handle new vertices being inserted, so this will
597 attempt to generate a triangulation without new vertices and raise a
598 ValueError if it is unable to do so.
600 Returns
601 --------------
602 vertices : (n, 2) float
603 Points in space
604 faces : (n, 3) int
605 Index of vertices that make up triangles
606 """
608 if engine is None:
609 # try getting the first engine that is installed
610 engine = next((name for name, exists in _engines if exists), None)
612 if polygon is None or polygon.is_empty:
613 return [], []
615 vertices = None
617 if engine == "earcut":
618 from mapbox_earcut import triangulate_float64
620 # get vertices as sequence where exterior
621 # is the first value
622 vertices = [np.array(polygon.exterior.coords)]
623 vertices.extend(np.array(i.coords) for i in polygon.interiors)
624 # record the index from the length of each vertex array
625 rings = np.cumsum([len(v) for v in vertices])
626 # stack vertices into (n, 2) float array
627 vertices = np.vstack(vertices)
628 # run triangulation
629 faces = (
630 triangulate_float64(vertices, rings)
631 .reshape((-1, 3))
632 .astype(np.int64)
633 .reshape((-1, 3))
634 )
636 elif engine == "manifold":
637 import manifold3d
639 # the outer ring is wound counter-clockwise
640 rings = [
641 np.array(polygon.exterior.coords)[:: (1 if polygon.exterior.is_ccw else -1)][
642 :-1
643 ]
644 ]
645 # wind interiors
646 rings.extend(
647 np.array(b.coords)[:: (-1 if b.is_ccw else 1)][:-1] for b in polygon.interiors
648 )
649 faces = manifold3d.triangulate(rings).astype(np.int64)
650 vertices = np.vstack(rings, dtype=np.float64)
652 elif engine == "triangle":
653 from triangle import triangulate
655 # set default triangulation arguments if not specified
656 if triangle_args is None:
657 triangle_args = "p"
658 # turn the polygon in to vertices, segments, and holes
659 arg = _polygon_to_kwargs(polygon)
660 # run the triangulation
661 blob = triangulate(arg, triangle_args)
662 vertices, faces = blob["vertices"], blob["triangles"].astype(np.int64)
664 # triangle may insert vertices
665 if force_vertices:
666 assert np.allclose(arg["vertices"], vertices)
668 if vertices is None:
669 log.warning(
670 "try running `pip install mapbox-earcut manifold3d`"
671 + "or `triangle`, `mapbox_earcut`, then explicitly pass:\n"
672 + '`triangulate_polygon(*args, engine="triangle")`\n'
673 + "to use the non-FSF-approved-license triangle engine"
674 )
675 raise ValueError("No available triangulation engine!")
677 return vertices, faces
680def _polygon_to_kwargs(polygon) -> dict:
681 """
682 Given a shapely polygon generate the data to pass to
683 the triangle mesh generator
685 Parameters
686 ---------
687 polygon : Shapely.geometry.Polygon
688 Input geometry
690 Returns
691 --------
692 result : dict
693 Has keys: vertices, segments, holes
694 """
696 if not polygon.is_valid:
697 raise ValueError("invalid shapely polygon passed!")
699 def round_trip(start, length):
700 """
701 Given a start index and length, create a series of (n, 2) edges which
702 create a closed traversal.
704 Examples
705 ---------
706 start, length = 0, 3
707 returns: [(0,1), (1,2), (2,0)]
708 """
709 tiled = np.tile(np.arange(start, start + length).reshape((-1, 1)), 2)
710 tiled = tiled.reshape(-1)[1:-1].reshape((-1, 2))
711 tiled = np.vstack((tiled, [tiled[-1][-1], tiled[0][0]]))
712 return tiled
714 def add_boundary(boundary, start):
715 # coords is an (n, 2) ordered list of points on the polygon boundary
716 # the first and last points are the same, and there are no
717 # guarantees on points not being duplicated (which will
718 # later cause meshpy/triangle to shit a brick)
719 coords = np.array(boundary.coords)
720 # find indices points which occur only once, and sort them
721 # to maintain order
722 unique = np.sort(grouping.unique_rows(coords)[0])
723 cleaned = coords[unique]
725 vertices.append(cleaned)
726 facets.append(round_trip(start, len(cleaned)))
728 # holes require points inside the region of the hole, which we find
729 # by creating a polygon from the cleaned boundary region, and then
730 # using a representative point. You could do things like take the mean of
731 # the points, but this is more robust (to things like concavity), if
732 # slower.
733 test = Polygon(cleaned)
734 holes.append(np.array(test.representative_point().coords)[0])
736 return len(cleaned)
738 # sequence of (n,2) points in space
739 vertices = collections.deque()
740 # sequence of (n,2) indices of vertices
741 facets = collections.deque()
742 # list of (2) vertices in interior of hole regions
743 holes = collections.deque()
745 start = add_boundary(polygon.exterior, 0)
746 for interior in polygon.interiors:
747 try:
748 start += add_boundary(interior, start)
749 except BaseException:
750 log.warning("invalid interior, continuing")
751 continue
753 # create clean (n,2) float array of vertices
754 # and (m, 2) int array of facets
755 # by stacking the sequence of (p,2) arrays
756 vertices = np.vstack(vertices)
757 facets = np.vstack(facets).tolist()
758 # shapely polygons can include a Z component
759 # strip it out for the triangulation
760 if vertices.shape[1] == 3:
761 vertices = vertices[:, :2]
762 result = {"vertices": vertices, "segments": facets}
763 # holes in meshpy lingo are a (h, 2) list of (x,y) points
764 # which are inside the region of the hole
765 # we added a hole for the exterior, which we slice away here
766 holes = np.array(holes)[1:]
767 if len(holes) > 0:
768 result["holes"] = holes
769 return result
772def box(
773 extents: ArrayLike | None = None,
774 transform: ArrayLike | None = None,
775 bounds: ArrayLike | None = None,
776 **kwargs,
777):
778 """
779 Return a cuboid.
781 Parameters
782 ------------
783 extents : (3,) float
784 Edge lengths
785 transform: (4, 4) float
786 Transformation matrix
787 bounds : None or (2, 3) float
788 Corners of AABB, overrides extents and transform.
789 **kwargs:
790 passed to Trimesh to create box
792 Returns
793 ------------
794 geometry : trimesh.Trimesh
795 Mesh of a cuboid
796 """
797 # vertices of the cube from reference
798 vertices = np.array(_data["box"]["vertices"], order="C", dtype=np.float64)
799 faces = np.array(_data["box"]["faces"], order="C", dtype=np.int64)
800 face_normals = np.array(_data["box"]["face_normals"], order="C", dtype=np.float64)
802 # resize cube based on passed extents
803 if bounds is not None:
804 if transform is not None or extents is not None:
805 raise ValueError("`bounds` overrides `extents`/`transform`!")
806 bounds = np.array(bounds, dtype=np.float64)
807 if bounds.shape != (2, 3):
808 raise ValueError("`bounds` must be (2, 3) float!")
809 extents = np.ptp(bounds, axis=0)
810 vertices *= extents
811 vertices += bounds[0]
812 elif extents is not None:
813 extents = np.asanyarray(extents, dtype=np.float64)
814 if extents.shape != (3,):
815 raise ValueError("Extents must be (3,)!")
816 vertices -= 0.5
817 vertices *= extents
818 else:
819 vertices -= 0.5
820 extents = np.asarray((1.0, 1.0, 1.0), dtype=np.float64)
822 if "metadata" not in kwargs:
823 kwargs["metadata"] = {}
824 kwargs["metadata"].update({"shape": "box", "extents": extents})
826 box = Trimesh(
827 vertices=vertices, faces=faces, face_normals=face_normals, process=False, **kwargs
828 )
830 # do the transform here to preserve face normals
831 if transform is not None:
832 box.apply_transform(transform)
834 return box
837def icosahedron(**kwargs) -> Trimesh:
838 """
839 Create an icosahedron, one of the platonic solids which is has 20 faces.
841 Parameters
842 ------------
843 kwargs : dict
844 Passed through to `Trimesh` constructor.
846 Returns
847 -------------
848 ico : trimesh.Trimesh
849 Icosahederon centered at the origin.
850 """
851 # get stored pre-baked primitive values
852 vertices = np.array(_data["icosahedron"]["vertices"], dtype=np.float64)
853 faces = np.array(_data["icosahedron"]["faces"], dtype=np.int64)
854 return Trimesh(
855 vertices=vertices, faces=faces, process=kwargs.pop("process", False), **kwargs
856 )
859def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
860 """
861 Create an icosphere centered at the origin.
863 Parameters
864 ----------
865 subdivisions : int
866 How many times to subdivide the mesh.
867 Note that the number of faces will grow as function of
868 4 ** subdivisions, so you probably want to keep this under ~5
869 radius : float
870 Desired radius of sphere
871 kwargs : dict
872 Passed through to `Trimesh` constructor.
874 Returns
875 ---------
876 ico : trimesh.Trimesh
877 Meshed sphere
878 """
879 radius = float(radius)
880 subdivisions = int(subdivisions)
882 ico = icosahedron()
883 ico._validate = False
885 for _ in range(subdivisions):
886 ico = ico.subdivide()
887 vectors = ico.vertices
888 scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1]))
889 unit = vectors / scalar.reshape((-1, 1))
890 ico.vertices += unit * (radius - scalar).reshape((-1, 1))
892 # if we didn't subdivide we still need to refine the radius
893 if subdivisions <= 0:
894 vectors = ico.vertices
895 scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1]))
896 unit = vectors / scalar.reshape((-1, 1))
897 ico.vertices += unit * (radius - scalar).reshape((-1, 1))
899 if "color" in kwargs:
900 warnings.warn(
901 "`icosphere(color=...)` is deprecated and will "
902 + "be removed in June 2024: replace with Trimesh constructor "
903 + "kewyword argument `icosphere(face_colors=...)`",
904 category=DeprecationWarning,
905 stacklevel=2,
906 )
907 kwargs["face_colors"] = kwargs.pop("color")
909 return Trimesh(
910 vertices=ico.vertices,
911 faces=ico.faces,
912 metadata={"shape": "sphere", "radius": radius},
913 process=kwargs.pop("process", False),
914 **kwargs,
915 )
918def uv_sphere(
919 radius: Number = 1.0,
920 count: ArrayLike | None = None,
921 transform: ArrayLike | None = None,
922 **kwargs,
923) -> Trimesh:
924 """
925 Create a UV sphere (latitude + longitude) centered at the
926 origin. Roughly one order of magnitude faster than an
927 icosphere but slightly uglier.
929 Parameters
930 ----------
931 radius : float
932 Radius of sphere
933 count : (2,) int
934 Number of latitude and longitude lines
935 transform : None or (4, 4) float
936 Transform to apply to mesh after construction
937 kwargs : dict
938 Passed thgrough
939 Returns
940 ----------
941 mesh : trimesh.Trimesh
942 Mesh of UV sphere with specified parameters
943 """
945 # set the resolution of the uv sphere
946 if count is None:
947 count = np.array([32, 64], dtype=np.int64)
948 else:
949 count = np.array(count, dtype=np.int64)
950 count += np.mod(count, 2)
951 count[1] *= 2
953 # generate the 2D curve for the UV sphere
954 theta = np.linspace(0.0, np.pi, num=count[0])
955 linestring = np.column_stack((np.sin(theta), -np.cos(theta))) * radius
957 # revolve the curve to create a volume
958 return revolve(
959 linestring=linestring,
960 sections=count[1],
961 transform=transform,
962 metadata={"shape": "sphere", "radius": radius},
963 **kwargs,
964 )
967def capsule(
968 height: Number = 1.0,
969 radius: Number = 1.0,
970 count: ArrayLike | None = None,
971 transform: ArrayLike | None = None,
972 **kwargs,
973) -> Trimesh:
974 """
975 Create a mesh of a capsule, or a cylinder with hemispheric ends.
977 Parameters
978 ----------
979 height : float
980 Center to center distance of two spheres
981 radius : float
982 Radius of the cylinder and hemispheres
983 count : (2,) int
984 Number of sections on latitude and longitude
985 transform : None or (4, 4) float
986 Transform to apply to mesh after construction
987 Returns
988 ----------
989 capsule : trimesh.Trimesh
990 Capsule geometry with:
991 - cylinder axis is along Z
992 - one hemisphere is centered at the origin
993 - other hemisphere is centered along the Z axis at height
994 """
995 if count is None:
996 count = np.array([32, 64], dtype=np.int64)
997 else:
998 count = np.array(count, dtype=np.int64)
999 count += np.mod(count, 2)
1001 height = abs(float(height))
1002 radius = abs(float(radius))
1004 # create a half circle
1005 theta = np.linspace(-np.pi / 2.0, np.pi / 2.0, count[0])
1006 linestring = np.column_stack((np.cos(theta), np.sin(theta))) * radius
1008 # offset the top and bottom by half the height
1009 half = len(linestring) // 2
1010 linestring[:half][:, 1] -= height / 2.0
1011 linestring[half:][:, 1] += height / 2.0
1013 return revolve(
1014 linestring,
1015 sections=count[1],
1016 transform=transform,
1017 metadata={"shape": "capsule", "height": height, "radius": radius},
1018 **kwargs,
1019 )
1022def cone(
1023 radius: Number,
1024 height: Number,
1025 sections: Integer | None = None,
1026 transform: ArrayLike | None = None,
1027 **kwargs,
1028) -> Trimesh:
1029 """
1030 Create a mesh of a cone along Z centered at the origin.
1032 Parameters
1033 ----------
1034 radius : float
1035 The radius of the cone at the widest part.
1036 height : float
1037 The height of the cone.
1038 sections : int or None
1039 How many pie wedges per revolution
1040 transform : (4, 4) float or None
1041 Transform to apply after creation
1042 **kwargs : dict
1043 Passed to Trimesh constructor
1045 Returns
1046 ----------
1047 cone: trimesh.Trimesh
1048 Resulting mesh of a cone
1049 """
1050 # create the 2D outline of a cone
1051 linestring = [[0, 0], [radius, 0], [0, height]]
1052 # revolve the profile to create a cone
1053 if "metadata" not in kwargs:
1054 kwargs["metadata"] = {}
1055 kwargs["metadata"].update({"shape": "cone", "radius": radius, "height": height})
1056 cone = revolve(
1057 linestring=linestring, sections=sections, transform=transform, **kwargs
1058 )
1060 return cone
1063def cylinder(
1064 radius: Number,
1065 height: Number | None = None,
1066 sections: Integer | None = None,
1067 segment: ArrayLike | None = None,
1068 transform: ArrayLike | None = None,
1069 **kwargs,
1070):
1071 """
1072 Create a mesh of a cylinder along Z centered at the origin.
1074 Parameters
1075 ----------
1076 radius : float
1077 The radius of the cylinder
1078 height : float or None
1079 The height of the cylinder, or None if `segment` has been passed.
1080 sections : int or None
1081 How many pie wedges should the cylinder have
1082 segment : (2, 3) float
1083 Endpoints of axis, overrides transform and height
1084 transform : None or (4, 4) float
1085 Transform to apply to mesh after construction
1086 **kwargs:
1087 passed to Trimesh to create cylinder
1089 Returns
1090 ----------
1091 cylinder: trimesh.Trimesh
1092 Resulting mesh of a cylinder
1093 """
1095 if segment is not None:
1096 # override transform and height with the segment
1097 transform, height = _segment_to_cylinder(segment=segment)
1099 if height is None:
1100 raise ValueError("either `height` or `segment` must be passed!")
1102 half = abs(float(height)) / 2.0
1103 # create a profile to revolve
1104 linestring = [[0, -half], [radius, -half], [radius, half], [0, half]]
1105 if "metadata" not in kwargs:
1106 kwargs["metadata"] = {}
1107 kwargs["metadata"].update({"shape": "cylinder", "height": height, "radius": radius})
1108 # generate cylinder through simple revolution
1109 return revolve(
1110 linestring=linestring, sections=sections, transform=transform, **kwargs
1111 )
1114def annulus(
1115 r_min: Number,
1116 r_max: Number,
1117 height: Number | None = None,
1118 sections: Integer | None = None,
1119 transform: ArrayLike | None = None,
1120 segment: ArrayLike | None = None,
1121 **kwargs,
1122):
1123 """
1124 Create a mesh of an annular cylinder along Z centered at the origin.
1126 Parameters
1127 ----------
1128 r_min : float
1129 The inner radius of the annular cylinder
1130 r_max : float
1131 The outer radius of the annular cylinder
1132 height : float
1133 The height of the annular cylinder
1134 sections : int or None
1135 How many pie wedges should the annular cylinder have
1136 transform : (4, 4) float or None
1137 Transform to apply to move result from the origin
1138 segment : None or (2, 3) float
1139 Override transform and height with a line segment
1140 **kwargs:
1141 passed to Trimesh to create annulus
1143 Returns
1144 ----------
1145 annulus : trimesh.Trimesh
1146 Mesh of annular cylinder
1147 """
1148 if segment is not None:
1149 # override transform and height with the segment if passed
1150 transform, height = _segment_to_cylinder(segment=segment)
1152 if height is None:
1153 raise ValueError("either `height` or `segment` must be passed!")
1155 r_min = abs(float(r_min))
1156 # if center radius is zero this is a cylinder
1157 if r_min < tol.merge:
1158 return cylinder(
1159 radius=r_max, height=height, sections=sections, transform=transform, **kwargs
1160 )
1161 r_max = abs(float(r_max))
1162 # we're going to center at XY plane so take half the height
1163 half = abs(float(height)) / 2.0
1164 # create counter-clockwise rectangle
1165 linestring = [
1166 [r_min, -half],
1167 [r_max, -half],
1168 [r_max, half],
1169 [r_min, half],
1170 [r_min, -half],
1171 ]
1173 if "metadata" not in kwargs:
1174 kwargs["metadata"] = {}
1175 kwargs["metadata"].update(
1176 {"shape": "annulus", "r_min": r_min, "r_max": r_max, "height": height}
1177 )
1179 # revolve the curve
1180 annulus = revolve(
1181 linestring=linestring, sections=sections, transform=transform, **kwargs
1182 )
1184 return annulus
1187def _segment_to_cylinder(segment: ArrayLike):
1188 """
1189 Convert a line segment to a transform and height for a cylinder
1190 or cylinder-like primitive.
1192 Parameters
1193 -----------
1194 segment : (2, 3) float
1195 3D line segment in space
1197 Returns
1198 -----------
1199 transform : (4, 4) float
1200 Matrix to move a Z-extruded origin cylinder to segment
1201 height : float
1202 The height of the cylinder needed
1203 """
1204 segment = np.asanyarray(segment, dtype=np.float64)
1205 if segment.shape != (2, 3):
1206 raise ValueError("segment must be 2 3D points!")
1207 vector = segment[1] - segment[0]
1208 # override height with segment length
1209 height = np.linalg.norm(vector)
1210 # point in middle of line
1211 midpoint = segment[0] + (vector * 0.5)
1212 # align Z with our desired direction
1213 rotation = align_vectors([0, 0, 1], vector)
1214 # translate to midpoint of segment
1215 translation = tf.translation_matrix(midpoint)
1216 # compound the rotation and translation
1217 transform = np.dot(translation, rotation)
1218 return transform, height
1221def random_soup(face_count: Integer = 100):
1222 """
1223 Return random triangles as a Trimesh
1225 Parameters
1226 -----------
1227 face_count : int
1228 Number of faces desired in mesh
1230 Returns
1231 -----------
1232 soup : trimesh.Trimesh
1233 Geometry with face_count random faces
1234 """
1235 vertices = np.random.random((face_count * 3, 3)) - 0.5
1236 faces = np.arange(face_count * 3).reshape((-1, 3))
1237 soup = Trimesh(vertices=vertices, faces=faces)
1238 return soup
1241def axis(
1242 origin_size: Number = 0.04,
1243 transform: ArrayLike | None = None,
1244 origin_color: ArrayLike | None = None,
1245 axis_radius: Number | None = None,
1246 axis_length: Number | None = None,
1247):
1248 """
1249 Return an XYZ axis marker as a Trimesh, which represents position
1250 and orientation. If you set the origin size the other parameters
1251 will be set relative to it.
1253 Parameters
1254 ----------
1255 origin_size : float
1256 Radius of sphere that represents the origin
1257 transform : (4, 4) float
1258 Transformation matrix
1259 origin_color : (3,) float or int, uint8 or float
1260 Color of the origin
1261 axis_radius : float
1262 Radius of cylinder that represents x, y, z axis
1263 axis_length: float
1264 Length of cylinder that represents x, y, z axis
1266 Returns
1267 -------
1268 marker : trimesh.Trimesh
1269 Mesh geometry of axis indicators
1270 """
1271 # the size of the ball representing the origin
1272 origin_size = float(origin_size)
1274 # set the transform and use origin-relative
1275 # sized for other parameters if not specified
1276 if transform is None:
1277 transform = np.eye(4)
1278 if origin_color is None:
1279 origin_color = [255, 255, 255, 255]
1280 if axis_radius is None:
1281 axis_radius = origin_size / 5.0
1282 if axis_length is None:
1283 axis_length = origin_size * 10.0
1285 # generate a ball for the origin
1286 axis_origin = icosphere(radius=origin_size)
1287 axis_origin.apply_transform(transform)
1289 # apply color to the origin ball
1290 axis_origin.visual.face_colors = origin_color
1292 # create the cylinder for the z-axis
1293 translation = tf.translation_matrix([0, 0, axis_length / 2])
1294 z_axis = cylinder(
1295 radius=axis_radius, height=axis_length, transform=transform.dot(translation)
1296 )
1297 # XYZ->RGB, Z is blue
1298 z_axis.visual.face_colors = [0, 0, 255]
1300 # create the cylinder for the y-axis
1301 translation = tf.translation_matrix([0, 0, axis_length / 2])
1302 rotation = tf.rotation_matrix(np.radians(-90), [1, 0, 0])
1303 y_axis = cylinder(
1304 radius=axis_radius,
1305 height=axis_length,
1306 transform=transform.dot(rotation).dot(translation),
1307 )
1308 # XYZ->RGB, Y is green
1309 y_axis.visual.face_colors = [0, 255, 0]
1311 # create the cylinder for the x-axis
1312 translation = tf.translation_matrix([0, 0, axis_length / 2])
1313 rotation = tf.rotation_matrix(np.radians(90), [0, 1, 0])
1314 x_axis = cylinder(
1315 radius=axis_radius,
1316 height=axis_length,
1317 transform=transform.dot(rotation).dot(translation),
1318 )
1319 # XYZ->RGB, X is red
1320 x_axis.visual.face_colors = [255, 0, 0]
1322 # append the sphere and three cylinders
1323 marker = util.concatenate([axis_origin, x_axis, y_axis, z_axis])
1324 return marker
1327def camera_marker(camera, marker_height: Number = 0.4, origin_size: Number | None = None):
1328 """
1329 Create a visual marker for a camera object, including an axis and FOV.
1331 Parameters
1332 ---------------
1333 camera : trimesh.scene.Camera
1334 Camera object with FOV and transform defined
1335 marker_height : float
1336 How far along the camera Z should FOV indicators be
1337 origin_size : float
1338 Sphere radius of the origin (default: marker_height / 10.0)
1340 Returns
1341 ------------
1342 meshes : list
1343 Contains Trimesh and Path3D objects which can be visualized
1344 """
1346 # create sane origin size from marker height
1347 if origin_size is None:
1348 origin_size = marker_height / 10.0
1350 # append the visualizations to an array
1351 meshes = [axis(origin_size=origin_size)]
1353 try:
1354 # path is a soft dependency
1355 from .path.exchange.load import load_path
1356 except ImportError:
1357 # they probably don't have shapely installed
1358 log.warning("unable to create FOV visualization!", exc_info=True)
1359 return meshes
1361 # calculate vertices from camera FOV angles
1362 x = marker_height * np.tan(np.deg2rad(camera.fov[0]) / 2.0)
1363 y = marker_height * np.tan(np.deg2rad(camera.fov[1]) / 2.0)
1364 z = marker_height
1366 # combine the points into the vertices of an FOV visualization
1367 points = np.array(
1368 [(0, 0, 0), (-x, -y, -z), (x, -y, -z), (x, y, -z), (-x, y, -z)], dtype=float
1369 )
1371 # create line segments for the FOV visualization
1372 # a segment from the origin to each bound of the FOV
1373 segments = np.column_stack((np.zeros_like(points), points)).reshape((-1, 3))
1375 # add a loop for the outside of the FOV then reshape
1376 # the whole thing into multiple line segments
1377 segments = np.vstack((segments, points[[1, 2, 2, 3, 3, 4, 4, 1]])).reshape((-1, 2, 3))
1379 # add a single Path3D object for all line segments
1380 meshes.append(load_path(segments))
1382 return meshes
1385def truncated_prisms(
1386 tris: ArrayLike,
1387 origin: ArrayLike | None = None,
1388 normal: ArrayLike | None = None,
1389):
1390 """
1391 Return a mesh consisting of multiple watertight prisms below
1392 a list of triangles, truncated by a specified plane.
1394 Parameters
1395 -------------
1396 triangles : (n, 3, 3) float
1397 Triangles in space
1398 origin : None or (3,) float
1399 Origin of truncation plane
1400 normal : None or (3,) float
1401 Unit normal vector of truncation plane
1403 Returns
1404 -----------
1405 mesh : trimesh.Trimesh
1406 Triangular mesh
1407 """
1408 if origin is None:
1409 transform = np.eye(4)
1410 else:
1411 transform = plane_transform(origin=origin, normal=normal)
1413 # transform the triangles to the specified plane
1414 transformed = tf.transform_points(tris.reshape((-1, 3)), transform).reshape((-1, 9))
1416 # stack triangles such that every other one is repeated
1417 vs = np.column_stack((transformed, transformed)).reshape((-1, 3, 3))
1418 # set the Z value of the second triangle to zero
1419 vs[1::2, :, 2] = 0
1420 # reshape triangles to a flat array of points and transform back to
1421 # original frame
1422 vertices = tf.transform_points(vs.reshape((-1, 3)), matrix=np.linalg.inv(transform))
1424 # face indexes for a *single* truncated triangular prism
1425 f = np.array(
1426 [
1427 [2, 1, 0],
1428 [3, 4, 5],
1429 [0, 1, 4],
1430 [1, 2, 5],
1431 [2, 0, 3],
1432 [4, 3, 0],
1433 [5, 4, 1],
1434 [3, 5, 2],
1435 ]
1436 )
1437 # find the projection of each triangle with the normal vector
1438 cross = np.dot([0, 0, 1], triangles.cross(transformed.reshape((-1, 3, 3))).T)
1439 # stack faces into one prism per triangle
1440 f_seq = np.tile(f, (len(transformed), 1)).reshape((-1, len(f), 3))
1441 # if the normal of the triangle was positive flip the winding
1442 f_seq[cross > 0] = np.fliplr(f)
1443 # offset stacked faces to create correct indices
1444 faces = (f_seq + (np.arange(len(f_seq)) * 6).reshape((-1, 1, 1))).reshape((-1, 3))
1446 # create a mesh from the data
1447 mesh = Trimesh(vertices=vertices, faces=faces, process=False)
1449 return mesh
1452def torus(
1453 major_radius: Number,
1454 minor_radius: Number,
1455 major_sections: Integer = 32,
1456 minor_sections: Integer = 32,
1457 transform: ArrayLike | None = None,
1458 **kwargs,
1459):
1460 """Create a mesh of a torus around Z centered at the origin.
1462 Parameters
1463 ------------
1464 major_radius: (float)
1465 Radius from the center of the torus to the center of the tube.
1466 minor_radius: (float)
1467 Radius of the tube.
1468 major_sections: int
1469 Number of sections around major radius result should have
1470 If not specified default is 32 per revolution
1471 minor_sections: int
1472 Number of sections around minor radius result should have
1473 If not specified default is 32 per revolution
1474 transform : (4, 4) float
1475 Transformation matrix
1477 **kwargs:
1478 passed to Trimesh to create torus
1480 Returns
1481 ------------
1482 geometry : trimesh.Trimesh
1483 Mesh of a torus
1484 """
1485 phi = np.linspace(0, 2 * np.pi, minor_sections + 1, endpoint=True)
1486 linestring = np.column_stack(
1487 (minor_radius * np.cos(phi), minor_radius * np.sin(phi))
1488 ) + [major_radius, 0]
1490 if "metadata" not in kwargs:
1491 kwargs["metadata"] = {}
1492 kwargs["metadata"].update(
1493 {"shape": "torus", "major_radius": major_radius, "minor_radius": minor_radius}
1494 )
1496 # generate torus through simple revolution
1497 return revolve(
1498 linestring=linestring, sections=major_sections, transform=transform, **kwargs
1499 )