Repository: mikedh/trimesh

Issue:
trimesh.creation.sweep_polygon - sweep without banking?
As I understand it, the sweep_polygon function rotates the polygon along x when it sweeps along a 3Dpath. In other software this is usually called banking when sweeping objects along a path. See here:
https://www.cadforum.cz/en/what-for-is-the-bank-option-of-the-sweep-command-tip12471

Is it possible to not rotate the polygon along X when sweeping? I would like resulting trimesh not to be banked.
Examples with a swept rectangle:
![2D](https://user-images.githubusercontent.com/10772529/145786168-3b8cdc26-7daf-4146-99c7-20937e3b1b68.png)
![banked](https://user-images.githubusercontent.com/10772529/145786180-3ec64de1-7e48-41a1-84d1-c9168dac3017.jpg)
![not_banked](https://user-images.githubusercontent.com/10772529/145786188-1392f324-a32e-49b8-8621-e5a3605bbbae.jpg)


I tried setting the angles option in trimesh.creation.sweep_polygon to a list with 0.0 values but it did not have any effect. Do I need calculate the angles for all vertices/line segments in the 3Dpath and then supply that to the angles option? Would appreciate an example if that is the case.

Thanks!

Discussion:

Patch:
diff --git a/Dockerfile b/Dockerfile
index f994ef1a..fe40e20c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -63,7 +63,7 @@ COPY --chown=499 pyproject.toml .
 COPY --chown=499 ./.git ./.git/
 
 USER root
-RUN trimesh-setup --install=test,gltf_validator,llvmpipe,binvox
+RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox
 USER user
 
 # install things like pytest
diff --git a/docker/trimesh-setup b/docker/trimesh-setup
index 5ed0f0d2..e0d8fa17 100755
--- a/docker/trimesh-setup
+++ b/docker/trimesh-setup
@@ -46,7 +46,8 @@ config_json = """
       "openscad",
       "curl",
       "git"
-    ]
+    ],
+   "gmsh": ["libxft2", "libxinerama-dev", "libxcursor1","libgomp1"]
   },
   "fetch": {
     "gltf_validator": {
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 3ee8613d..0ed4a793 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -15,6 +15,7 @@ from . import transformations as tf
 from .base import Trimesh
 from .constants import log, tol
 from .geometry import align_vectors, faces_to_edges, plane_transform
+from .resources import get_json
 from .typed import ArrayLike, Dict, Integer, Number, Optional
 
 try:
@@ -32,6 +33,9 @@ try:
 except BaseException as E:
     _tri_earcut = exceptions.ExceptionWrapper(E)
 
+# get stored values for simple box and icosahedron primitives
+_data = get_json("creation.json")
+
 
 def revolve(
     linestring: ArrayLike,
@@ -208,13 +212,20 @@ def extrude_polygon(
 def sweep_polygon(
     polygon: "Polygon",
     path: ArrayLike,
-    closed: bool = False,
     angles: Optional[ArrayLike] = None,
-    **kwargs,
+    cap: bool = True,
+    connect: bool = True,
+    kwargs: Optional[Dict] = None,
+    **triangulation,
 ) -> Trimesh:
     """
-    Extrude a 2D shapely polygon into a 3D mesh along an
-    arbitrary 3D path. Doesn't handle sharp curvature well.
+    Extrude a 2D polygon into a 3D mesh along a 3D path. Note that this
+    does *not* handle the case where there is very sharp curvature leading
+    the polygon to intersect the plane of a previous slice, and does *not*
+    scale the polygon along the induced normal to result in a constant cross section.
+
+    You may want to resample your path with a B-spline, i.e:
+      `trimesh.path.simplify.resample_spline(path, smooth=0.2, count=100)`
 
     Parameters
     ----------
@@ -222,13 +233,19 @@ def sweep_polygon(
       Profile to sweep along path
     path : (n, 3) float
       A path in 3D
-    closed : bool
-      Whether to close the 2 ends of the path
     angles : (n,) float
       Optional rotation angle relative to prior vertex
-      at each vertex
-    **kwargs : dict
-      Passed to `triangulate_polygon`.
+      at each vertex.
+    cap
+      If an open path is passed apply a cap to both ends.
+    connect
+      If a closed path is passed connect the sweep into
+      a single watertight mesh.
+    kwargs : dict
+      Passed to the mesh constructor.
+    **triangulation
+      Passed to `triangulate_polygon`, i.e. `engine='triangle'`
+
     Returns
     -------
     mesh : trimesh.Trimesh
@@ -239,96 +256,175 @@ def sweep_polygon(
     if not util.is_shape(path, (-1, 3)):
         raise ValueError("Path must be (n, 3)!")
 
+    if angles is not None:
+        angles = np.asanyarray(angles, dtype=np.float64)
+        if angles.shape != (len(path),):
+            raise ValueError(angles.shape)
+    else:
+        # set all angles to zero
+        angles = np.zeros(len(path), dtype=np.float64)
+
+    # check to see if path is closed i.e. first and last vertex are the same
+    closed = np.linalg.norm(path[0] - path[-1]) < tol.merge
     # Extract 2D vertices and triangulation
-    verts_2d = np.array(polygon.exterior.coords)[:-1]
-    base_verts_2d, faces_2d = triangulate_polygon(polygon, **kwargs)
-    n = len(verts_2d)
+    vertices_2D, faces_2D = triangulate_polygon(polygon, **triangulation)
 
-    # Create basis for first planar polygon cap
-    x, y, z = util.generate_basis(path[0] - path[1])
-    tf_mat = np.ones((4, 4))
-    tf_mat[:3, :3] = np.c_[x, y, z]
-    tf_mat[:3, 3] = path[0]
+    # stack the `(n, 3)` faces into `(3 * n, 2)` edges
+    edges = faces_to_edges(faces_2D)
+    # edges which only occur once are on the boundary of the polygon
+    # since the triangulation may have subdivided the boundary of the
+    # shapely polygon, we need to find it again
+    edges_unique = grouping.group_rows(np.sort(edges, axis=1), require_count=1)
+    # subset the vertices to only ones included in the boundary
+    unique, inverse = np.unique(edges[edges_unique].reshape(-1), return_inverse=True)
+    # take only the vertices in the boundary
+    # and stack them with zeros and ones so we can use dot
+    # products to transform them all over the place
+    vertices_tf = np.column_stack(
+        (vertices_2D[unique], np.zeros(len(unique)), np.ones(len(unique)))
+    )
+    # the indices of vertices_tf
+    boundary = inverse.reshape((-1, 2))
+
+    # now create the normals for the plane each slice will lie on
+    # consider the simple path with 3 vertices and therefore 2 vectors:
+    # - the first plane will be exactly along the first vector
+    # - the second plane will be the average of the two vectors
+    # - the last plane will be exactly along the last vector
+    # and each plane origin will be the corresponding vertex on the path
+    vector = util.unitize(path[1:] - path[:-1])
+    # unitize instead of / 2 as they may be degenerate / zero
+    vector_mean = util.unitize(vector[1:] + vector[:-1])
+    # collect the vectors into plane normals
+    normal = np.concatenate([[vector[0]], vector_mean, [vector[-1]]], axis=0)
+
+    if closed and connect:
+        # if we have a closed loop average the first and last planes
+        normal[0] = util.unitize(normal[[0, -1]].mean(axis=0))
+
+    # planes should have one unit normal and one vertex each
+    assert normal.shape == path.shape
+
+    # get the spherical coordinates for the normal vectors
+    theta, phi = util.vector_to_spherical(normal).T
+
+    # collect the trig values into numpy arrays we can compose into matrices
+    cos_theta, sin_theta = np.cos(theta), np.sin(theta)
+    cos_phi, sin_phi = np.cos(phi), np.sin(phi)
+    cos_roll, sin_roll = np.cos(angles), np.sin(angles)
+
+    # we want a rotation which will be the identity for a Z+ vector
+    # this was constructed and unrolled from the following sympy block
+    # theta, phi, roll = sp.symbols("theta phi roll")
+    # matrix = (
+    #     tf.rotation_matrix(roll, [0, 0, 1]) @
+    #     tf.rotation_matrix(phi, [1, 0, 0]) @
+    #     tf.rotation_matrix((sp.pi / 2) - theta, [0, 0, 1])
+    # ).inv()
+    # matrix.simplify()
+
+    # shorthand for stacking
+    zeros = np.zeros(len(theta))
+    ones = np.ones(len(theta))
+
+    # stack initially as one unrolled matrix per row
+    transforms = np.column_stack(
+        [
+            -sin_roll * cos_phi * cos_theta + sin_theta * cos_roll,
+            sin_roll * sin_theta + cos_phi * cos_roll * cos_theta,
+            sin_phi * cos_theta,
+            path[:, 0],
+            -sin_roll * sin_theta * cos_phi - cos_roll * cos_theta,
+            -sin_roll * cos_theta + sin_theta * cos_phi * cos_roll,
+            sin_phi * sin_theta,
+            path[:, 1],
+            sin_phi * sin_roll,
+            -sin_phi * cos_roll,
+            cos_phi,
+            path[:, 2],
+            zeros,
+            zeros,
+            zeros,
+            ones,
+        ]
+    ).reshape((-1, 4, 4))
 
-    # Compute 3D locations of those vertices
-    verts_3d = np.c_[verts_2d, np.zeros(n)]
-    verts_3d = tf.transform_points(verts_3d, tf_mat)
+    if tol.strict:
+        # make sure that each transform moves the Z+ vector to the requested normal
+        for n, matrix in zip(normal, transforms):
+            check = tf.transform_points([[0.0, 0.0, 1.0]], matrix, translate=False)[0]
+            assert np.allclose(check, n)
+
+    # apply transforms to prebaked homogeneous coordinates
+    vertices_3D = np.concatenate(
+        [np.dot(vertices_tf, matrix.T) for matrix in transforms], axis=0
+    )[:, :3]
+
+    # now construct the faces with one group of boundary faces per slice
+    stride = len(unique)
+    boundary_next = boundary + stride
+    faces_slice = np.column_stack(
+        [boundary, boundary_next[:, :1], boundary_next[:, ::-1], boundary[:, 1:]]
+    ).reshape((-1, 3))
 
-    if closed:
-        vertices = []
-        faces = []
-    else:
-        base_verts_3d = np.c_[base_verts_2d, np.zeros(len(base_verts_2d))]
-        base_verts_3d = tf.transform_points(base_verts_3d, tf_mat)
-
-        vertices = [base_verts_3d]
-        faces = [faces_2d]
-
-    # Compute plane normals for each turn --
-    # each turn induces a plane halfway between the two vectors
-    v1s = util.unitize(path[1:-1] - path[:-2])
-    v2s = util.unitize(path[1:-1] - path[2:])
-    norms = np.cross(np.cross(v1s, v2s), v1s + v2s)
-    norms[(norms == 0.0).all(1)] = v1s[(norms == 0.0).all(1)]
-    norms = util.unitize(norms)
-    final_v1 = util.unitize(path[-1] - path[-2])
-    norms = np.vstack((norms, final_v1))
-    v1s = np.vstack((v1s, final_v1))
-
-    # Create all side walls by projecting the 3d vertices into each plane
-    # in succession
-    for i in range(len(norms)):
-        verts_3d_prev = verts_3d
-
-        # Rotate if needed
-        if angles is not None:
-            tf_mat = tf.rotation_matrix(angles[i], norms[i], path[i])
-            verts_3d_prev = tf.transform_points(verts_3d_prev, tf_mat)
-
-        # Project vertices onto plane in 3D
-        ds = np.einsum("ij,j->i", (path[i + 1] - verts_3d_prev), norms[i])
-        ds = ds / np.dot(v1s[i], norms[i])
-
-        verts_3d_new = np.einsum("i,j->ij", ds, v1s[i]) + verts_3d_prev
-
-        # Add to face and vertex lists
-        new_faces = [[i + n, (i + 1) % n, i] for i in range(n)]
-        new_faces.extend([[(i - 1) % n + n, i + n, i] for i in range(n)])
-
-        if i == len(norms) - 1 and closed:  # add faces to close mesh
-            N = 2 * n * len(norms)  # total number of vertices in mesh
-            new_faces.extend(
-                [[i + n - (N - n), (i + 1) % n + n, i + n] for i in range(n)]
-            )
-            new_faces.extend(
-                [[(i - 1) % n + n - (N - n), i + n - (N - n), i + n] for i in range(n)]
-            )
-
-        # save faces and vertices into a sequence
-        faces.append(np.array(new_faces))
-        vertices.append(np.vstack((verts_3d, verts_3d_new)))
-
-        verts_3d = verts_3d_new
-
-    # do the main stack operation from a sequence to (n,3) arrays
-    # doing one vstack provides a substantial speedup by
-    # avoiding a bunch of temporary allocations
-    vertices, faces = util.append_faces(vertices, faces)
-
-    # Create final cap
-    if not closed:
-        x, y, z = util.generate_basis(path[-1] - path[-2])
-        vecs = verts_3d - path[-1]
-        coords = np.c_[np.einsum("ij,j->i", vecs, x), np.einsum("ij,j->i", vecs, y)]
-        base_verts_2d, faces_2d = triangulate_polygon(Polygon(coords), **kwargs)
-        base_verts_3d = (
-            np.einsum("i,j->ij", base_verts_2d[:, 0], x)
-            + np.einsum("i,j->ij", base_verts_2d[:, 1], y)
-        ) + path[-1]
-        faces = np.vstack((faces, faces_2d + len(vertices)))
-        vertices = np.vstack((vertices, base_verts_3d))
-
-    return Trimesh(vertices, faces)
+    # offset the slices
+    faces = [faces_slice + offset for offset in np.arange(len(path) - 1) * stride]
+
+    # connect only applies to closed paths
+    if closed and connect:
+        # the last slice will not be required
+        max_vertex = (len(path) - 1) * stride
+        # clip off the duplicated vertices
+        vertices_3D = vertices_3D[:max_vertex]
+        # apply the modulus in-place to a conservative subset
+        faces[-1] %= max_vertex
+    elif cap:
+        # these are indices of `vertices_2D` that were not on the boundary
+        # which can happen for triangulation algorithms that added vertices
+        # we don't currently support that but you could append the unconsumed
+        # vertices and then update the mapping below to reflect that
+        unconsumed = set(unique).difference(faces_2D.ravel())
+        if len(unconsumed) > 0:
+            raise NotImplementedError("triangulation added vertices: no logic to cap!")
+
+        # map the 2D faces to the order we used
+        mapped = np.zeros(unique.max() + 2, dtype=np.int64)
+        mapped[unique] = np.arange(len(unique))
+
+        # now should correspond to the first vertex block
+        cap_zero = mapped[faces_2D]
+        # winding will be along +Z so flip for the bottom cap
+        faces.append(np.fliplr(cap_zero))
+        # offset the end cap
+        faces.append(cap_zero + stride * (len(path) - 1))
+
+    if kwargs is None:
+        kwargs = {}
+
+    if "process" not in kwargs:
+        # we should be constructing clean meshes here
+        # so we don't need to run an expensive verex merge
+        kwargs["process"] = False
+
+    # stack the faces used
+    faces = np.concatenate(faces, axis=0)
+
+    # generate the mesh from the face data
+    mesh = Trimesh(vertices=vertices_3D, faces=faces, **kwargs)
+
+    if tol.strict:
+        # we should not have included any unused vertices
+        assert len(np.unique(faces)) == len(vertices_3D)
+
+        if cap:
+            # mesh should always be a volume if cap is true
+            assert mesh.is_volume
+
+        if closed and connect:
+            assert mesh.is_volume
+            assert mesh.body_count == 1
+
+    return mesh
 
 
 def extrude_triangulation(
@@ -606,12 +702,10 @@ def box(
     geometry : trimesh.Trimesh
       Mesh of a cuboid
     """
-    # vertices of the cube
-    vertices = np.array(
-        [0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1],
-        order="C",
-        dtype=np.float64,
-    ).reshape((-1, 3))
+    # vertices of the cube from reference
+    vertices = np.array(_data["box"]["vertices"], order="C", dtype=np.float64)
+    faces = np.array(_data["box"]["faces"], order="C", dtype=np.int64)
+    face_normals = np.array(_data["box"]["face_normals"], order="C", dtype=np.float64)
 
     # resize cube based on passed extents
     if bounds is not None:
@@ -633,88 +727,6 @@ def box(
         vertices -= 0.5
         extents = np.asarray((1.0, 1.0, 1.0), dtype=np.float64)
 
-    # hardcoded face indices
-    # TODO : make less lol?
-    faces = [
-        1,
-        3,
-        0,
-        4,
-        1,
-        0,
-        0,
-        3,
-        2,
-        2,
-        4,
-        0,
-        1,
-        7,
-        3,
-        5,
-        1,
-        4,
-        5,
-        7,
-        1,
-        3,
-        7,
-        2,
-        6,
-        4,
-        2,
-        2,
-        7,
-        6,
-        6,
-        5,
-        4,
-        7,
-        5,
-        6,
-    ]
-    faces = np.array(faces, order="C", dtype=np.int64).reshape((-1, 3))
-
-    face_normals = [
-        -1,
-        0,
-        0,
-        0,
-        -1,
-        0,
-        -1,
-        0,
-        0,
-        0,
-        0,
-        -1,
-        0,
-        0,
-        1,
-        0,
-        -1,
-        0,
-        0,
-        0,
-        1,
-        0,
-        1,
-        0,
-        0,
-        0,
-        -1,
-        0,
-        1,
-        0,
-        1,
-        0,
-        0,
-        1,
-        0,
-        0,
-    ]
-    face_normals = np.asanyarray(face_normals, order="C", dtype=np.float64).reshape(-1, 3)
-
     if "metadata" not in kwargs:
         kwargs["metadata"] = {}
     kwargs["metadata"].update({"shape": "box", "extents": extents})
@@ -744,111 +756,9 @@ def icosahedron(**kwargs) -> Trimesh:
     ico : trimesh.Trimesh
       Icosahederon centered at the origin.
     """
-    t = (1.0 + 5.0**0.5) / 2.0
-    vertices = [
-        -1,
-        t,
-        0,
-        1,
-        t,
-        0,
-        -1,
-        -t,
-        0,
-        1,
-        -t,
-        0,
-        0,
-        -1,
-        t,
-        0,
-        1,
-        t,
-        0,
-        -1,
-        -t,
-        0,
-        1,
-        -t,
-        t,
-        0,
-        -1,
-        t,
-        0,
-        1,
-        -t,
-        0,
-        -1,
-        -t,
-        0,
-        1,
-    ]
-    faces = [
-        0,
-        11,
-        5,
-        0,
-        5,
-        1,
-        0,
-        1,
-        7,
-        0,
-        7,
-        10,
-        0,
-        10,
-        11,
-        1,
-        5,
-        9,
-        5,
-        11,
-        4,
-        11,
-        10,
-        2,
-        10,
-        7,
-        6,
-        7,
-        1,
-        8,
-        3,
-        9,
-        4,
-        3,
-        4,
-        2,
-        3,
-        2,
-        6,
-        3,
-        6,
-        8,
-        3,
-        8,
-        9,
-        4,
-        9,
-        5,
-        2,
-        4,
-        11,
-        6,
-        2,
-        10,
-        8,
-        6,
-        7,
-        9,
-        8,
-        1,
-    ]
-    # scale vertices so each vertex radius is 1.0
-    vertices = np.reshape(vertices, (-1, 3)) / np.sqrt(2.0 + t)
-    faces = np.reshape(faces, (-1, 3))
-
+    # get stored pre-baked primitive values
+    vertices = np.array(_data["icosahedron"]["vertices"], dtype=np.float64)
+    faces = np.array(_data["icosahedron"]["faces"], dtype=np.int64)
     return Trimesh(
         vertices=vertices, faces=faces, process=kwargs.pop("process", False), **kwargs
     )
diff --git a/trimesh/interfaces/gmsh.py b/trimesh/interfaces/gmsh.py
index 524a34fd..93e54c4a 100644
--- a/trimesh/interfaces/gmsh.py
+++ b/trimesh/interfaces/gmsh.py
@@ -1,15 +1,27 @@
 import os
 import tempfile
+import warnings
 
 import numpy as np
 
 from ..constants import log
 
-# the old `gmsh-sdk` package is deprectated and
+# the old `gmsh-sdk` package is deprecated and
 # has a different incompatible API!
 _min_gmsh = (4, 12, 1)
 
 
+_warning = " ".join(
+    [
+        "`trimesh.interfaces.gmsh` is deprecated and will be removed January 2025!",
+        "There are *many* gmsh options on PyPi: `scikit-gmsh` `gmsh` `pygmsh` `gmsh-sdk`,",
+        "users should pick one of those and use it directly. If STEP loading is the only",
+        "thing needed you may want `pip install cascadio` which uses OpenCASCADE more",
+        "directly and will immediatly enable STEP as a loadable format in trimesh.",
+    ]
+)
+
+
 def load_gmsh(file_name, gmsh_args=None):
     """
     Returns a surface mesh from CAD model in Open Cascade
@@ -38,6 +50,8 @@ def load_gmsh(file_name, gmsh_args=None):
     mesh : trimesh.Trimesh
       Surface mesh of input geometry
     """
+    warnings.warn(_warning, category=DeprecationWarning, stacklevel=2)
+
     # use STL as an intermediate format
     # do import here to avoid very occasional segfaults
     import gmsh
@@ -138,7 +152,7 @@ def load_gmsh(file_name, gmsh_args=None):
     return kwargs
 
 
-def to_volume(mesh, file_type='msh', max_element=None, mesher_id=1) -> bytes:
+def to_volume(mesh, file_type="msh", max_element=None, mesher_id=1) -> bytes:
     """
     Convert a surface mesh to a 3D volume mesh generated by gmsh.
 
@@ -179,6 +193,8 @@ def to_volume(mesh, file_type='msh', max_element=None, mesher_id=1) -> bytes:
       MSH data, only returned if file_name is None
 
     """
+    warnings.warn(_warning, category=DeprecationWarning, stacklevel=2)
+
     # do import here to avoid very occasional segfaults
     import gmsh
 
@@ -197,17 +213,17 @@ def to_volume(mesh, file_type='msh', max_element=None, mesher_id=1) -> bytes:
     if max_element is None:
         max_element = np.sqrt(np.mean(mesh.area_faces))
 
-
-    file_type = file_type.lower().strip('.')
+    file_type = file_type.lower().strip(".")
     if file_type not in ["bdf", "msh", "inp", "diff", "mesh"]:
         raise ValueError(
             "Only Nastran (`bdf`), Gmsh (`msh`), Abaqus (`inp`), "
             + "Diffpack (`diff`) and Inria Medit (`mesh`) formats "
-            + "are available!")
+            + "are available!"
+        )
 
     # use a temporary directory for input and output
     with tempfile.TemporaryDirectory() as D:
-        mesh_file = os.path.join(D, 'input.stl')
+        mesh_file = os.path.join(D, "input.stl")
         mesh.export(mesh_file)
 
         # starts Gmsh Python API script
@@ -235,7 +251,7 @@ def to_volume(mesh, file_type='msh', max_element=None, mesher_id=1) -> bytes:
         p2 = gmsh.model.addPhysicalGroup(dim2, [tag2])
         gmsh.model.setPhysicalName(dim, p2, "Nastran_bdf")
 
-        out_path = os.path.join(D, f'temp.{file_type}')
+        out_path = os.path.join(D, f"temp.{file_type}")
         gmsh.write(out_path)
         with open(out_path, "rb") as f:
             data = f.read()
diff --git a/trimesh/resources/creation.json b/trimesh/resources/creation.json
new file mode 100644
index 00000000..91d55801
--- /dev/null
+++ b/trimesh/resources/creation.json
@@ -0,0 +1,1 @@
+{"box":{"vertices":[[0.0,0.0,0.0],[0.0,0.0,1.0],[0.0,1.0,0.0],[0.0,1.0,1.0],[1.0,0.0,0.0],[1.0,0.0,1.0],[1.0,1.0,0.0],[1.0,1.0,1.0]],"faces":[[1,3,0],[4,1,0],[0,3,2],[2,4,0],[1,7,3],[5,1,4],[5,7,1],[3,7,2],[6,4,2],[2,7,6],[6,5,4],[7,5,6]],"face_normals":[[-1,0,0],[0,-1,0],[-1,0,0],[0,0,-1],[0,0,1],[0,-1,0],[0,0,1],[0,1,0],[0,0,-1],[0,1,0],[1,0,0],[1,0,0]]},"icosahedron":{"vertices":[[-0.5257311121191336,0.85065080835204,0.0],[0.5257311121191336,0.85065080835204,0.0],[-0.5257311121191336,-0.85065080835204,0.0],[0.5257311121191336,-0.85065080835204,0.0],[0.0,-0.5257311121191336,0.85065080835204],[0.0,0.5257311121191336,0.85065080835204],[0.0,-0.5257311121191336,-0.85065080835204],[0.0,0.5257311121191336,-0.85065080835204],[0.85065080835204,0.0,-0.5257311121191336],[0.85065080835204,0.0,0.5257311121191336],[-0.85065080835204,0.0,-0.5257311121191336],[-0.85065080835204,0.0,0.5257311121191336]],"faces":[[0,11,5],[0,5,1],[0,1,7],[0,7,10],[0,10,11],[1,5,9],[5,11,4],[11,10,2],[10,7,6],[7,1,8],[3,9,4],[3,4,2],[3,2,6],[3,6,8],[3,8,9],[4,9,5],[2,4,11],[6,2,10],[8,6,7],[9,8,1]]}}
\ No newline at end of file
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 44c92aa1..8fedecfc 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -197,6 +197,7 @@ True
 """
 
 import numpy as np
+from numpy.typing import ArrayLike, NDArray
 
 _IDENTITY = np.eye(4)
 _IDENTITY.flags["WRITEABLE"] = False
@@ -228,10 +229,20 @@ def translation_matrix(direction):
     """
     # are we 2D or 3D
     dim = len(direction)
+
     # start with identity matrix
-    M = np.identity(dim + 1)
+
+    if any("sympy" in str(type(v)) for v in direction):
+        # if we have been passed input values as sympy.Symbol
+        from sympy import eye
+
+        M = eye(dim + 1)
+    else:
+        M = np.eye(dim + 1)
+
     # apply the offset
     M[:dim, dim] = direction[:dim]
+
     return M
 
 
@@ -344,7 +355,7 @@ def rotation_matrix(angle, direction, point=None):
     True
 
     """
-    if type(angle).__name__ == "Symbol":
+    if "sympy" in str(type(angle)):
         # special case sympy symbolic angles
         import sympy as sp
 
@@ -357,6 +368,7 @@ def rotation_matrix(angle, direction, point=None):
         cosa = np.cos(angle)
 
     direction = unit_vector(direction[:3])
+
     # rotation matrix around unit vector
     M = np.diag([cosa, cosa, cosa, 1.0])
     M[:3, :3] += np.outer(direction, direction) * (1.0 - cosa)
@@ -1124,7 +1136,7 @@ def euler_matrix(ai, aj, ak, axes="sxyz"):
     if parity:
         ai, aj, ak = -ai, -aj, -ak
 
-    if type(ai).__name__ == "Symbol":
+    if "sympy" in str(type(ai)):
         # if we have been passed input values as sympy.Symbol
         # use symbolic cosine and identity matrix
         from sympy import cos, eye, sin
@@ -2131,7 +2143,9 @@ def spherical_matrix(theta, phi, axes="sxyz"):
     return result
 
 
-def transform_points(points, matrix, translate=True):
+def transform_points(
+    points: ArrayLike, matrix: ArrayLike, translate: bool = True
+) -> NDArray[np.float64]:
     """
     Returns points rotated by a homogeneous
     transformation matrix.
diff --git a/trimesh/util.py b/trimesh/util.py
index aaf03d00..39d12ee8 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -470,7 +470,7 @@ def vector_to_spherical(cartesian):
 
 def spherical_to_vector(spherical):
     """
-    Convert a set of (n, 2) spherical vectors to (n, 3) vectors
+    Convert an array of `(n, 2)` spherical angles to `(n, 3)` unit vectors.
 
     Parameters
     ------------
@@ -489,8 +489,7 @@ def spherical_to_vector(spherical):
     theta, phi = spherical.T
     st, ct = np.sin(theta), np.cos(theta)
     sp, cp = np.sin(phi), np.cos(phi)
-    vectors = np.column_stack((ct * sp, st * sp, cp))
-    return vectors
+    return np.column_stack((ct * sp, st * sp, cp))
 
 
 def pairwise(iterable):


Test patch:
diff --git a/tests/data/random_tree.json b/tests/data/random_tree.json
new file mode 100644
index 00000000..98de02f3
--- /dev/null
+++ b/tests/data/random_tree.json
@@ -0,0 +1,1 @@
+[[0, 710], [0, 906], [0, 758], [1, 205], [1, 82], [2, 750], [3, 323], [3, 209], [7, 999], [8, 394], [9, 298], [9, 224], [10, 850], [12, 619], [13, 179], [14, 178], [14, 218], [15, 740], [15, 307], [16, 248], [19, 788], [20, 817], [20, 155], [22, 512], [22, 773], [23, 534], [27, 75], [27, 994], [28, 718], [30, 708], [30, 353], [31, 810], [32, 683], [33, 191], [33, 20], [34, 955], [36, 635], [36, 329], [36, 964], [36, 421], [37, 511], [38, 876], [39, 347], [39, 712], [39, 193], [40, 552], [40, 937], [40, 956], [41, 18], [41, 789], [41, 133], [42, 843], [43, 19], [43, 803], [43, 918], [45, 979], [46, 467], [46, 625], [47, 692], [48, 469], [49, 854], [51, 720], [56, 342], [59, 576], [61, 902], [62, 526], [62, 184], [62, 226], [63, 171], [63, 451], [63, 974], [64, 233], [64, 995], [65, 456], [65, 117], [67, 89], [67, 481], [67, 950], [67, 340], [70, 328], [71, 546], [72, 857], [72, 360], [72, 610], [73, 547], [75, 110], [75, 483], [77, 647], [78, 614], [80, 620], [80, 258], [82, 252], [82, 352], [82, 665], [83, 771], [83, 734], [84, 422], [85, 648], [85, 903], [86, 827], [86, 947], [86, 958], [89, 654], [91, 70], [92, 587], [93, 251], [93, 299], [93, 501], [95, 180], [95, 836], [95, 235], [97, 81], [100, 754], [100, 8], [101, 108], [101, 111], [101, 64], [102, 766], [103, 129], [103, 453], [103, 543], [103, 554], [103, 48], [107, 924], [107, 154], [107, 331], [107, 425], [111, 264], [111, 424], [113, 502], [116, 149], [117, 355], [117, 851], [117, 908], [118, 426], [118, 504], [118, 779], [118, 490], [119, 442], [120, 589], [121, 756], [121, 916], [122, 496], [124, 829], [124, 998], [126, 522], [126, 765], [127, 273], [127, 484], [127, 351], [127, 666], [130, 957], [130, 869], [132, 561], [133, 335], [133, 738], [133, 230], [135, 649], [135, 632], [136, 782], [138, 287], [141, 832], [142, 69], [143, 76], [144, 978], [145, 246], [146, 212], [149, 460], [149, 638], [149, 436], [150, 105], [151, 499], [151, 537], [152, 819], [153, 339], [153, 847], [154, 141], [154, 846], [155, 984], [155, 988], [157, 256], [158, 787], [159, 878], [159, 943], [159, 751], [161, 701], [162, 584], [163, 670], [164, 882], [165, 586], [167, 276], [167, 63], [169, 47], [170, 883], [170, 904], [173, 570], [174, 448], [179, 592], [182, 550], [182, 10], [183, 315], [184, 446], [186, 705], [186, 309], [188, 297], [189, 521], [191, 320], [192, 728], [193, 188], [193, 488], [193, 804], [194, 439], [195, 234], [195, 755], [195, 774], [196, 608], [196, 907], [199, 513], [199, 354], [202, 890], [202, 925], [203, 760], [204, 400], [206, 693], [206, 934], [208, 283], [208, 573], [209, 143], [209, 674], [210, 790], [211, 772], [212, 823], [212, 262], [212, 3], [215, 524], [218, 745], [220, 385], [221, 816], [221, 896], [222, 901], [223, 66], [224, 465], [224, 936], [225, 443], [225, 377], [226, 71], [226, 767], [227, 240], [228, 673], [230, 951], [235, 221], [236, 838], [239, 600], [241, 383], [244, 227], [244, 975], [245, 138], [245, 163], [245, 748], [247, 314], [248, 194], [249, 219], [256, 90], [256, 588], [256, 613], [258, 739], [260, 583], [261, 497], [262, 781], [262, 407], [263, 895], [265, 21], [265, 699], [265, 793], [266, 168], [266, 326], [266, 545], [266, 204], [267, 225], [268, 880], [276, 42], [277, 440], [277, 833], [281, 405], [281, 22], [282, 378], [285, 158], [286, 762], [287, 557], [288, 74], [293, 244], [294, 292], [294, 634], [296, 597], [296, 892], [296, 211], [297, 247], [298, 271], [298, 601], [300, 132], [300, 818], [301, 596], [303, 636], [306, 336], [307, 41], [308, 269], [308, 549], [309, 761], [309, 858], [309, 38], [309, 519], [310, 52], [310, 844], [312, 968], [315, 49], [317, 106], [317, 657], [318, 605], [319, 333], [322, 571], [323, 137], [323, 744], [324, 311], [325, 920], [327, 253], [329, 837], [330, 909], [331, 645], [332, 84], [332, 985], [333, 229], [333, 714], [336, 855], [338, 123], [338, 459], [340, 275], [340, 174], [340, 260], [341, 217], [341, 277], [342, 668], [342, 849], [343, 437], [343, 621], [343, 877], [345, 341], [345, 485], [345, 991], [346, 840], [348, 9], [350, 165], [351, 831], [353, 356], [354, 664], [355, 660], [356, 182], [358, 308], [360, 631], [360, 80], [361, 987], [362, 131], [365, 420], [365, 606], [365, 466], [366, 55], [367, 266], [367, 637], [367, 196], [367, 124], [369, 848], [370, 387], [374, 72], [375, 797], [376, 835], [377, 85], [379, 93], [380, 444], [380, 952], [380, 455], [381, 445], [384, 828], [384, 578], [387, 458], [387, 926], [388, 862], [391, 517], [392, 973], [392, 199], [393, 417], [394, 5], [394, 305], [394, 62], [395, 477], [396, 933], [397, 607], [397, 317], [397, 970], [400, 368], [400, 122], [400, 558], [401, 800], [403, 100], [404, 369], [406, 769], [407, 509], [408, 187], [411, 32], [411, 741], [412, 859], [414, 29], [415, 577], [417, 791], [419, 594], [421, 361], [424, 661], [424, 681], [424, 306], [425, 727], [425, 101], [425, 792], [427, 743], [427, 962], [427, 492], [427, 30], [428, 688], [429, 399], [430, 17], [430, 536], [432, 945], [433, 222], [433, 696], [435, 539], [436, 449], [436, 875], [436, 764], [436, 913], [441, 170], [441, 702], [444, 134], [445, 655], [446, 358], [452, 591], [453, 156], [455, 295], [455, 401], [456, 169], [457, 161], [457, 441], [458, 250], [458, 652], [459, 894], [459, 864], [461, 408], [462, 538], [463, 775], [466, 195], [468, 12], [469, 820], [469, 202], [469, 395], [471, 671], [477, 626], [478, 515], [478, 719], [478, 717], [481, 564], [481, 826], [482, 13], [482, 103], [483, 125], [485, 866], [487, 327], [487, 102], [488, 54], [488, 150], [489, 852], [490, 523], [490, 872], [492, 863], [492, 976], [493, 630], [493, 966], [497, 26], [497, 40], [498, 429], [498, 706], [499, 929], [500, 899], [501, 259], [502, 796], [503, 144], [504, 409], [505, 213], [505, 67], [506, 713], [506, 815], [507, 533], [508, 996], [509, 874], [509, 121], [510, 853], [510, 932], [514, 542], [514, 798], [515, 551], [516, 68], [517, 953], [519, 430], [519, 312], [520, 255], [520, 603], [523, 25], [528, 948], [530, 824], [531, 569], [531, 993], [532, 967], [533, 166], [533, 669], [533, 898], [534, 282], [535, 856], [536, 715], [537, 373], [537, 942], [538, 915], [540, 704], [541, 691], [542, 350], [542, 954], [545, 96], [547, 476], [548, 296], [550, 822], [553, 280], [553, 364], [555, 206], [556, 249], [557, 23], [557, 46], [559, 370], [560, 274], [560, 290], [561, 176], [561, 192], [561, 404], [562, 716], [564, 270], [565, 148], [569, 403], [571, 265], [573, 128], [574, 157], [574, 707], [574, 860], [577, 930], [578, 641], [579, 432], [581, 238], [581, 679], [581, 687], [581, 799], [582, 595], [582, 682], [583, 568], [583, 972], [585, 582], [587, 472], [587, 34], [589, 313], [589, 495], [593, 402], [596, 388], [597, 57], [597, 153], [598, 900], [599, 267], [600, 622], [601, 291], [601, 431], [601, 830], [603, 389], [604, 639], [608, 1], [610, 302], [610, 553], [610, 928], [610, 914], [611, 120], [612, 961], [612, 33], [614, 776], [616, 99], [616, 479], [616, 982], [617, 289], [617, 263], [618, 709], [620, 491], [620, 37], [622, 757], [622, 812], [622, 397], [624, 633], [624, 555], [624, 61], [625, 139], [626, 207], [626, 541], [626, 36], [627, 321], [627, 423], [629, 478], [632, 612], [633, 87], [635, 435], [635, 646], [635, 842], [636, 730], [638, 921], [640, 201], [641, 886], [642, 406], [642, 910], [645, 434], [645, 135], [647, 629], [647, 808], [648, 643], [648, 733], [649, 473], [650, 486], [650, 510], [652, 228], [653, 415], [654, 887], [655, 140], [655, 821], [656, 413], [656, 917], [656, 167], [659, 729], [660, 785], [662, 73], [664, 318], [664, 759], [664, 562], [666, 663], [666, 784], [666, 345], [669, 136], [671, 814], [671, 889], [672, 324], [672, 971], [673, 611], [674, 689], [676, 410], [678, 690], [681, 452], [682, 532], [684, 200], [686, 650], [688, 572], [689, 294], [689, 677], [689, 83], [689, 749], [691, 753], [692, 580], [693, 770], [694, 742], [694, 92], [695, 91], [695, 386], [695, 529], [695, 731], [695, 507], [696, 480], [696, 653], [697, 593], [697, 210], [697, 980], [698, 992], [699, 366], [700, 115], [702, 427], [703, 752], [705, 286], [707, 615], [708, 367], [714, 464], [714, 173], [715, 667], [715, 983], [715, 514], [716, 359], [716, 556], [716, 118], [716, 989], [717, 414], [717, 624], [718, 685], [718, 700], [718, 811], [718, 873], [720, 231], [722, 97], [722, 372], [722, 119], [722, 348], [723, 272], [723, 735], [726, 678], [727, 190], [727, 675], [729, 506], [730, 698], [731, 598], [732, 651], [734, 14], [734, 616], [735, 189], [735, 376], [735, 301], [736, 181], [739, 332], [742, 737], [744, 215], [746, 535], [746, 293], [747, 109], [747, 223], [748, 489], [749, 16], [749, 563], [749, 960], [751, 977], [752, 343], [756, 623], [758, 186], [759, 468], [763, 938], [764, 939], [764, 78], [765, 7], [766, 658], [767, 806], [767, 891], [770, 462], [772, 747], [772, 927], [773, 79], [773, 618], [774, 380], [775, 334], [776, 6], [776, 530], [777, 15], [780, 627], [785, 575], [790, 428], [791, 310], [792, 159], [794, 95], [795, 599], [795, 802], [796, 482], [796, 795], [798, 346], [798, 642], [800, 39], [801, 454], [802, 44], [802, 617], [802, 392], [803, 198], [804, 881], [807, 59], [807, 500], [807, 303], [808, 834], [808, 365], [810, 865], [810, 457], [811, 778], [812, 418], [813, 447], [813, 694], [816, 919], [817, 304], [817, 861], [817, 885], [818, 88], [818, 288], [818, 396], [819, 525], [819, 463], [819, 946], [819, 164], [822, 242], [822, 145], [824, 172], [824, 813], [825, 384], [826, 723], [827, 888], [828, 51], [829, 98], [829, 393], [829, 986], [829, 697], [831, 382], [831, 726], [831, 870], [834, 278], [835, 450], [836, 470], [836, 783], [837, 325], [838, 374], [839, 794], [841, 316], [841, 65], [842, 721], [842, 2], [844, 197], [844, 965], [845, 390], [845, 963], [846, 559], [847, 214], [848, 662], [849, 50], [849, 363], [849, 746], [850, 494], [850, 997], [853, 379], [853, 330], [855, 544], [855, 412], [857, 371], [857, 609], [857, 319], [857, 736], [858, 565], [860, 185], [861, 867], [862, 349], [862, 944], [864, 4], [864, 777], [865, 438], [865, 162], [865, 493], [866, 805], [869, 241], [869, 801], [869, 807], [870, 114], [871, 285], [871, 239], [873, 912], [874, 644], [874, 487], [875, 461], [877, 183], [880, 505], [881, 531], [882, 780], [882, 338], [884, 841], [884, 911], [884, 941], [885, 871], [886, 160], [888, 216], [889, 528], [889, 152], [891, 232], [892, 684], [892, 786], [892, 381], [893, 763], [894, 203], [895, 602], [895, 680], [895, 686], [895, 151], [896, 254], [896, 676], [896, 724], [896, 585], [897, 337], [898, 433], [902, 113], [905, 711], [905, 839], [906, 540], [906, 672], [909, 931], [910, 905], [911, 11], [911, 503], [913, 58], [913, 640], [913, 579], [914, 146], [915, 703], [916, 220], [919, 357], [920, 112], [920, 940], [921, 419], [922, 391], [922, 548], [922, 261], [922, 126], [923, 104], [923, 566], [923, 45], [924, 659], [924, 28], [926, 560], [927, 959], [929, 60], [929, 498], [930, 574], [931, 94], [931, 474], [931, 581], [933, 981], [934, 142], [935, 281], [937, 177], [937, 949], [938, 416], [938, 300], [939, 237], [939, 520], [939, 695], [940, 35], [940, 175], [940, 567], [941, 879], [941, 508], [942, 897], [943, 590], [944, 279], [944, 768], [945, 344], [945, 362], [945, 86], [953, 935], [955, 257], [956, 245], [956, 990], [956, 923], [957, 656], [958, 475], [958, 107], [959, 527], [960, 375], [960, 922], [964, 56], [965, 268], [967, 27], [968, 116], [969, 604], [969, 322], [970, 43], [972, 398], [972, 411], [976, 628], [977, 31], [979, 77], [980, 893], [981, 130], [984, 516], [984, 809], [985, 722], [985, 884], [986, 284], [986, 732], [988, 24], [989, 147], [989, 208], [990, 243], [991, 53], [991, 471], [992, 127], [993, 518], [995, 845], [995, 969], [997, 725], [997, 825], [997, 236], [999, 868]]
\ No newline at end of file
diff --git a/tests/test_creation.py b/tests/test_creation.py
index effa23ba..e7f1abd6 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -170,9 +170,7 @@ class CreationTest(g.unittest.TestCase):
 
         # Extrude
         for engine in self.engines:
-            mesh = g.trimesh.creation.sweep_polygon(
-                poly, path_closed, closed=True, engine=engine
-            )
+            mesh = g.trimesh.creation.sweep_polygon(poly, path_closed, engine=engine)
             assert mesh.is_volume
 
     def test_annulus(self):
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 77e787f3..b5556a31 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -206,13 +206,13 @@ class GraphTests(g.unittest.TestCase):
             return
 
         tf = g.trimesh.transformations
-        # start with creating a random tree
-        edgelist = {}
-        tree = g.nx.random_tree(n=1000, seed=0, create_using=g.nx.DiGraph)
-        edges = list(tree.edges)
+        # start with a known good random tree
+        edges = [tuple(row) for row in g.data["random_tree"]]
+        tree = g.nx.from_edgelist(edges, create_using=g.nx.DiGraph)
 
         r_choices = g.random((len(edges), 2))
         r_matrices = g.random_transforms(len(edges))
+        edgelist = {}
         for e, r_choice, r_mat in zip(edges, r_choices, r_matrices):
             data = {}
             if r_choice[0] > 0.5:
diff --git a/tests/test_step.py b/tests/test_step.py
index 33ed248b..f60a92e0 100644
--- a/tests/test_step.py
+++ b/tests/test_step.py
@@ -18,6 +18,7 @@ class STEPTests(g.unittest.TestCase):
         b = g.get_mesh("featuretype.STEP")
         assert len(b.geometry) == 1
         m = next(iter(b.geometry.values()))
+        m.merge_vertices(merge_tex=True, merge_norm=True)
         assert m.is_watertight
 
 
diff --git a/tests/test_sweep.py b/tests/test_sweep.py
new file mode 100644
index 00000000..402f8a2d
--- /dev/null
+++ b/tests/test_sweep.py
@@ -0,0 +1,130 @@
+import numpy as np
+from pyinstrument import Profiler
+from shapely.geometry import Point, Polygon
+
+import trimesh
+from trimesh.creation import sweep_polygon
+
+trimesh.tol.strict = True
+
+
+def arc_2d(final_angle, d=1, start_angle=0, splits=20):
+    xs = np.linspace(start_angle, final_angle, splits)
+    return np.stack((d / 2 * np.cos(xs), d / 2 * np.sin(xs)), axis=1)
+
+
+def test_simple_closed(h1=1, w=2, r=4.4):
+    # a simple closed path
+    square = Polygon([(-0.01, -h1 / 2), (w, -h1 / 2), (w, +h1 / 2), (-0.01, +h1 / 2)])
+    circle = Point([0, 0]).buffer(1.0)
+
+    # a simple square of side `r`
+    path = [[0, 0, 0], [r, 0, 0], [r, r, 0], [0, r, 0], [0, 0, 0]]
+
+    a = sweep_polygon(square, path)
+    assert a.is_volume
+
+    aa = sweep_polygon(square, path, connect=False)
+    assert aa.is_volume
+
+    # should have the same bounds but no longer a volume
+    aaa = sweep_polygon(square, path, connect=False, cap=False)
+    assert np.allclose(aa.bounds, aaa.bounds)
+    assert not aaa.is_volume
+
+    b = sweep_polygon(circle, path)
+    assert b.is_volume
+
+
+def test_simple_extrude(height=10):
+    # make sure sweep behaves okay on a simple single segment path
+    # this should be identical to the extrude operation
+    circle = Point([0, 0]).buffer(1.0)
+
+    # a simple extrusion
+    path = [[0, 0, 0], [0, 0, height]]
+    # will be running asserts inside function
+    b = sweep_polygon(circle, path)
+
+    # should be a straight extrude along Z
+    expected = np.append(np.reshape(circle.bounds, (2, 2)).ptp(axis=0), height)
+    assert np.allclose(expected, b.extents)
+
+    # should be well constructed
+    assert b.is_volume
+    # volume should correspond to expected cylinder area
+    assert np.isclose(b.volume, circle.area * height)
+
+
+def test_simple_open():
+    circle = Point([0, 0]).buffer(1.0)
+    theta = np.linspace(0.0, np.pi, 100)
+    path = np.column_stack([np.cos(theta), np.sin(theta), np.zeros(len(theta))]) * 10
+
+    a = sweep_polygon(circle, path)
+    assert a.is_volume
+
+
+def test_spline_3D():
+    circle = Point([0, 0]).buffer(1.0)
+    # make a 3D noodle using spline smoothing
+    path = trimesh.path.simplify.resample_spline(
+        [[0, 0, 0], [4, 4, 0], [5, 5, 0], [10, 0, 10], [0, 20, 0]], smooth=0.25, count=100
+    )
+
+    a = sweep_polygon(circle, path)
+    assert a.is_volume
+    assert a.body_count == 1
+
+
+def test_screw():
+    h = 200
+    d = 8
+    h1 = 1
+    w = 2
+    d = d - w
+    h2 = h1
+
+    lead = 2
+    splits_per_turn = 4 * 20
+    polygon = np.array([(-0.01, -h1 / 2), (w, -h2 / 2), (w, +h2 / 2), (-0.01, +h1 / 2)])
+    h_ = h + h1
+    n = int(np.ceil(splits_per_turn * h_ / lead))
+    turns = n / splits_per_turn
+    xys = arc_2d(2 * np.pi * turns, d, splits=n)
+    zs = np.linspace(0, h_, n)[:, None]
+    spin_path = np.concatenate((xys, zs), axis=1)
+
+    with Profiler() as P:
+        x = sweep_polygon(Polygon(polygon), spin_path)
+    P.print()
+
+    assert x.is_volume
+    # should have produced a result corresponding to the input dimensions
+    assert np.allclose(x.extents[:2], d + w * 2, atol=0.01)
+    assert np.allclose(x.extents[2], h + h1 * 2, atol=0.01)
+
+    # check without capping: should be the same size just without caps
+    o = sweep_polygon(Polygon(polygon), spin_path, cap=False, connect=False)
+    assert np.allclose(x.bounds, o.bounds)
+    assert not o.is_volume
+
+    # try spinning along an angle
+    sweep_polygon(
+        Polygon(polygon), spin_path, angles=np.linspace(0.0, np.pi / 2, len(spin_path))
+    )
+
+    circle = Point([0, 0]).buffer(1.0)
+    theta = np.linspace(0.0, np.pi, 100)
+    path = np.column_stack([np.cos(theta), np.sin(theta), np.zeros(len(theta))]) * 10
+
+    a = sweep_polygon(circle, path)
+    assert a.is_volume
+
+
+if __name__ == "__main__":
+    test_simple_closed()
+    test_simple_extrude()
+    test_simple_open()
+    test_screw()
+    test_spline_3D()
diff --git a/tests/test_transformations.py b/tests/test_transformations.py
index 1d54c310..18762415 100644
--- a/tests/test_transformations.py
+++ b/tests/test_transformations.py
@@ -227,6 +227,31 @@ class TransformTest(g.unittest.TestCase):
             # they should be the same matrix
             assert g.np.allclose(s, n)
 
+    def test_symbolic_translate(self):
+        # some of the functions have been modified to support `sympy.Symbol`
+        # values which is useful for calculating final rotations symbolically
+        try:
+            import sympy as sp
+        except BaseException:
+            return
+
+        translate = g.trimesh.transformations.translation_matrix
+
+        x, y, z = sp.symbols("x y z")
+
+        m = translate([x, y, z])
+
+        for T in g.random((100, 3)):
+            # get the euler matrix evaluated from the symbolic matrix
+            s = g.np.array(
+                m.subs({x: T[0], y: T[1], z: T[2]}).evalf(), dtype=g.np.float64
+            )
+            # get it from a numeric scalar
+            n = translate(T)
+
+            # they should be the same matrix
+            assert g.np.allclose(s, n)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()