Repository: mikedh/trimesh
Pull request #2008 (2023-08-22 02:17:41)

Release: Reduce Requirements
- sympy is only used implicitly, so move it to a new `recommends` extra
- recursively delete any test directories included in the docker images
- explicitly assign the `uid=499` to the docker `user` user.
  - system users are uid 100-499, and automatic ones start at 100 and increment
  - this allows upstream docker to do a `COPY --link --chown=499` using buildkit without hitting https://github.com/moby/buildkit/issues/2987
- release #2006 
- remove `trimesh.integrate` which was deprecated and not included in any `__init__`



Patch:
diff --git a/Dockerfile b/Dockerfile
index 4a47ab7a..7966dd79 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,8 +4,8 @@ LABEL maintainer="mikedh@kerfed.com"
 # Install helper script to PATH.
 COPY --chmod=755 docker/trimesh-setup /usr/local/bin/
 
-# Create a local non-root user.
-RUN useradd -m -s /bin/bash user
+# Create a non-root user with `uid=499`.
+RUN useradd -m -u 499 -s /bin/bash user
 
 # Required for Python to be able to find libembree.
 ENV LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"
@@ -20,14 +20,18 @@ FROM base AS build
 RUN trimesh-setup --install build
 
 # copy in essential files
-COPY --chown=user:user trimesh/ /home/user/trimesh
-COPY --chown=user:user setup.py /home/user/
+COPY --chown=499 trimesh/ /home/user/trimesh
+COPY --chown=499 setup.py /home/user/
 
 # switch to non-root user
 USER user
 
 # install trimesh into .local
-RUN pip install /home/user[all]
+# then delete any included test directories
+# and remove Cython after all the building is complete
+RUN pip install /home/user[all] && \
+    find /home/user/.local -type d -name tests -prune -exec rm -rf {} \; && \
+    pip uninstall -y cython
 
 ####################################
 ### Build output image most things should run on
@@ -38,7 +42,7 @@ USER user
 WORKDIR /home/user
 
 # just copy over the results of the compiled packages
-COPY --chown=user:user --from=build /home/user/.local /home/user/.local
+COPY --chown=499 --from=build /home/user/.local /home/user/.local
 
 # Set environment variables for software rendering.
 ENV XVFB_WHD="1920x1080x24"\
@@ -51,19 +55,21 @@ ENV XVFB_WHD="1920x1080x24"\
 FROM output AS tests
 
 # copy in tests and supporting files
-COPY --chown=user:user tests ./tests/
-COPY --chown=user:user models ./models/
-COPY --chown=user:user setup.py .
+COPY --chown=499 tests ./tests/
+COPY --chown=499 trimesh ./trimesh/
+COPY --chown=499 models ./models/
+COPY --chown=499 setup.py .
+COPY --chown=499 pyproject.toml .
 
 # codecov looks at the git history
-COPY --chown=user:user ./.git ./.git/
+COPY --chown=499 ./.git ./.git/
 
 USER root
 RUN trimesh-setup --install=test,gltf_validator,llvmpipe,binvox
 USER user
 
 # install things like pytest
-RUN pip install `python setup.py --list-test`
+RUN pip install -e .[all,easy,recommends,test]
 
 # run pytest wrapped with xvfb for simple viewer tests
 RUN xvfb-run pytest --cov=trimesh \
@@ -86,11 +92,11 @@ USER root
 RUN trimesh-setup --install docs
 USER user
 
-COPY --chown=user:user README.md .
-COPY --chown=user:user docs ./docs/
-COPY --chown=user:user examples ./examples/
-COPY --chown=user:user models ./models/
-COPY --chown=user:user trimesh ./trimesh/
+COPY --chown=499 README.md .
+COPY --chown=499 docs ./docs/
+COPY --chown=499 examples ./examples/
+COPY --chown=499 models ./models/
+COPY --chown=499 trimesh ./trimesh/
 
 WORKDIR /home/user/docs
 RUN make
diff --git a/setup.py b/setup.py
index 68f72d90..7a07c79f 100644
--- a/setup.py
+++ b/setup.py
@@ -5,88 +5,100 @@ import sys
 from setuptools import setup
 
 # load __version__ without importing anything
-_version_file = os.path.join(
-    os.path.dirname(__file__),
-    'trimesh', 'version.py')
+_version_file = os.path.join(os.path.dirname(__file__), "trimesh", "version.py")
 
 if os.path.isfile(_version_file):
-    with open(_version_file, 'r') as f:
+    with open(_version_file, "r") as f:
         _version_raw = f.read()
     # use eval to get a clean string of version from file
-    __version__ = eval(next(
-        line.strip().split('=')[-1]
-        for line in str.splitlines(_version_raw)
-        if '_version_' in line))
+    __version__ = eval(
+        next(
+            line.strip().split("=")[-1]
+            for line in str.splitlines(_version_raw)
+            if "_version_" in line
+        )
+    )
 else:
     __version__ = None
 
 # load README.md as long_description
-long_description = ''
-if os.path.exists('README.md'):
-    with open('README.md', 'r') as f:
+long_description = ""
+if os.path.exists("README.md"):
+    with open("README.md", "r") as f:
         long_description = f.read()
 
 # minimal requirements for installing trimesh
-requirements_default = set(['numpy'])
+requirements_default = set(["numpy"])
 
 # "easy" requirements should install without compiling
 # anything on Windows, Linux, and Mac, for Python >= 3.6
-requirements_easy = set([
-    'scipy',     # provide convex hulls, fast graph ops, etc
-    'networkx',  # provide slow graph ops with a nice API
-    'lxml',      # handle XML better and faster than built- in XML
-    'shapely',   # handle 2D polygons robustly
-    'rtree',     # create N-dimension trees for broad-phase queries
-    'svg.path',  # handle SVG format path strings
-    'sympy',     # do analytical math
-    'pillow',    # load images
-    'embreex',   # Intel's Embree ray check engine with wheels
-    'requests',  # do network requests
-    'xxhash',    # hash ndarrays faster than built-in MD5/CRC
-    'setuptools',  # do setuptools stuff
-    'jsonschema',  # validate JSON schemas like GLTF
-    'pycollada',   # parse collada/dae/zae files
-    'chardet',     # figure out if someone used UTF-16
-    'mapbox-earcut',  # fast 2D triangulations of polygons
-    'colorlog'])   # log in pretty colors
+requirements_easy = set(
+    [
+        "scipy",  # provide convex hulls, fast graph ops, etc
+        "networkx",  # provide slow graph ops with a nice API
+        "lxml",  # handle XML better and faster than built- in XML
+        "shapely",  # handle 2D polygons robustly
+        "rtree",  # create N-dimension trees for broad-phase queries
+        "svg.path",  # handle SVG format path strings
+        "pillow",  # load images
+        "embreex",  # Intel's Embree ray check engine with wheels
+        "requests",  # do network requests
+        "xxhash",  # hash ndarrays faster than built-in MD5/CRC
+        "setuptools",  # do setuptools stuff
+        "jsonschema",  # validate JSON schemas like GLTF
+        "pycollada",  # parse collada/dae/zae files
+        "chardet",  # figure out if someone used UTF-16
+        "mapbox-earcut",  # fast 2D triangulations of polygons
+        "colorlog",
+    ]
+)  # log in pretty colors
 
 # "all" requirements only need to be installable
 # through some mechanism on Linux with Python 3.5+
 # and are allowed to compile code
-requirements_all = requirements_easy.union([
-    'python-fcl',    # do fast 3D collision queries
-    'psutil',        # figure out how much memory we have
-    'glooey',        # make GUI applications with 3D stuff
-    'meshio',        # load a number of additional mesh formats; Python 3.5+
-    'scikit-image',  # marching cubes and other nice stuff
-    'xatlas',        # texture unwrapping
-    'pyglet<2',  # render preview windows nicely : note pyglet 2.0 is basically a re-write
-])
+requirements_all = requirements_easy.union(
+    [
+        "python-fcl",  # do fast 3D collision queries
+        "psutil",  # figure out how much memory we have
+        "scikit-image",  # marching cubes and other nice stuff
+        "xatlas",  # texture unwrapping
+        "pyglet<2",  # render preview windows nicely : note pyglet 2.0 is basically a re-write
+    ]
+)
 # requirements for running unit tests
-requirements_test = set(['pytest',       # run all unit tests
-                         'pytest-cov',   # coverage plugin
-                         'pyinstrument',  # profile code
-                         'coveralls',    # report coverage stats
-                         'autopep8<2',     # check and autoformat
-                         'ruff',         # static code analysis
-                         'pymeshlab',    # used as a validator for exports
-                         'ezdxf'])       # used as a validator for exports
+requirements_test = set(
+    [
+        "pytest",  # run all unit tests
+        "pytest-cov",  # coverage plugin
+        "pyinstrument",  # profile code
+        "coveralls",  # report coverage stats
+        "autopep8<2",  # check and autoformat
+        "ruff",  # static code analysis
+        "pymeshlab",  # used as a validator for exports
+        "ezdxf",
+    ]
+)  # used as a validator for exports
+
+# things that are used implicitly
+requirements_recommends = set(["meshio", "sympy", "glooey"])
 
 # Python 2.7 and 3.4 support has been dropped from packages
 # version lock those packages here so install succeeds
 current = (sys.version_info.major, sys.version_info.minor)
 
 # packages that no longer support old Python
-lock = [((3, 4), 'lxml', '4.3.5'),
-        ((3, 4), 'shapely', '1.6.4'),
-        ((3, 4), 'pyglet', '1.4.10'),
-        ((3, 5), 'sympy', None),
-        ((3, 6), 'pyglet<2', None),
-        ((3, 6), 'autopep8', None),
-        ((3, 6), 'ruff', None),
-        ((3, 7), 'pymeshlab', None),
-        ((3, 5), 'embreex', None),
-        ((3, 6), 'svg.path', '4.1')]
+lock = [
+    ((3, 4), "lxml", "4.3.5"),
+    ((3, 4), "shapely", "1.6.4"),
+    ((3, 4), "pyglet", "1.4.10"),
+    ((3, 5), "sympy", None),
+    ((3, 6), "pyglet<2", None),
+    ((3, 6), "autopep8", None),
+    ((3, 6), "ruff", None),
+    ((3, 7), "pymeshlab", None),
+    ((3, 5), "embreex", None),
+    ((3, 6), "svg.path", "4.1"),
+]
 for max_python, name, version in lock:
     if current <= max_python:
         # remove version-free requirements
@@ -96,7 +108,7 @@ for max_python, name, version in lock:
         # if version is None drop that package
         if version is not None:
             # add working version locked requirements
-            requirements_easy.add('{}=={}'.format(name, version))
+            requirements_easy.add("{}=={}".format(name, version))
 
 
 def format_all():
@@ -112,104 +124,126 @@ def format_all():
         word_skip = "datas,coo,nd,files',filetests,ba,childs,whats"
         # files to skip spelling on
         file_skip = "*.pyc,*.zip,.DS_Store,*.js,./trimesh/resources"
-        spell = ['codespell', '-i', '3',
-                 '--skip=' + file_skip,
-                 '-L', word_skip, '-w', target]
-        print("Running: \n {} \n\n\n".format(' '.join(spell)))
+        spell = [
+            "codespell",
+            "-i",
+            "3",
+            "--skip=" + file_skip,
+            "-L",
+            word_skip,
+            "-w",
+            target,
+        ]
+        print("Running: \n {} \n\n\n".format(" ".join(spell)))
         subprocess.check_call(spell)
 
-        formatter = ["autopep8", "--recursive", "--verbose",
-                     "--in-place", "--aggressive", target]
-        print("Running: \n {} \n\n\n".format(
-            ' '.join(formatter)))
+        formatter = [
+            "autopep8",
+            "--recursive",
+            "--verbose",
+            "--in-place",
+            "--aggressive",
+            target,
+        ]
+        print("Running: \n {} \n\n\n".format(" ".join(formatter)))
         subprocess.check_call(formatter)
 
-        flake = ['flake8', target]
-        print("Running: \n {} \n\n\n".format(' '.join(flake)))
+        flake = ["flake8", target]
+        print("Running: \n {} \n\n\n".format(" ".join(flake)))
         subprocess.check_call(flake)
 
     # run on our target locations
-    for t in ['trimesh', 'tests', 'examples']:
+    for t in ["trimesh", "tests", "examples"]:
         run_on(t)
 
 
 # if someone wants to output a requirements file
 # `python setup.py --list-all > requirements.txt`
-if '--list-all' in sys.argv:
+if "--list-all" in sys.argv:
     # will not include default requirements (numpy)
-    print('\n'.join(requirements_all))
+    print("\n".join(requirements_all))
     exit()
-elif '--list-easy' in sys.argv:
+elif "--list-easy" in sys.argv:
     # again will not include numpy+setuptools
-    print('\n'.join(requirements_easy))
+    print("\n".join(requirements_easy))
     exit()
-elif '--list-test' in sys.argv:
+elif "--list-test" in sys.argv:
     # again will not include numpy+setuptools
-    print('\n'.join(requirements_test))
+    print("\n".join(requirements_test))
     exit()
-elif '--format' in sys.argv:
+elif "--format" in sys.argv:
     format_all()
     exit()
-elif '--bump' in sys.argv:
+elif "--bump" in sys.argv:
     # bump the version number
     # convert current version to integers
-    bumped = [int(i) for i in __version__.split('.')]
+    bumped = [int(i) for i in __version__.split(".")]
     # increment the last field by one
     bumped[-1] += 1
     # re-combine into a version string
-    version_new = '.'.join(str(i) for i in bumped)
-    print('version bump `{}` => `{}`'.format(
-        __version__, version_new))
+    version_new = ".".join(str(i) for i in bumped)
+    print("version bump `{}` => `{}`".format(__version__, version_new))
     # write back the original version file with
     # just the value replaced with the new one
     raw_new = _version_raw.replace(__version__, version_new)
-    with open(_version_file, 'w') as f:
+    with open(_version_file, "w") as f:
         f.write(raw_new)
     exit()
 
 
 # call the magical setuptools setup
-setup(name='trimesh',
-      version=__version__,
-      description='Import, export, process, analyze and view triangular meshes.',
-      long_description=long_description,
-      long_description_content_type='text/markdown',
-      author='Michael Dawson-Haggerty',
-      author_email='mikedh@kerfed.com',
-      license='MIT',
-      url='https://github.com/mikedh/trimesh',
-      keywords='graphics mesh geometry 3D',
-      classifiers=[
-          'Development Status :: 4 - Beta',
-          'License :: OSI Approved :: MIT License',
-          'Programming Language :: Python',
-          'Programming Language :: Python :: 2.7',
-          'Programming Language :: Python :: 3.5',
-          'Programming Language :: Python :: 3.6',
-          'Programming Language :: Python :: 3.7',
-          'Programming Language :: Python :: 3.8',
-          'Programming Language :: Python :: 3.9',
-          'Programming Language :: Python :: 3.10',
-          'Natural Language :: English',
-          'Topic :: Scientific/Engineering'],
-      packages=[
-          'trimesh',
-          'trimesh.ray',
-          'trimesh.path',
-          'trimesh.path.exchange',
-          'trimesh.scene',
-          'trimesh.voxel',
-          'trimesh.visual',
-          'trimesh.viewer',
-          'trimesh.exchange',
-          'trimesh.resources',
-          'trimesh.interfaces'],
-      package_data={'trimesh': ['resources/templates/*',
-                                'resources/*.json',
-                                'resources/schema/*',
-                                'resources/schema/primitive/*.json',
-                                'resources/*.zip']},
-      install_requires=list(requirements_default),
-      extras_require={'test': list(requirements_test),
-                      'easy': list(requirements_easy),
-                      'all': list(requirements_all)})
+setup(
+    name="trimesh",
+    version=__version__,
+    description="Import, export, process, analyze and view triangular meshes.",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    author="Michael Dawson-Haggerty",
+    author_email="mikedh@kerfed.com",
+    license="MIT",
+    url="https://github.com/mikedh/trimesh",
+    keywords="graphics mesh geometry 3D",
+    classifiers=[
+        "Development Status :: 4 - Beta",
+        "License :: OSI Approved :: MIT License",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 2.7",
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Natural Language :: English",
+        "Topic :: Scientific/Engineering",
+    ],
+    packages=[
+        "trimesh",
+        "trimesh.ray",
+        "trimesh.path",
+        "trimesh.path.exchange",
+        "trimesh.scene",
+        "trimesh.voxel",
+        "trimesh.visual",
+        "trimesh.viewer",
+        "trimesh.exchange",
+        "trimesh.resources",
+        "trimesh.interfaces",
+    ],
+    package_data={
+        "trimesh": [
+            "resources/templates/*",
+            "resources/*.json",
+            "resources/schema/*",
+            "resources/schema/primitive/*.json",
+            "resources/*.zip",
+        ]
+    },
+    install_requires=list(requirements_default),
+    extras_require={
+        "test": list(requirements_test),
+        "easy": list(requirements_easy),
+        "all": list(requirements_all),
+        "recommends": list(requirements_recommends),
+    },
+)
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 4c765768..4d789791 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -82,11 +82,11 @@ def revolve(linestring,
 
     if angle is None:
         # default to closing the revolution
-        angle = np.pi * 2
+        angle = np.pi * 2.0
         closed = True
     else:
         # check passed angle value
-        closed = angle >= ((np.pi * 2) - 1e-8)
+        closed = util.isclose(angle, np.pi * 2, atol=1e-10)
 
     if sections is None:
         # default to 32 sections for a full revolution
@@ -166,6 +166,7 @@ def revolve(linestring,
         # if revolved curve starts and ends with zero radius
         # it should really be a valid volume, unless the sign
         # reversed on the input linestring
+        assert closed
         assert mesh.is_volume
         assert mesh.body_count == 1
 
diff --git a/trimesh/integrate.py b/trimesh/integrate.py
deleted file mode 100644
index 65cd7d11..00000000
--- a/trimesh/integrate.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""
-integrate.py
---------------
-
-Utilities for integrating functions over meshes surfaces.
-"""
-
-import sympy as sp
-from sympy.parsing.sympy_parser import parse_expr as sympy_parse
-
-from .constants import log
-from . import util
-
-import warnings
-warnings.warn(
-    '`trimesh.integrate` is deprecated and will ' +
-    'be removed in March 2024, you might take a ' +
-    'look at `quadpy` which is far more complete',
-    category=DeprecationWarning,
-    stacklevel=2)
-
-
-def symbolic_barycentric(function):
-    """
-    Symbolically integrate a function(x,y,z) across a triangle or mesh.
-
-    Parameters
-    ----------
-    function: string or sympy expression
-              x, y, z will be replaced with a barycentric representation
-              and the the function is integrated across the triangle.
-
-    Returns
-    ----------
-    evaluator: numpy lambda function of result which takes a mesh
-    expr:      sympy expression of result
-
-    Examples
-    -----------
-
-    In [1]: function = '1'
-
-    In [2]: integrator, expr = integrate_barycentric(function)
-
-    In [3]: integrator
-    Out[3]: <__main__.evaluator instance at 0x7f66cd2a6200>
-
-    In [4]: expr
-    Out[4]: 1/2
-
-    In [5]: result  = integrator(mesh)
-
-    In [6]: mesh.area
-    Out[6]: 34.641016151377542
-
-    In [7]: result.sum()
-    Out[7]: 34.641016151377542
-    """
-
-    class evaluator:
-
-        def __init__(self, expr, expr_args):
-            self.lambdified = sp.lambdify(args=expr_args,
-                                          expr=expr,
-                                          modules=['numpy', 'sympy'])
-
-        def __call__(self, mesh, *args):
-            """
-            Quickly evaluate the surface integral across a mesh
-
-            Parameters
-            ----------
-            mesh: Trimesh object
-
-            Returns
-            ----------
-            integrated: (len(faces),) float, integral evaluated for each face
-            """
-            integrated = self.lambdified(*mesh.triangles.reshape((-1, 9)).T)
-            integrated *= 2 * mesh.area_faces
-            return integrated
-
-    function, symbols = substitute_barycentric(function)
-
-    b1, b2, x1, x2, x3, y1, y2, y3, z1, z2, z3 = symbols
-    # do the first integral for b1
-    integrated_1 = sp.integrate(function, b1)
-    integrated_1 = (integrated_1.subs({b1: 1 - b2}) -
-                    integrated_1.subs({b1: 0}))
-
-    integrated_2 = sp.integrate(integrated_1, b2)
-    integrated_2 = (integrated_2.subs({b2: 1}) -
-                    integrated_2.subs({b2: 0}))
-
-    lambdified = evaluator(expr=integrated_2,
-                           expr_args=[x1, y1, z1, x2, y2, z2, x3, y3, z3])
-
-    return lambdified, integrated_2
-
-
-def substitute_barycentric(function):
-    if util.is_string(function):
-        function = sympy_parse(function)
-    # barycentric coordinates
-    b1, b2 = sp.symbols('b1 b2',
-                        real=True,
-                        positive=True)
-    # vertices of the triangles
-    x1, x2, x3, y1, y2, y3, z1, z2, z3 = sp.symbols(
-        'x1,x2,x3,y1,y2,y3,z1,z2,z3', real=True)
-
-    # generate the substitution dictionary to convert from cartesian to barycentric
-    # since the input could have been a sympy expression or a string
-    # that we parsed substitute based on name to avoid id(x) issues
-    substitutions = {}
-    for symbol in function.free_symbols:
-        if symbol.name == 'x':
-            substitutions[symbol] = b1 * x1 + b2 * x2 + (1 - b1 - b2) * x3
-        elif symbol.name == 'y':
-            substitutions[symbol] = b1 * y1 + b2 * y2 + (1 - b1 - b2) * y3
-        elif symbol.name == 'z':
-            substitutions[symbol] = b1 * z1 + b2 * z2 + (1 - b1 - b2) * z3
-    # apply the conversion to barycentric
-    function = function.subs(substitutions)
-    log.debug('converted function to barycentric: %s', str(function))
-
-    symbols = (b1, b2, x1, x2, x3, y1, y2, y3, z1, z2, z3)
-
-    return function, symbols
diff --git a/trimesh/intersections.py b/trimesh/intersections.py
index 8dd2f771..2784e50c 100644
--- a/trimesh/intersections.py
+++ b/trimesh/intersections.py
@@ -13,6 +13,7 @@ from . import triangles as tm
 from . import transformations as tf
 
 from .constants import tol
+from .triangles import points_to_barycentric
 
 
 def mesh_plane(mesh,
@@ -430,6 +431,7 @@ def slice_faces_plane(vertices,
                       faces,
                       plane_normal,
                       plane_origin,
+                      uv=None,
                       face_index=None,
                       cached_dots=None):
     """
@@ -447,6 +449,8 @@ def slice_faces_plane(vertices,
         Normal vector of plane to intersect with mesh
     plane_origin :  (3,) float
         Point on plane to intersect with mesh
+    uv : (n, 2) float, optional
+        UV coordinates of source mesh to slice
     face_index : ((m,) int)
         Indexes of faces to slice. When no mask is provided, the
         default is to slice all faces.
@@ -460,10 +464,14 @@ def slice_faces_plane(vertices,
         Vertices of sliced mesh
     new_faces : (n, 3) int
         Faces of sliced mesh
+    new_uv : (n, 2) int or None
+        UV coordinates of sliced mesh
     """
 
     if len(vertices) == 0:
-        return vertices, faces
+        return vertices, faces, uv
+
+    have_uv = uv is not None
 
     # Construct a mask for the faces to slice.
     if face_index is not None:
@@ -539,7 +547,8 @@ def slice_faces_plane(vertices,
         if len(new_faces) == 0:
             # if no new faces at all return empty arrays
             empty = (np.zeros((0, 3), dtype=np.float64),
-                     np.zeros((0, 3), dtype=np.int64))
+                     np.zeros((0, 3), dtype=np.int64),
+                     np.zeros((0, 2), dtype=np.float64) if have_uv else None)
             return empty
 
         # find the unique indices in the new faces
@@ -551,8 +560,9 @@ def slice_faces_plane(vertices,
         # use the unique indices for our final vertices and faces
         final_vert = vertices[unique]
         final_face = inverse.reshape((-1, 3))
+        final_uv = uv[unique] if have_uv else None
 
-        return final_vert, final_face
+        return final_vert, final_face, final_uv
 
     # Extract the intersections of each triangle's edges with the plane
     o = cut_triangles                               # origins
@@ -566,6 +576,8 @@ def slice_faces_plane(vertices,
 
     # Initialize the array of new vertices with the current vertices
     new_vertices = vertices
+    new_quad_vertices = np.zeros((0, 3))
+    new_tri_vertices = np.zeros((0, 3))
 
     # Handle the case where a new quad is formed by the intersection
     # First, extract the intersection points belonging to a new quad
@@ -641,7 +653,23 @@ def slice_faces_plane(vertices,
     final_vert = new_vertices[unique]
     final_face = inverse.reshape((-1, 3))
 
-    return final_vert, final_face
+    final_uv = None
+    if have_uv:
+        # Generate barycentric coordinates for intersection vertices
+        quad_barycentrics = points_to_barycentric(
+            np.repeat(vertices[cut_faces_quad], 2, axis=0),
+            new_quad_vertices)
+        tri_barycentrics = points_to_barycentric(
+            np.repeat(vertices[cut_faces_tri], 2, axis=0),
+            new_tri_vertices)
+        all_barycentrics = np.concatenate([quad_barycentrics, tri_barycentrics])
+
+        # Interpolate UVs
+        cut_uv = np.concatenate([uv[cut_faces_quad], uv[cut_faces_tri]])
+        new_uv = np.einsum('ijk,ij->ik', np.repeat(cut_uv, 2, axis=0), all_barycentrics)
+        final_uv = np.concatenate([uv, new_uv])[unique]
+
+    return final_vert, final_face, final_uv
 
 
 def slice_mesh_plane(mesh,
@@ -689,6 +717,7 @@ def slice_mesh_plane(mesh,
 
     # avoid circular import
     from .base import Trimesh
+    from .visual import TextureVisuals
     from .path import polygons
     from scipy.spatial import cKDTree
     from .creation import triangulate_polygon
@@ -712,6 +741,11 @@ def slice_mesh_plane(mesh,
     vertices = mesh.vertices.copy()
     faces = mesh.faces.copy()
 
+    # We copy the UV coordinates if available
+    has_uv = (hasattr(mesh.visual, 'uv') and np.shape(
+        mesh.visual.uv) == (len(mesh.vertices), 2)) and not cap
+    uv = mesh.visual.uv.copy() if has_uv else None
+
     if 'process' not in kwargs:
         kwargs['process'] = False
 
@@ -719,9 +753,10 @@ def slice_mesh_plane(mesh,
     for origin, normal in zip(plane_origin.reshape((-1, 3)),
                               plane_normal.reshape((-1, 3))):
         # save the new vertices and faces
-        vertices, faces = slice_faces_plane(
+        vertices, faces, uv = slice_faces_plane(
             vertices=vertices,
             faces=faces,
+            uv=uv,
             plane_normal=normal,
             plane_origin=origin,
             face_index=face_index)
@@ -774,5 +809,8 @@ def slice_mesh_plane(mesh,
                 faces.append(vid[fn])
             faces = np.vstack(faces)
 
+    visual = TextureVisuals(
+        uv=uv, material=mesh.visual.material.copy()) if has_uv else None
+
     # return the sliced mesh
-    return Trimesh(vertices=vertices, faces=faces, **kwargs)
+    return Trimesh(vertices=vertices, faces=faces, visual=visual, **kwargs)
diff --git a/trimesh/version.py b/trimesh/version.py
index b21b2f52..5103cdf8 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,4 +1,4 @@
-__version__ = '3.23.3'
+__version__ = '3.23.5'
 
 if __name__ == '__main__':
     # print version if run directly i.e. in a CI script


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index facdac4e..871a5e30 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -85,8 +85,6 @@ if all_dependencies and not trimesh.ray.has_embree:
 try:
     import sympy as sp
 except ImportError as E:
-    if all_dependencies:
-        raise E
     sp = None
 
 try:
diff --git a/tests/test_section.py b/tests/test_section.py
index 2f804497..a75ea950 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -291,6 +291,32 @@ class SliceTest(g.unittest.TestCase):
 
         assert len(sliced.faces) > 0
 
+    def test_textured_mesh(self):
+        # Create a plane mesh with UV == xy
+        plane = g.trimesh.Trimesh(
+            vertices=g.np.array([[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]),
+            faces=g.np.array([[0, 1, 2], [2, 3, 0]]))
+        plane.visual = g.trimesh.visual.TextureVisuals(
+            uv=plane.vertices[:, :2], material=g.trimesh.visual.material.empty_material())
+
+        # We cut the plane and check that the new UV match the new vertices
+        origin = g.np.array([0.5, 0.5, 0.])
+        normal = g.trimesh.unitize([2, 1, 2])
+        sliced = plane.slice_plane(plane_origin=origin, plane_normal=normal)
+        assert g.np.isclose(sliced.vertices[:, :2], sliced.visual.uv).all()
+
+        # Test scenario when plane does not cut
+        origin = g.np.array([-1., -1., 0.])
+        normal = g.trimesh.unitize([1, 1, 2])
+        sliced = plane.slice_plane(plane_origin=origin, plane_normal=normal)
+        assert g.np.isclose(sliced.vertices[:, :2], sliced.visual.uv).all()
+
+        # Test cut with no new vertices
+        origin = g.np.array([0.5, 0.5, 0.])
+        normal = g.trimesh.unitize([2, -2, 1])
+        sliced = plane.slice_plane(plane_origin=origin, plane_normal=normal)
+        assert g.np.isclose(sliced.vertices[:, :2], sliced.visual.uv).all()
+
     def test_cap_coplanar(self):
         # check to see if we handle capping with
         # existing coplanar faces correctly