Repository: mikedh/trimesh
Pull request #1530 (2022-03-23 01:34:57)

check mesh.is_watertight in polygons.projected for different logics
related to #1528


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9553975d..a2cdd9fa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,7 +69,12 @@ jobs:
       if: matrix.os == 'ubuntu-latest'
       run: sudo bash docker/builds/gltf_validator.bash
     - name: Run Pytest
-      run: pytest tests/
+      run: pytest --cov=trimesh tests/
+    - name: Coverage Report
+      if: matrix.os == 'ubuntu-latest'
+      env:
+        CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+      run: bash <(curl -s https://codecov.io/bash)
   pypi:
     name: Release To PyPi
     needs: [tests, containers]
@@ -86,8 +91,8 @@ jobs:
         pip install build twine
     - name: Build and publish
       env:
-        TWINE_USERNAME: __token__
-        TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
       run: |
         pyproject-build --outdir dist .
         twine upload dist/*
@@ -143,9 +148,6 @@ jobs:
         export TAGS="trimesh/trimesh:latest; trimesh/trimesh:$VERSION; trimesh/trimesh:$GIT_SHORT; mikedh/trimesh:latest; mikedh/trimesh:$VERSION; mikedh/trimesh:$GIT_SHORT; $ECR_REGISTRY/$ECR_REPOSITORY:$VERSION; $ECR_REGISTRY/$ECR_REPOSITORY:latest; $ECR_REGISTRY/$ECR_REPOSITORY:$GIT_SHORT"
         # build image tagged with all our many tags
         docker build -t `echo "${TAGS//;/$' -t '}"` .
-        ci_env=`bash <(curl -s https://codecov.io/env)`
-        # run unit tests in docker image
-        docker run $ci_env -e CI=true -e CODECOV_TOKEN=${{ secrets.CODECOV_TOKEN }} --rm --user 0:0 -v `pwd`:/trimesh trimesh/trimesh:latest  bash -c 'pip install /trimesh[test] && pytest --cov=trimesh -x -p no:warnings -p no:alldep -p no:cacheprovider /trimesh/tests && bash <(curl -s https://codecov.io/bash)'
         # push every tag to remote registry
         for i in ${TAGS//;/ }; do docker push $i; done;
     - name: Logout of registries
diff --git a/Dockerfile b/Dockerfile
index 25031389..a4bf48f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,6 +31,12 @@ RUN pip install --no-cache-dir -e "${TRIMESH_PATH}[all,test]" pyassimp==4.1.3
 # Switch to non-root user.
 USER user
 
+# make sure build fails if tests are failing
+# -p no:warnings suppresses 10,000 useless upstream warnings
+# -p no:alldep means that tests will fail if a dependency is missing
+# -x will exit on first test failure
+RUN pytest -x -p no:warnings -p no:alldep -p no:cacheprovider "${TRIMESH_PATH}/tests"
+
 # Set environment variables for software rendering.
 ENV XVFB_WHD="1920x1080x24"\
     DISPLAY=":99" \
diff --git a/README.md b/README.md
index 4c87ed50..c86a292c 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
 [![trimesh](https://trimsh.org/images/logotype-a.svg)](http://trimsh.org)
 
 -----------
-[![Github Actions](https://github.com/mikedh/trimesh/workflows/Release%20Trimesh/badge.svg)](https://github.com/mikedh/trimesh/actions)  [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh) [![codecov](https://codecov.io/gh/mikedh/trimesh/branch/main/graph/badge.svg?token=4PVRQXyl2h)](https://codecov.io/gh/mikedh/trimesh)
+[![Github Actions](https://github.com/mikedh/trimesh/workflows/Release%20Trimesh/badge.svg)](https://github.com/mikedh/trimesh/actions)  [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh) [![codecov](https://codecov.io/gh/mikedh/trimesh/branch/master/graph/badge.svg?token=4PVRQXyl2h)](https://codecov.io/gh/mikedh/trimesh)
 
 
 Trimesh is a pure Python (2.7-3.4+) library for loading and using [triangular meshes](https://en.wikipedia.org/wiki/Triangle_mesh) with an emphasis on watertight surfaces. The goal of the library is to provide a full featured and well tested Trimesh object which allows for easy manipulation and analysis, in the style of the Polygon object in the [Shapely library](https://github.com/Toblerity/Shapely).
 
 The API is mostly stable, but this should not be relied on and is not guaranteed: install a specific version if you plan on deploying something using trimesh.
 
-Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/199) although things not on that list are also welcome. Here are some [tips for writing mesh code in Python.](https://github.com/mikedh/trimesh/blob/main/trimesh/exchange/README.md)
+Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/199) although things not on that list are also welcome. Here are some [tips for writing mesh code in Python.](https://github.com/mikedh/trimesh/blob/master/trimesh/exchange/README.md)
 
 
 ## Basic Installation
@@ -29,7 +29,7 @@ Further information is available in the [advanced installation documentation](ht
 ## Quick Start
 
 Here is an example of loading a mesh from file and colorizing its faces. Here is a nicely formatted
-[ipython notebook version](https://trimsh.org/examples/quick_start.html) of this example. Also check out the [cross section example](https://trimsh.org/examples/section.html) or possibly the [integration of a function over a mesh example](https://github.com/mikedh/trimesh/blob/main/examples/integrate.ipynb).
+[ipython notebook version](https://trimsh.org/examples/quick_start.html) of this example. Also check out the [cross section example](https://trimsh.org/examples/section.html) or possibly the [integration of a function over a mesh example](https://github.com/mikedh/trimesh/blob/master/examples/integrate.ipynb).
 
 ```python
 import numpy as np
@@ -216,9 +216,9 @@ A question that comes up pretty frequently is [how to cite the library.](https:/
 
 ## Containers
 
-If you want to deploy something in a container that uses trimesh, automated `debian:buster-slim` based builds with trimesh and dependencies are available on [Docker Hub](https://hub.docker.com/repository/docker/trimesh/trimesh) with image tags for `latest`, git short hash for the commit in main (i.e. `trimesh/trimesh:0c1298d`), and version (i.e. `trimesh/trimesh:3.5.27`):
+If you want to deploy something in a container that uses trimesh, automated `debian:buster-slim` based builds with trimesh and dependencies are available on [Docker Hub](https://hub.docker.com/repository/docker/trimesh/trimesh) with image tags for `latest`, git short hash for the commit in master (i.e. `trimesh/trimesh:0c1298d`), and version (i.e. `trimesh/trimesh:3.5.27`):
 
 `docker pull trimesh/trimesh`
 
-[Here's an example](https://github.com/mikedh/trimesh/tree/main/examples/dockerRender) of how to render meshes using LLVMpipe and XVFB inside a container.
+[Here's an example](https://github.com/mikedh/trimesh/tree/master/examples/dockerRender) of how to render meshes using LLVMpipe and XVFB inside a container.
 
diff --git a/docker/builds/apt.bash b/docker/builds/apt.bash
index d9d345b4..cc88a555 100644
--- a/docker/builds/apt.bash
+++ b/docker/builds/apt.bash
@@ -5,7 +5,7 @@ DEBIAN_FRONTEND=noninteractive
 # Install binary dependencies directly from APT.
 apt-get update -qq
 apt-get install -qq --no-install-recommends \
-	curl wget bzip2 supervisor \
+	wget bzip2 supervisor \
 	libgl1-mesa-glx libgl1-mesa-dri xvfb xauth \
 	ca-certificates zstd freeglut3-dev git sudo \
 	build-essential g++ gcc cmake libfcl-dev
diff --git a/setup.py b/setup.py
index 4581d23c..c84fc995 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,9 @@ if os.path.exists('README.md'):
 
 # minimal requirements for installing trimesh
 # note that `pip` requires setuptools itself
-requirements_default = set(['numpy'])
+requirements_default = set([
+    'numpy',     # all data structures
+])
 
 # "easy" requirements should install without compiling
 # anything on Windows, Linux, and Mac, for Python 2.7-3.4+
@@ -72,8 +74,7 @@ current = (sys.version_info.major, sys.version_info.minor)
 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), 'svg.path', '4.1')]
+        ((3, 5), 'sympy', None)]
 for max_python, name, version in lock:
     if current <= max_python:
         # remove version-free requirements
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 19fbb843..1b073f44 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -30,12 +30,6 @@ except BaseException as E:
     Polygon = exceptions.closure(E)
     load_wkb = exceptions.closure(E)
 
-try:
-    from triangle import triangulate
-except BaseException as E:
-    from . import exceptions
-    triangulate = exceptions.closure(E)
-
 
 def revolve(linestring,
             angle=None,
@@ -461,6 +455,8 @@ def triangulate_polygon(polygon,
 
         return vertices, faces
 
+    # do the import here for soft requirement
+    from triangle import triangulate
     # set default triangulation arguments if not specified
     if triangle_args is None:
         triangle_args = 'p'
diff --git a/trimesh/curvature.py b/trimesh/curvature.py
index 9bcf6764..3c8acb2b 100644
--- a/trimesh/curvature.py
+++ b/trimesh/curvature.py
@@ -9,10 +9,9 @@ import numpy as np
 from . import util
 
 try:
-    from scipy.sparse import coo_matrix
-except ImportError as E:
-    from . import exceptions
-    coo_matrix = exceptions.closure(E)
+    from scipy.sparse.coo import coo_matrix
+except ImportError:
+    pass
 
 
 def face_angles_sparse(mesh):
diff --git a/trimesh/exchange/export.py b/trimesh/exchange/export.py
index 657f2615..05b52601 100644
--- a/trimesh/exchange/export.py
+++ b/trimesh/exchange/export.py
@@ -145,7 +145,7 @@ def export_dict(mesh, encoding=None):
     return export
 
 
-def scene_to_dict(scene, use_base64=False, include_metadata=True):
+def scene_to_dict(scene, use_base64=False):
     """
     Export a Scene object as a dict.
 
@@ -168,14 +168,6 @@ def scene_to_dict(scene, use_base64=False, include_metadata=True):
                               'centroid': scene.centroid.tolist(),
                               'scale': scene.scale}}
 
-    if include_metadata:
-        try:
-            # jsonify will convert numpy arrays to lists recursively
-            # a little silly round-tripping to json but it is pretty fast
-            export['metadata'] = json.loads(util.jsonify(scene.metadata))
-        except BaseException:
-            log.warning('failed to serialize metadata', exc_info=True)
-
     # encode arrays with base64 or not
     if use_base64:
         file_type = 'dict64'
@@ -218,9 +210,6 @@ def export_scene(scene,
     export : bytes
       Only returned if file_obj is None
     """
-    if len(scene.geometry) == 0:
-        raise ValueError("Can't export empty scenes!")
-
     # if we weren't passed a file type extract from file_obj
     if file_type is None:
         if util.is_string(file_obj):
@@ -248,9 +237,9 @@ def export_scene(scene,
         from trimesh.path.exchange import svg_io
         data = svg_io.export_svg(scene, **kwargs)
     elif file_type == 'ply':
-        data = export_ply(scene.dump(concatenate=True))
+        data = export_ply(scene)
     elif file_type == 'stl':
-        data = export_stl(scene.dump(concatenate=True))
+        data = export_stl(scene)
     else:
         raise ValueError(
             'unsupported export format: {}'.format(file_type))
diff --git a/trimesh/graph.py b/trimesh/graph.py
index aed27da8..03227cae 100644
--- a/trimesh/graph.py
+++ b/trimesh/graph.py
@@ -64,11 +64,9 @@ def face_adjacency(faces=None,
     ----------
     This is useful for lots of things such as finding
     face- connected components:
-    ```python
     >>> graph = nx.Graph()
     >>> graph.add_edges_from(mesh.face_adjacency)
     >>> groups = nx.connected_components(graph_connected)
-    ```
     """
 
     if mesh is None:
diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py
index 0ddb42db..66e43f39 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -275,10 +275,7 @@ def _svg_path_convert(paths, force=None):
 
     for attrib, matrix in paths:
         # the path string is stored under `d`
-        path_string = attrib.get('d', '')
-        if len(path_string) == 0:
-            log.warning('empty path string!')
-            continue
+        path_string = attrib['d']
 
         # get the name of the geometry if trimesh specified it
         # note that the get will by default return `None`
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index d9aef417..dc36e4ab 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -94,14 +94,10 @@ def enclosure_tree(polygons):
     if len(degrees) > 0 and degrees.max() > 1:
         # collect new edges for graph
         edges = []
-
-        # order the roots so they are sorted by degree
-        roots = roots[np.argsort([degree[r] for r in roots])]
         # find edges of subgraph for each root and children
         for root in roots:
             children = indexes[degrees == degree[root] + 1]
-            edges.extend(contains.subgraph(
-                np.append(children, root)).edges())
+            edges.extend(contains.subgraph(np.append(children, root)).edges())
         # stack edges into new directed graph
         contains = nx.from_edgelist(edges, nx.DiGraph())
         # if roots have no children add them anyway
@@ -714,23 +710,28 @@ def projected(mesh,
 
     # the projection of each face normal onto facet normal
     dot_face = np.dot(normal, mesh.face_normals.T)
-    # check if face lies on front or back of normal
-    front = dot_face > tol_dot
-    back = dot_face < -tol_dot
-    # divide the mesh into front facing section and back facing parts
-    # and discard the faces perpendicular to the axis.
-    # since we are doing a unary_union later we can use the front *or*
-    # the back so we use which ever one has fewer triangles
-    # we want the largest nonzero group
-    count = np.array([front.sum(), back.sum()])
-    if count.min() == 0:
-        # if one of the sides has zero faces we need the other
-        pick = count.argmax()
+    if mesh.is_watertight:
+        # for watertight mesh, speed up projection by handling side with less faces
+        # check if face lies on front or back of normal
+        front = dot_face > tol_dot
+        back = dot_face < -tol_dot
+        # divide the mesh into front facing section and back facing parts
+        # and discard the faces perpendicular to the axis.
+        # since we are doing a unary_union later we can use the front *or*
+        # the back so we use which ever one has fewer triangles
+        # we want the largest nonzero group
+        count = np.array([front.sum(), back.sum()])
+        if count.min() == 0:
+            # if one of the sides has zero faces we need the other
+            pick = count.argmax()
+        else:
+            # otherwise use the normal direction with the fewest faces
+            pick = count.argmin()
+        # use the picked side
+        side = [front, back][pick]
     else:
-        # otherwise use the normal direction with the fewest faces
-        pick = count.argmin()
-    # use the picked side
-    side = [front, back][pick]
+        # for non-watertight mesh, only handle the front side of normal
+        side = dot_face > tol_dot
 
     # subset the adjacency pairs to ones which have both faces included
     # on the side we are currently looking at
diff --git a/trimesh/path/raster.py b/trimesh/path/raster.py
index 664dd106..33248917 100644
--- a/trimesh/path/raster.py
+++ b/trimesh/path/raster.py
@@ -71,33 +71,41 @@ def rasterize(path,
     # the path indexes that are exteriors
     # needed to know what to fill/empty but expensive
     roots = path.root
-    enclosure = path.enclosure_directed
 
     # draw the exteriors
-    result = Image.new(mode='1', size=resolution)
-    draw = ImageDraw.Draw(result)
+    exteriors = Image.new(mode='1', size=resolution)
+    edraw = ImageDraw.Draw(exteriors)
 
     # if a width is specified draw the outline
     if width is not None:
         width = int(width)
         for coords in discrete:
-            draw.line(coords.flatten().tolist(),
-                      fill=1,
-                      width=width)
+            edraw.line(coords.flatten().tolist(),
+                       fill=1,
+                       width=width)
         # if we are not filling the polygon exit
         if not fill:
-            return result
+            del edraw
+            return exteriors
 
-    # roots are ordered by degree
-    # so we draw the outermost one first
-    # and then go in as we progress
-    for root in roots:
-        # draw the exterior
-        draw.polygon(discrete[root].flatten().tolist(),
-                     fill=1)
-        # draw the interior children
-        for child in enclosure[root]:
-            draw.polygon(discrete[child].flatten().tolist(),
-                         fill=0)
+    # draw the interiors
+    interiors = Image.new(mode='1', size=resolution)
+    idraw = ImageDraw.Draw(interiors)
+    for i, points in enumerate(discrete):
+        # draw the polygon on either the exterior or
+        # interior image buffer
+        if i in roots:
+            edraw.polygon(points.flatten().tolist(),
+                          fill=1)
+        else:
+            idraw.polygon(points.flatten().tolist(),
+                          fill=1)
+    # clean up the draw objects
+    # this is in the PIL examples and I have
+    # no idea if it this is actually necessary
+    del edraw
+    del idraw
+    # the final result is the exteriors minus the interiors
+    raster = ImageChops.subtract(exteriors, interiors)
 
-    return result
+    return raster
diff --git a/trimesh/rendering.py b/trimesh/rendering.py
index 9297d113..341d58a8 100644
--- a/trimesh/rendering.py
+++ b/trimesh/rendering.py
@@ -361,7 +361,7 @@ def matrix_to_gl(matrix):
     from pyglet import gl
 
     # convert to GLfloat, switch to column major and flatten to (16,)
-    return (gl.GLfloat * 16)(*np.array(
+    return (gl.GLfloat * 16)(*np.asanyarray(
         matrix, dtype=np.float32).T.ravel())
 
 
diff --git a/trimesh/repair.py b/trimesh/repair.py
index 83f012b7..07f72427 100644
--- a/trimesh/repair.py
+++ b/trimesh/repair.py
@@ -10,7 +10,6 @@ import numpy as np
 from . import graph
 from . import triangles
 
-from .util import diagonal_dot
 from .constants import log
 from .grouping import group_rows
 from .geometry import faces_to_edges
@@ -24,23 +23,20 @@ except BaseException as E:
     from .exceptions import ExceptionModule
     nx = ExceptionModule(E)
 
-try:
-    from .path.exchange.misc import faces_to_path
-except BaseException as E:
-    from .exceptions import closure
-    faces_to_path = closure(E)
-
 
 def fix_winding(mesh):
     """
-    Traverse and change mesh faces in-place to make sure
-    winding is correct with edges on adjacent faces in
+    Traverse and change mesh faces in-place to make sure winding
+    is correct, with edges on adjacent faces in
     opposite directions.
 
     Parameters
     -------------
-    mesh : Trimesh
-      Source geometry to alter in-place.
+    mesh: Trimesh object
+
+    Notes
+    -------------
+    mesh.face : will reverse columns of certain faces
     """
     # anything we would fix is already done
     if mesh.is_winding_consistent:
@@ -96,13 +92,16 @@ def fix_inversion(mesh, multibody=False):
     Parameters
     -------------
     mesh : trimesh.Trimesh
-      Mesh to fix in-place.
+      Mesh to fix.
     multibody : bool
       If True will try to fix normals on every body
+
+    Notes
+    -------------
+    mesh.face : may reverse faces
     """
     if multibody:
-        groups = graph.connected_components(
-            mesh.face_adjacency)
+        groups = graph.connected_components(mesh.face_adjacency)
         # escape early for single body
         if len(groups) == 1:
             if mesh.volume < 0.0:
@@ -356,80 +355,3 @@ def fill_holes(mesh):
 
     log.debug('Filled in mesh with %i triangles', np.sum(valid))
     return mesh.is_watertight
-
-
-def fan_stitch(mesh, faces):
-    """
-    Create a fan stitch over the boundary of the specified
-    faces. If the boundary is non-convex a triangle fan
-    is going to be extremely wonky.
-
-    Parameters
-    -----------
-    vertices : (n, 3) float
-      Vertices in space.
-    faces : (n,) int
-      Face indexes to stitch with triangle fans.
-
-    Returns
-    ----------
-    fan : (m, 3) int
-      New triangles referencing mesh.vertices.
-    """
-    if faces is None:
-        faces = np.arange(len(mesh.faces))
-
-    # get a sequence of vertex indices representing the
-    # boundary of the specified faces
-    # will be referencing the same indexes of `mesh.vertices`
-    points = [e.points for e in
-              faces_to_path(mesh, faces)['entities']
-              if len(e.points) > 3 and
-              e.points[0] == e.points[-1]]
-
-    # now create a triangle fan for each boundary curve
-    fan = [np.column_stack((
-        np.ones(len(p) - 3, dtype=int) * p[0],
-        p[1:-2],
-        p[2:-1]))
-        for p in points]
-
-    # get properties to avoid querying in loop
-    vertices = mesh.vertices
-    normals = mesh.face_normals
-    adjacency = mesh.face_adjacency
-    tree = mesh.face_adjacency_edges_tree
-
-    # we're going to want to see which faces were *not*
-    # specified in the original face set
-    mask = np.ones(len(mesh.faces), dtype=bool)
-    mask[faces] = False
-    # now we do a normal check against an adjacent face
-    # to see if each region needs to be flipped
-    for i, p, t in zip(range(len(fan)), points, fan):
-        # get the edges from the original mesh
-        # for the first 3 new triangles
-        e = t[:3, 1:].copy()
-        e.sort(axis=1)
-        # find which index of `face_adjacency` this
-        # edge represents using a tree query
-        distance, aid = tree.query(e)
-        # if this is nonzero it means the edge doesn't
-        # exist in the original mesh and something
-        # extremely suspicious is going on
-        assert distance.max() < 1e-10
-        adj = adjacency[aid]
-        # get the normal of the adjacent face that
-        # wasn't included in our original set of faces
-        original = normals[adj[mask[adj]]]
-        # calculate the normals for a few new faces
-        # note that this 3-face check is totally arbitrary
-        # and you could check anywhere from one to all faces
-        check = triangles.normals(vertices[t[:3]])[0]
-        # if our new faces are reversed from the original
-        # adjacent face flip them along their axis
-        sign = diagonal_dot(check, original)
-        if sign.mean() < 0:
-            fan[i] = np.fliplr(t)
-
-    return np.vstack(fan)
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 25e6f8e4..e472ef60 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -8,10 +8,6 @@ from .. import util
 from .. import caching
 from .. import transformations
 
-# we compare to identity a lot
-_identity = np.eye(4)
-_identity.flags['WRITEABLE'] = False
-
 
 class SceneGraph(object):
     """
@@ -269,7 +265,7 @@ class SceneGraph(object):
                 # get the matrix from this edge
                 matrix = edge_data[(parent, node)]['matrix']
                 # only include if it's not an identify matrix
-                if not util.allclose(matrix, _identity):
+                if np.abs(matrix - np.eye(4)).max() > 1e-5:
                     info['matrix'] = matrix.T.reshape(-1).tolist()
 
                 # if an extra was stored on this edge
@@ -527,9 +523,8 @@ class EnforcedForest(object):
         else:
             # check to see if matrix and geometry are identical
             edge = self.edge_data[(u, v)]
-            if (util.allclose(kwargs.get('matrix', _identity),
-                              edge.get('matrix', _identity),
-                              1e-8)
+            if (np.allclose(kwargs.get('matrix', np.eye(4)),
+                            edge.get('matrix', np.eye(4)))
                 and (edge.get('geometry') ==
                      kwargs.get('geometry'))):
                 return False
diff --git a/trimesh/util.py b/trimesh/util.py
index d863f234..6c7120cd 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1488,8 +1488,6 @@ def concatenate(a, b=None):
     # if there is only one mesh just return the first
     if len(meshes) == 1:
         return meshes[0].copy()
-    elif len(meshes) == 0:
-        return []
 
     # extract the trimesh type to avoid a circular import
     # and assert that both inputs are Trimesh objects
@@ -1520,6 +1518,7 @@ def concatenate(a, b=None):
                         face_normals=face_normals,
                         visual=visual,
                         process=False)
+
     return mesh
 
 
@@ -2251,7 +2250,7 @@ def isclose(a, b, atol):
     return close
 
 
-def allclose(a, b, atol=1e-8):
+def allclose(a, b, atol):
     """
     A replacement for np.allclose that does few checks
     and validation and as a result is faster.
@@ -2269,7 +2268,7 @@ def allclose(a, b, atol=1e-8):
     -----------
     bool indicating if all elements are within `atol`.
     """
-    return float((a - b).ptp()) < atol
+    return np.all(np.abs(a - b).max() < atol)
 
 
 class FunctionRegistry(Mapping):
diff --git a/trimesh/version.py b/trimesh/version.py
index e234e8a2..33fc5280 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.10.6'
+__version__ = '3.10.2'
diff --git a/trimesh/voxel/transforms.py b/trimesh/voxel/transforms.py
index 93bb202e..853c8e98 100644
--- a/trimesh/voxel/transforms.py
+++ b/trimesh/voxel/transforms.py
@@ -110,10 +110,8 @@ class Transform(object):
         matrix = self.matrix
         # get the (3,) diagonal of the rotation component
         scale = np.diag(matrix[:3, :3])
-        if not np.allclose(
-                matrix[:3, :3],
-                scale * np.eye(3),
-                scale * 1e-6 + 1e-8):
+        if not util.allclose(
+                matrix[:3, :3], scale * np.eye(3), scale * 1e-6 + 1e-8):
             raise RuntimeError('transform features a shear or rotation')
         return scale
 


Test patch:
diff --git a/tests/test_obj.py b/tests/test_obj.py
index bf6819c7..2f7ebecb 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -239,16 +239,17 @@ class OBJTest(g.unittest.TestCase):
             e = g.get_mesh('emptyIO/' + empty_file)
 
             # create export
+            export = e.export(file_type='ply')
+            reconstructed = g.wrapload(export, file_type='ply')
+
             if 'empty' in empty_file:
-                try:
-                    export = e.export(file_type='ply')
-                except BaseException:
-                    continue
-                raise ValueError('cannot export empty')
+                # result should be an empty scene without vertices
+                assert isinstance(e, g.trimesh.Scene)
+                assert not hasattr(e, 'vertices')
+                # export should not contain geometry
+                assert isinstance(reconstructed, g.trimesh.Scene)
+                assert not hasattr(reconstructed, 'vertices')
             elif 'points' in empty_file:
-                export = e.export(file_type='ply')
-                reconstructed = g.wrapload(export, file_type='ply')
-
                 # result should be a point cloud instance
                 assert isinstance(e, g.trimesh.PointCloud)
                 assert hasattr(e, 'vertices')
diff --git a/tests/test_ply.py b/tests/test_ply.py
index aec47f39..baca2512 100644
--- a/tests/test_ply.py
+++ b/tests/test_ply.py
@@ -160,18 +160,19 @@ class PlyTest(g.unittest.TestCase):
 
         for empty_file in empty_files:
             e = g.get_mesh('emptyIO/' + empty_file)
+
+            # create export
+            export = e.export(file_type='ply')
+            reconstructed = g.wrapload(export, file_type='ply')
+
             if 'empty' in empty_file:
-                # result should be an empty scene
-                try:
-                    e.export(file_type='ply')
-                except BaseException:
-                    continue
-                raise ValueError('should not export empty')
+                # result should be an empty scene without vertices
+                assert isinstance(e, g.trimesh.Scene)
+                assert not hasattr(e, 'vertices')
+                # export should not contain geometry
+                assert isinstance(reconstructed, g.trimesh.Scene)
+                assert not hasattr(reconstructed, 'vertices')
             elif 'points' in empty_file:
-                # create export
-                export = e.export(file_type='ply')
-                reconstructed = g.wrapload(export, file_type='ply')
-
                 # result should be a point cloud instance
                 assert isinstance(e, g.trimesh.PointCloud)
                 assert hasattr(e, 'vertices')
diff --git a/tests/test_raster.py b/tests/test_raster.py
index d3cf91c4..ce25b77a 100644
--- a/tests/test_raster.py
+++ b/tests/test_raster.py
@@ -57,35 +57,6 @@ class RasterTest(g.unittest.TestCase):
         # A different pitch results in a different image
         assert fill_2dpitch_cnt != fill_cnt
 
-    def test_nested(self):
-        # make a test path with nested circles
-        theta = g.np.linspace(0, g.np.pi * 2, 100)
-        unit = g.np.column_stack((g.np.cos(theta), g.np.sin(theta)))
-        radii = g.np.linspace(1.0, 10.0, 10)
-        g.np.random.shuffle(radii)
-        paths = []
-        for R in radii:
-            paths.append(g.trimesh.load_path(R * unit))
-        path = g.trimesh.path.util.concatenate(paths)
-
-        # split and extrude should both show 5 regions
-        assert len(path.split()) == 5
-        assert len(path.extrude(1.0)) == 5
-
-        pitch = path.extents.max() / 1000
-        origin = path.bounds[0] - pitch
-        resolution = (g.np.ceil(
-            path.extents / pitch) + 2).astype(int)
-
-        # rasterize using the settings
-        r = path.rasterize(
-            pitch=pitch, origin=origin, resolution=resolution)
-        # it's a boolean image so filled cells times
-        # pitch area should be about the same as the area
-        filled = g.np.array(r).sum() * pitch ** 2
-
-        assert g.np.isclose(filled, path.area, rtol=0.01)
-
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 01fabd3b..90738a04 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -107,8 +107,9 @@ class SceneTests(g.unittest.TestCase):
                 s.explode()
 
     def test_scaling(self):
-        # Test the scaling of scenes including unit conversion.
-
+        """
+        Test the scaling of scenes including unit conversion.
+        """
         scene = g.get_mesh('cycloidal.3DXML')
 
         md5 = scene.md5()
@@ -295,9 +296,10 @@ class SceneTests(g.unittest.TestCase):
         assert len(n) == 0
 
     def test_zipped(self):
-        # Make sure a zip file with multiple file types
-        # is returned as a single scene.
-
+        """
+        Make sure a zip file with multiple file types
+        is returned as a single scene.
+        """
         # allow mixed 2D and 3D geometry
         m = g.get_mesh('scenes.zip', mixed=True)
 
@@ -370,26 +372,6 @@ class SceneTests(g.unittest.TestCase):
         s = g.get_mesh('box.obj', group_material=False)
         assert set(s.geometry.keys()) != {'Material', 'SecondMaterial'}
 
-    def test_export_concat(self):
-        # Scenes exported in mesh formats should be
-        # concatenating the meshes somewhere.
-        original = g.trimesh.creation.icosphere(
-            radius=0.123312)
-        original_hash = original.identifier_md5
-
-        scene = g.trimesh.Scene()
-        scene.add_geometry(original)
-
-        with g.TemporaryDirectory() as d:
-            for ext in ['stl', 'ply']:
-                file_name = g.os.path.join(d, 'mesh.' + ext)
-                scene.export(file_name)
-                loaded = g.trimesh.load(file_name)
-                assert g.np.isclose(loaded.volume,
-                                    original.volume)
-        # nothing should have changed
-        assert original.identifier_md5 == original_hash
-
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_stl.py b/tests/test_stl.py
index a0f8c3c0..9e90f52d 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -84,11 +84,11 @@ class STLTests(g.unittest.TestCase):
             assert not hasattr(e, 'vertices')
 
             # create export
-            try:
-                e.export(file_type='ply')
-            except BaseException:
-                return
-            raise ValueError("Shouldn't export empty scenes!")
+            export = e.export(file_type='ply')
+            reconstructed = g.wrapload(export, file_type='ply')
+            # export should not contain geometry
+            assert isinstance(reconstructed, g.trimesh.Scene)
+            assert not hasattr(reconstructed, 'vertices')
 
 
 if __name__ == '__main__':