Repository: mikedh/trimesh
Pull request #1685 (2022-09-15 19:57:08)

Change uses of hashlib.md5 to an FIPS compliant hash function
#1684 


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..a46379e6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,128 @@
+Contributing To Trimesh
+=======================
+
+Pull requests are appreciated! 
+
+
+## Developer Quick Start
+
+Here's how I set up a new environment and write functions. It's not necessary to do it this way but it does make some things easier! Start with installing some stuff:
+```
+# I create a virtul env I enter every time I open a
+# terminal. There are plenty of alternative
+# options like poetry, pipenv, conda, etc but for simple
+# pip-installing-to-my-home-dir needs this works fine
+python -m venv ~/myenv
+# enter the venv every time you open a new terminal
+echo "source ~/myenv/bin/activate" >> ~/.bashrc
+
+# pip install in our venv
+# no-implicit-concat adds a rule which disallows strings on
+# different lines with no comma being concatenated as this is
+# pretty much always a bug. 
+pip install autopep8 flake8 flake8-no-implicit-concat codespell pyinstrument ipython
+```
+
+I pretty much always start with an interactive terminal (i.e. a "REPL") inside a stub function:
+```
+import numpy as np
+
+def fancy_function(blah):
+    if blah.shape != (3, 3):
+       raise ValueError('this input was goofy!')
+
+    # do some obvious operations and whatnot
+    dots = np.dot(blah, [1,2,3])
+
+    # get a REPL inside my function so I can write each line
+    # with the context of the function, copy paste the lines
+    # in and at the end return the value and remove the embed
+    from IPython import embed
+    embed()
+    
+if __name__ == '__main__':
+    # I like pyinstrument as it's a relatively low-overhead sampling
+    # profiler and has nice looking nested print statements compared
+    # to cProfile or others.
+    import pyinstrument
+    
+    data = np.random.random((3, 3))
+    with pyinstrument.Profiler() as pr:
+        result = fancy_function(data)
+    pr.print()
+```
+
+When you remove the embed and see the profile result you can then tweak the lines that are slow before finishing the function.
+
+Before PR'ing I run some auto-formatting rules which are available in `setup.py`:
+```
+# run autopep8 and yell at you about any flake8 rule violations
+python setup.py --format
+```
+
+
+## General Tips
+
+Python can be fast but only when you use it as little as possible. In general, if you ever have a block which loops through faces and vertices it will be basically unusable with even moderately sized meshes. All operations on face or vertex arrays should be vectorized numpy operations unless absolutely unavoidable. Profiling helps figure out what is slow, but some general advice:
+
+### Helpful
+- Run your test script with `ipython -i newstuff.py` and profile with magic, i.e. `%timeit var.split()`
+- Use `np.fromstring`, `np.frombuffer`
+- Use `str.split` or `np.fromstring`:
+```
+In [6]: %timeit np.array(text.split(), dtype=np.float64)
+1000 loops, best of 3: 209 µs per loop
+
+In [7]: %timeit np.fromstring(text, sep='\n', dtype=np.float64)
+10000 loops, best of 3: 139 µs per loop
+```
+- Use giant format strings rather than looping, appending, or even iterator joining:
+```
+In [14]: array = np.random.random((10000,3))
+
+In [15]: %timeit '\n'.join('{}/{}/{}'.format(*row) for row in array)
+10 loops, best of 3: 60.3 ms per loop
+
+In [16]: %timeit ('{}/{}/{}\n' * len(array))[:-1].format(*array.flatten())
+10 loops, best of 3: 34.3 ms per loop
+```
+- Sometimes you can use sparse matrices to replace a loop and get a huge speedup. [Here's the same algorithm implemented two ways, looping and sparse dot products.](https://github.com/mikedh/trimesh/blob/master/trimesh/geometry.py#L186-L203)
+- In tight loops, `array.sum(axis=1)` often pops up as the slowest thing. This can be replaced with a dot product of ones, which are very optimized can be substantially faster:
+```
+In [1]: import numpy as np
+
+In [2]: a = np.random.random((10000, 3))
+
+In [3]: %timeit a.sum(axis=1)
+10000 loops, best of 3: 157 µs per loop
+
+In [4]: %timeit np.dot(a, [1,1,1])
+10000 loops, best of 3: 25.8 µs per loop
+```
+- If you can use it, `np.concatenate` is usually faster than `np.vstack`, `np.append`, or `np.column_stack`
+```
+In [3]: seq = [np.random.random((int(np.random.random() * 1000), 3)) for i in range(1000)]
+
+In [7]: %timeit np.vstack(seq)
+100 loops, best of 3: 3.48 ms per loop
+
+In [8]: %timeit np.concatenate(seq)
+100 loops, best of 3: 2.33 ms per loop
+```
+- Sometimes `np.bincount` can be used instead of `np.unique` for a substantial speedup:
+```
+In [45]: a = (np.random.random(1000) * 1000).astype(int)
+
+In [46]: set(np.where(np.bincount(a).astype(bool))[0]) == set(np.unique(a))
+Out[46]: True
+
+In [47]: %timeit np.where(np.bincount(a).astype(bool))[0]
+100000 loops, best of 3: 5.81 µs per loop
+
+In [48]: %timeit np.unique(a)
+10000 loops, best of 3: 31.8 µs per loop
+```
+
+### Try To Avoid
+- Looping in general, and *especially* looping on arrays that could have many elements(i.e. vertices and faces). The loop overhead is very high in Python. If necessary to loop you may find that list comprehensions are quite a bit faster (though definitely profile first!) probably for scoping reasons.
+- Boolean operations (i.e. intersection, difference, union) on meshes may seem like the answer, but they are nearly always flaky and slow. The best answer is usually to restructure your problem to use some form of vector checks if possible (i.e. dot products, ray tests, etc). Look at `trimesh.intersections` for an example of a problem that could have used a boolean operation but didn't.
diff --git a/docs/Makefile b/docs/Makefile
index 4f9ef390..eae99c2c 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -12,8 +12,6 @@ TEMPLATESDIR  = templates
 STATICDIR     = _static
 
 example_notebooks := $(wildcard ../examples/*.ipynb)
-example_notebooks := $(filter-out ../examples/save_image.ipynb, $(example_notebooks))
-
 example_names = $(foreach path, $(example_notebooks), $(basename $(notdir $(path))))
 example_htmls = $(foreach name, $(example_names), $(STATICDIR)/examples/$(name).html)
 example_rsts = $(foreach name, $(example_names), examples.$(name).rst)
diff --git a/docs/install.md b/docs/install.md
index fcdec27c..dd03c8f2 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -75,5 +75,6 @@ Trimesh has a lot of soft-required upstream packages. We try to make sure they'r
 |`glooey`| Provide a viewer with widgets. | | `all`|
 |`meshio`| Load additional mesh formats. | | `all`|
 |`scikit-image`| Used in voxel ops | | `all`|
-|`mapbox-earcut`| Triangulate 2D polygons | `triangle` which has an unusual license | `all`|
+|`triangle`| Triangulate 2D polygons, with somewhat unclear licensing. | `mapbox-earcut` | `all`|
+|`mapbox-earcut`| Triangulate 2D polygons, with a permissive license but mostly abandoned | `triangle` | `all`|
 |`psutil`| Get current memory usage, useful for checking to see if we're going to run out of memory instantiating a giant array | | `all`|
\ No newline at end of file
diff --git a/examples/save_image.ipynb b/examples/save_image.ipynb
deleted file mode 100644
index bab59e23..00000000
--- a/examples/save_image.ipynb
+++ /dev/null
@@ -1,166 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "813d2960",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "\"\"\"\n",
-    "A simple example showing how to save a 3D object as an image\n",
-    "\"\"\""
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "183b68be",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import numpy as np\n",
-    "import trimesh\n",
-    "import math"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "ba25b4d4",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# load a file by name\n",
-    "mesh = trimesh.load_mesh('../models/featuretype.STL')"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "adb4a249",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# preview mesh in a pyglet window from a terminal, or inline in a notebook\n",
-    "mesh.show()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "fb628a7c",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a scene\n",
-    "scene = trimesh.Scene()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "4dc9de77",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Add the chosen geometry to the scene\n",
-    "scene.add_geometry(mesh)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "61e7427f",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Get the bounds corners for the camera transform\n",
-    "corners = scene.bounds_corners"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "a61704bc",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Get isometric view\n",
-    "r_e = trimesh.transformations.euler_matrix(\n",
-    "    math.radians(45),\n",
-    "    math.radians(45),\n",
-    "    math.radians(45),\n",
-    "    \"ryxz\",\n",
-    "    )"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "d36e4a59",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Get camera transform to look at geometry with isometric view \n",
-    "t_r = scene.camera.look_at(corners, rotation=r_e)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "1f8dc471",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Set camera transform\n",
-    "scene.camera_transform = t_r"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "c32d0d6d",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Render of scene as a PNG bytes\n",
-    "png = scene.save_image()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "id": "2791b600",
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Write the bytes to file\n",
-    "with open('../models/featuretype.png', \"wb\") as f:\n",
-    "    f.write(png)\n",
-    "    f.close()"
-   ]
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
-   "language": "python",
-   "name": "python3"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.9.6"
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/setup.py b/setup.py
index a782f603..071ec358 100644
--- a/setup.py
+++ b/setup.py
@@ -41,13 +41,14 @@ requirements_easy = set([
     '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([
+    'mapbox-earcut',  # faster 2D triangulations of polygons
+    'triangle',      # 2D triangulations of polygons
     'python-fcl',    # do fast 3D collision queries
     'psutil',        # figure out how much memory we have
     'glooey',        # make GUI applications with 3D stuff
diff --git a/trimesh/base.py b/trimesh/base.py
index 601470d0..852c5a74 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -102,7 +102,7 @@ class Trimesh(Geometry3D):
         # in the base class all that is stored here is vertex and
         # face information
         # any data put into the store is converted to a TrackedArray
-        # which is a subclass of np.ndarray that provides md5 and crc
+        # which is a subclass of np.ndarray that provides hash and crc
         # methods which can be used to detect changes in the array.
         self._data = caching.DataStore()
 
@@ -246,23 +246,23 @@ class Trimesh(Geometry3D):
         self.metadata['processed'] = True
         return self
 
-    def md5(self):
+    def hash(self):
         """
-        An MD5 of the core geometry information for the mesh,
+        A hash of the core geometry information for the mesh,
         faces and vertices.
 
         Generated from TrackedArray which subclasses np.ndarray to
         monitor array for changes and returns a correct lazily
-        evaluated md5 so it only has to recalculate the hash
+        evaluated hash so it only has to recalculate the hash
         occasionally, rather than on every call.
 
         Returns
         ----------
-        md5 : string
-          MD5 of everything in the DataStore
+        hash : string
+          hash of everything in the DataStore
         """
-        md5 = self._data.md5()
-        return md5
+        hash_val = self._data.hash()
+        return hash_val
 
     def crc(self):
         """
@@ -808,7 +808,7 @@ class Trimesh(Geometry3D):
           Points of triangle vertices
         """
         # use of advanced indexing on our tracked arrays will
-        # trigger a change flag which means the MD5 will have to be
+        # trigger a change flag which means the hash will have to be
         # recomputed. We can escape this check by viewing the array.
         triangles = self.vertices.view(np.ndarray)[self.faces]
 
@@ -2495,8 +2495,7 @@ class Trimesh(Geometry3D):
         simple : trimesh.Trimesh
           Simplified version of mesh.
         """
-        simple = self.as_open3d.simplify_quadric_decimation(
-            int(face_count))
+        simple = self.as_open3d.simplify_quadric_decimation(int(face_count))
         return Trimesh(vertices=simple.vertices, faces=simple.triangles)
 
     def outline(self, face_ids=None, **kwargs):
@@ -2711,14 +2710,14 @@ class Trimesh(Geometry3D):
         return identifier
 
     @caching.cache_decorator
-    def identifier_md5(self):
+    def identifier_hash(self):
         """
-        An MD5 of the rotation invariant identifier vector
+        A hash of the rotation invariant identifier vector
 
         Returns
         ---------
         hashed : str
-          MD5 hash of the identifier vector
+            hash of the identifier vector
         """
         hashed = comparison.identifier_hash(self.identifier)
         return hashed
@@ -3048,14 +3047,14 @@ class Trimesh(Geometry3D):
 
     def __hash__(self):
         """
-        Return the MD5 hash of the mesh as an integer.
+        Return the hash of the mesh as an integer.
 
         Returns
         ----------
         hashed : int
-          MD5 of mesh data
+          hash of mesh data
         """
-        hashed = int(self.md5(), 16)
+        hashed = int(self.hash(), 16)
         return hashed
 
     def __add__(self, other):
diff --git a/trimesh/caching.py b/trimesh/caching.py
index fb177b7b..b1ee1d11 100644
--- a/trimesh/caching.py
+++ b/trimesh/caching.py
@@ -9,12 +9,11 @@ and clearing cached values based on those changes.
 import numpy as np
 
 import zlib
-from hashlib import md5
 
 from functools import wraps
 
 from .constants import log
-from .util import is_sequence
+from .util import is_sequence, hash_func
 
 try:
     from collections.abc import Mapping
@@ -32,11 +31,14 @@ except BaseException:
         # older version of the algorithm
         from xxhash import xxh64_intdigest as fast_hash
     except BaseException:
-        log.debug('falling back to MD5 hashing: ' +
-                  '`pip install xxhash`' +
+        # use hashlib as a fallback hashing library
+        log.debug('falling back to hashlib ' +
+                  ' hashing: `pip install xxhash`' +
                   ' for 30x faster cache checks')
-        # use MD5 as a fallback hashing algorithm
-        def fast_hash(a): return int(md5(a).hexdigest(), 16)
+
+        def fast_hash(a):
+
+            return int(hash_func(a), 16)
 
 
 def tracked_array(array, dtype=None):
@@ -137,7 +139,7 @@ class TrackedArray(np.ndarray):
 
     Methods
     ----------
-    md5 :       str, hexadecimal MD5 of array
+    hash :      str, hexadecimal hash of array
     crc :       int, zlib crc32/adler32 checksum
     fast_hash : int, CRC or xxhash.xx64
     """
@@ -164,27 +166,26 @@ class TrackedArray(np.ndarray):
     def mutable(self, value):
         self.flags.writeable = value
 
-    def md5(self):
+    def hash(self):
         """
-        Return an MD5 hash of the current array.
+        Return a sha256 hash of the current array.
 
         Returns
         -----------
-        md5 : str
-          Hexadecimal MD5 of the array
+        sha256 : str
+          Hexadecimal sha256 of the array
         """
-        if self._modified_m or not hasattr(self, '_hashed_md5'):
+        if self._modified_m or not hasattr(self, '_hashed_self'):
             if self.flags['C_CONTIGUOUS']:
-                self._hashed_md5 = md5(self).hexdigest()
+                self._hashed_self = hash_func(self)
             else:
                 # the case where we have sliced our nice
                 # contiguous array into a non- contiguous block
                 # for example (note slice *after* track operation):
                 # t = util.tracked_array(np.random.random(10))[::-1]
-                self._hashed_md5 = md5(
-                    np.ascontiguousarray(self)).hexdigest()
+                self._hashed_self = hash_func((np.ascontiguousarray(self)))
         self._modified_m = False
-        return self._hashed_md5
+        return self._hashed_self
 
     def crc(self):
         """
@@ -601,7 +602,7 @@ class DataStore(Mapping):
         if not self.mutable:
             raise ValueError('DataStore is configured immutable!')
 
-        if hasattr(data, 'md5'):
+        if hasattr(data, 'hash'):
             # don't bother to re-track TrackedArray
             tracked = data
         else:
@@ -627,19 +628,18 @@ class DataStore(Mapping):
         for key, value in values.items():
             self[key] = value
 
-    def md5(self):
+    def hash(self):
         """
-        Get an MD5 reflecting everything in the DataStore.
+        Get a hash reflecting everything in the DataStore.
 
         Returns
         ----------
-        md5 : str
-          MD5 of data in hexadecimal
+        hash : str
+          hash of data in hexadecimal
         """
-        hasher = md5(''.join(
-            self.data[key].md5()
+        return hash_func(''.join(
+            self.data[key].hash()
             for key in sorted(self.data.keys())).encode('utf-8'))
-        return hasher.hexdigest()
 
     def crc(self):
         """
diff --git a/trimesh/comparison.py b/trimesh/comparison.py
index b58c4ee4..011ac71f 100644
--- a/trimesh/comparison.py
+++ b/trimesh/comparison.py
@@ -5,7 +5,6 @@ comparison.py
 Provide methods for quickly hashing and comparing meshes.
 """
 
-import hashlib
 import numpy as np
 
 from . import util
@@ -142,8 +141,8 @@ def identifier_hash(identifier, sigfig=None):
 
     Returns
     ----------
-    md5 : str
-      MD5 hash of identifier
+    hash : str
+      hash of identifier
     """
     if sigfig is None:
         sigfig = id_sigfig
@@ -154,7 +153,7 @@ def identifier_hash(identifier, sigfig=None):
     if (multiplier < 0).any():
         multiplier += np.abs(multiplier.min())
     hashable = (as_int * (10 ** multiplier)).astype(np.int64)
-    return hashlib.md5(hashable).hexdigest()
+    return util.hash_func(hashable)
 
 
 def face_ordering(mesh):
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 23e1943d..19fbb843 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -7,14 +7,11 @@ Create meshes from primitives, or with operations.
 
 from .base import Trimesh
 from .constants import log, tol
-from .geometry import (faces_to_edges,
-                       align_vectors,
-                       plane_transform)
+from .geometry import faces_to_edges, align_vectors, plane_transform
 
 from . import util
 from . import grouping
 from . import triangles
-from . import exceptions
 from . import transformations as tf
 
 import numpy as np
@@ -25,15 +22,19 @@ try:
     from shapely.geometry import Polygon
     from shapely.wkb import loads as load_wkb
 except BaseException as E:
+    # shapely will sometimes raise OSErrors
+    # on import rather than just ImportError
+    from . import exceptions
     # re-raise the exception when someone tries
     # to use the module that they don't have
     Polygon = exceptions.closure(E)
     load_wkb = exceptions.closure(E)
 
 try:
-    from mapbox_earcut import triangulate_float64 as _tri_earcut
+    from triangle import triangulate
 except BaseException as E:
-    _tri_earcut = exceptions.closure(E)
+    from . import exceptions
+    triangulate = exceptions.closure(E)
 
 
 def revolve(linestring,
@@ -232,7 +233,7 @@ def sweep_polygon(polygon,
         raise ValueError('Path must be (n, 3)!')
 
     # Extract 2D vertices and triangulation
-    verts_2d = np.array(polygon.exterior.coords)[:-1]
+    verts_2d = np.array(polygon.exterior)[:-1]
     base_verts_2d, faces_2d = triangulate_polygon(
         polygon, **kwargs)
     n = len(verts_2d)
@@ -444,38 +445,31 @@ def triangulate_polygon(polygon,
     faces : (n, 3) int
        Index of vertices that make up triangles
     """
-    if engine is None or engine == 'earcut':
-        # get vertices as sequence where exterior
-        # is the first value
-        vertices = [np.array(polygon.exterior.coords)]
-        vertices.extend(np.array(i.coords)
-                        for i in polygon.interiors)
+    if engine == 'earcut':
+        from mapbox_earcut import triangulate_float64
+
+        # get vertices as sequence where exterior is the first value
+        vertices = [np.array(polygon.exterior)]
+        vertices.extend(np.array(i) for i in polygon.interiors)
         # record the index from the length of each vertex array
         rings = np.cumsum([len(v) for v in vertices])
         # stack vertices into (n, 2) float array
         vertices = np.vstack(vertices)
         # run triangulation
-        faces = _tri_earcut(vertices, rings).reshape(
+        faces = triangulate_float64(vertices, rings).reshape(
             (-1, 3)).astype(np.int64).reshape((-1, 3))
 
         return vertices, faces
 
-    elif engine == 'triangle':
-        from triangle import triangulate
-        # set default triangulation arguments if not specified
-        if triangle_args is None:
-            triangle_args = 'p'
-            # turn the polygon in to vertices, segments, and holes
-        arg = _polygon_to_kwargs(polygon)
-        # run the triangulation
-        result = triangulate(arg, triangle_args)
-        return result['vertices'], result['triangles']
-    else:
-        log.warning('try running `pip install mapbox-earcut`' +
-                    'or explicitly pass:\n' +
-                    '`triangulate_polygon(*args, engine="triangle")`\n' +
-                    'to use the non-FSF-approved-license triangle engine')
-        raise ValueError('no valid triangulation engine!')
+    # set default triangulation arguments if not specified
+    if triangle_args is None:
+        triangle_args = 'p'
+    # turn the polygon in to vertices, segments, and hole points
+    arg = _polygon_to_kwargs(polygon)
+    # run the triangulation
+    result = triangulate(arg, triangle_args)
+
+    return result['vertices'], result['triangles']
 
 
 def _polygon_to_kwargs(polygon):
@@ -532,8 +526,7 @@ def _polygon_to_kwargs(polygon):
         # the points, but this is more robust (to things like concavity), if
         # slower.
         test = Polygon(cleaned)
-        holes.append(np.array(
-            test.representative_point().coords)[0])
+        holes.append(np.array(test.representative_point().coords)[0])
 
         return len(cleaned)
 
@@ -557,10 +550,12 @@ def _polygon_to_kwargs(polygon):
     # by stacking the sequence of (p,2) arrays
     vertices = np.vstack(vertices)
     facets = np.vstack(facets).tolist()
+
     # shapely polygons can include a Z component
     # strip it out for the triangulation
     if vertices.shape[1] == 3:
         vertices = vertices[:, :2]
+
     result = {'vertices': vertices,
               'segments': facets}
     # holes in meshpy lingo are a (h, 2) list of (x,y) points
@@ -569,6 +564,7 @@ def _polygon_to_kwargs(polygon):
     holes = np.array(holes)[1:]
     if len(holes) > 0:
         result['holes'] = holes
+
     return result
 
 
diff --git a/trimesh/intersections.py b/trimesh/intersections.py
index b717ea20..ee47c5a3 100644
--- a/trimesh/intersections.py
+++ b/trimesh/intersections.py
@@ -12,6 +12,7 @@ from . import grouping
 from . import transformations as tf
 
 from .constants import tol
+from .triangles import windings_aligned
 
 
 def mesh_plane(mesh,
@@ -672,8 +673,6 @@ def slice_mesh_plane(mesh,
     # avoid circular import
     from .base import Trimesh
     from .creation import triangulate_polygon
-    from .path import polygons
-    from scipy.spatial import cKDTree
 
     # check input plane
     plane_normal = np.asanyarray(
@@ -691,6 +690,7 @@ def slice_mesh_plane(mesh,
         raise ValueError('plane origins and normals must be (n, 3)!')
 
     # start with copy of original mesh, faces, and vertices
+    sliced_mesh = mesh.copy()
     vertices = mesh.vertices.copy()
     faces = mesh.faces.copy()
 
@@ -700,58 +700,90 @@ def slice_mesh_plane(mesh,
     # slice away specified planes
     for origin, normal in zip(plane_origin.reshape((-1, 3)),
                               plane_normal.reshape((-1, 3))):
+
+        # calculate dots here if not passed in to save time
+        # in case of cap
+        if cached_dots is None:
+            # dot product of each vertex with the plane normal indexed by face
+            # so for each face the dot product of each vertex is a row
+            # shape is the same as faces (n,3)
+            dots = np.einsum('i,ij->j', normal,
+                             (vertices - origin).T)
+        else:
+            dots = cached_dots
         # save the new vertices and faces
-        vertices, faces = slice_faces_plane(
-            vertices=vertices,
-            faces=faces,
-            plane_normal=normal,
-            plane_origin=origin,
-            face_index=face_index)
+        vertices, faces = slice_faces_plane(vertices=vertices,
+                                            faces=faces,
+                                            plane_normal=normal,
+                                            plane_origin=origin,
+                                            face_index=face_index,
+                                            cached_dots=dots)
+
         # check if cap arg specified
         if cap:
             if face_index:
                 # This hasn't been implemented yet.
-                raise NotImplementedError(
-                    "face_index and cap can't be used together")
-
-            # start by deduplicating vertices again
-            unique, inverse = grouping.unique_rows(vertices)
-            vertices = vertices[unique]
-            # will collect additional faces
-            f = inverse[faces]
-
-            # transform to the cap plane
-            to_2D = geometry.plane_transform(
-                origin=origin,
-                normal=-normal)
-            to_3D = np.linalg.inv(to_2D)
-
-            vertices_2D = tf.transform_points(vertices, to_2D)
-            edges = geometry.faces_to_edges(f)
-            edges.sort(axis=1)
-
-            on_plane = np.abs(vertices_2D[:, 2]) < 1e-8
-            edges = edges[on_plane[edges].all(axis=1)]
-            edges = edges[edges[:, 0] != edges[:, 1]]
-
-            unique_edge = grouping.group_rows(
-                edges, require_count=1)
-            if len(unique) < 3:
+                raise NotImplementedError("face_index and cap can't be used together")
+            # check if mesh is watertight (can't cap if not)
+            if not sliced_mesh.is_watertight:
+                raise ValueError('Input mesh must be watertight to cap slice')
+            path = sliced_mesh.section(
+                plane_normal=normal,
+                plane_origin=origin,
+                cached_dots=dots)
+            if path is None:
+                # if path is None it means this plane didn't
+                # intersect anything so we can exit early without
+                # doing anything to cap the result
                 continue
-
-            tree = cKDTree(vertices)
-            # collect new faces
-            faces = [f]
-            for p in polygons.edges_to_polygons(
-                    edges[unique_edge], vertices_2D[:, :2]):
-                vn, fn = triangulate_polygon(p)
-                # collect the original index for the new vertices
-                vn3 = tf.transform_points(util.stack_3D(vn), to_3D)
-                distance, vid = tree.query(vn3)
-                # triangulation should not have inserted vertices
-                assert distance.max() < 1e-8
-                faces.append(vid[fn])
-            faces = np.vstack(faces)
+            # transform Path3D onto XY plane for triangulation
+            on_plane, to_3D = path.to_planar()
+            # triangulate each closed region of 2D cap
+            # without adding any new vertices
+            v, f = [], []
+            for polygon in on_plane.polygons_full:
+                t = triangulate_polygon(
+                    polygon, triangle_args='pY', engine='triangle')
+                v.append(t[0])
+                f.append(t[1])
+
+                if tol.strict:
+                    # in unit tests make sure that our triangulation didn't
+                    # insert any new vertices which would break watertightness
+                    from scipy.spatial import cKDTree
+                    # get all interior and exterior points on tree
+                    check = [np.array(polygon.exterior.coords)]
+                    check.extend(np.array(i.coords) for i in polygon.interiors)
+                    tree = cKDTree(np.vstack(check))
+                    # every new vertex should be on an old vertex
+                    assert np.allclose(tree.query(v[-1])[0], 0.0)
+
+            # append regions and reindex
+            vf, ff = util.append_faces(v, f)
+
+            # make vertices 3D and transform back to mesh frame
+            vf = tf.transform_points(
+                np.column_stack((vf, np.zeros(len(vf)))),
+                to_3D)
+
+            # check to see if our new faces are aligned with our normal
+            check = windings_aligned(vf[ff], normal)
+
+            # if 50% of our new faces are aligned with the normal flip
+            if check.astype(np.float64).mean() > 0.5:
+                ff = np.fliplr(ff)
+
+            # check vertices to see which ones are on plane
+            on_plane = np.abs(np.dot(vertices - origin, normal)) < tol.merge
+            # add cap vertices and faces and reindex
+            # remove any original faces which are coplanar with cap plane
+            # as these will be replaced by newly generated faces
+            vertices, faces = util.append_faces(
+                [vertices, vf], [faces[~on_plane[faces].all(axis=1)], ff])
+
+            # Update mesh with cap (processing needed to merge vertices)
+            sliced_mesh = Trimesh(vertices=vertices, faces=faces)
+            vertices, faces = sliced_mesh.vertices.copy(), sliced_mesh.faces.copy()
 
     # return the sliced mesh
     return Trimesh(vertices=vertices, faces=faces, **kwargs)
diff --git a/trimesh/parent.py b/trimesh/parent.py
index a5ca0382..e893c4b0 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -44,7 +44,7 @@ class Geometry(ABC):
         pass
 
     @abc.abstractmethod
-    def md5(self):
+    def hash(self):
         pass
 
     @abc.abstractmethod
diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py
index e6b5c98b..9e142827 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -297,8 +297,8 @@ def _svg_path_convert(paths, force=None):
 
         if tol.strict:
             # in unit tests make sure we didn't lose any entities
-            assert util.allclose(np.hstack(blocks),
-                                 np.arange(len(raw)))
+            assert np.allclose(np.hstack(blocks),
+                               np.arange(len(raw)))
 
         # Combine consecutive lines into a single MultiLine
         parsed = []
@@ -374,7 +374,7 @@ def _entities_to_str(entities,
     temp_digits = '0.{}f'.format(int(digits))
     # generate a format string for circles as two arc segments
     temp_circle = ('M {x:DI},{y:DI}a{r:DI},{r:DI},0,1,0,{d:DI},' +
-                   '0a{r:DI},{r:DI},0,1,0,-{d:DI},0Z').replace('DI', temp_digits)
+                   '0a{r:DI},{r:DI},0,1,{rev},-{d:DI},0Z').replace('DI', temp_digits)
     # generate a format string for an absolute move-to command
     temp_move = 'M{:DI},{:DI}'.replace('DI', temp_digits)
     # generate a format string for an absolute-line command
@@ -383,13 +383,15 @@ def _entities_to_str(entities,
     temp_arc = 'M{SX:DI} {SY:DI}A{R},{R} 0 {L:d},{S:d} {EX:DI},{EY:DI}'.replace(
         'DI', temp_digits)
 
-    def svg_arc(arc):
+    def svg_arc(arc, reverse=False):
         """
         arc string: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
         large-arc-flag: greater than 180 degrees
         sweep flag: direction (cw/ccw)
         """
-        vertices = points[arc.points]
+        arc_idx = arc.points[::((reverse * -2) + 1)]
+        vertices = points[arc_idx]
+        vertex_start, vertex_mid, vertex_end = vertices
         info = arc_center(
             vertices, return_normal=False, return_angle=True)
         C, R, angle = info['center'], info['radius'], info['span']
@@ -397,9 +399,9 @@ def _entities_to_str(entities,
             return temp_circle.format(x=C[0] - R,
                                       y=C[1],
                                       r=R,
-                                      d=2.0 * R)
+                                      d=2.0 * R,
+                                      rev=int(bool(reverse)))
 
-        vertex_start, vertex_mid, vertex_end = vertices
         large_flag = int(angle > np.pi)
         sweep_flag = int(np.cross(vertex_mid - vertex_start,
                                   vertex_end - vertex_start) > 0.0)
@@ -411,7 +413,7 @@ def _entities_to_str(entities,
                                EY=vertex_end[1],
                                R=R)
 
-    def svg_discrete(entity):
+    def svg_discrete(entity, reverse=False):
         """
         Use an entities discrete representation to export a
         curve as a polyline
@@ -420,9 +422,14 @@ def _entities_to_str(entities,
         # if entity contains no geometry return
         if len(discrete) == 0:
             return ''
+        # are we reversing the entity
+        if reverse:
+            discrete = discrete[::-1]
         # the format string for the SVG path
         result = (temp_move + (temp_line * (len(discrete) - 1))).format(
             *discrete.reshape(-1))
+        assert result.count('L') == len(discrete) - 1
+
         return result
 
     # tuples of (metadata, path string)
@@ -431,8 +438,9 @@ def _entities_to_str(entities,
     for entity in entities:
         if only_layers is not None and entity.layer not in only_layers:
             continue
-        # check the class name of the entity
-        if entity.__class__.__name__ == 'Arc':
+        # the class name of the entity
+        etype = entity.__class__.__name__
+        if etype == 'Arc':
             # export the exact version of the entity
             path_string = svg_arc(entity)
         else:
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index 9d012282..d57a4dc2 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -8,7 +8,6 @@ those stored in a DXF or SVG file.
 import numpy as np
 
 import copy
-import hashlib
 import collections
 
 from ..points import plane_fit
@@ -230,20 +229,21 @@ class Path(parent.Geometry):
         target ^= self.vertices.fast_hash()
         return target
 
-    def md5(self):
+    def hash(self):
         """
-        An MD5 hash of the current vertices and entities.
+        A hash of the current vertices and entities.
 
         Returns
         ------------
-        md5 : str
-          Appended MD5 hashes
+        hash : str
+          Appended hashes
         """
-        # first MD5 the points in every entity
+        # first hash the points in every entity
+        hash_val = util.hash_func(bytes().join(
+            e._bytes() for e in self.entities))
         target = '{}{}'.format(
-            hashlib.md5(bytes().join(
-                e._bytes() for e in self.entities)).hexdigest(),
-            self.vertices.md5())
+            hash_val,
+            self.vertices.hash())
 
         return target
 
@@ -1445,7 +1445,7 @@ class Path2D(Path):
 
             fmt = eformat[e_key].copy()
             if color is not None:
-                # passed color will override other options
+                # passed color will override other optons
                 fmt['color'] = color
             elif hasattr(entity, 'color'):
                 # if entity has specified color use it
@@ -1469,9 +1469,9 @@ class Path2D(Path):
         return polygons.polygon_hash(self.polygons_full[0])
 
     @caching.cache_decorator
-    def identifier_md5(self):
+    def identifier_hash(self):
         """
-        Return an MD5 of the identifier.
+        Return a hash of the identifier.
 
         Returns
         ----------
@@ -1479,8 +1479,7 @@ class Path2D(Path):
           Hashed identifier.
         """
         as_int = (self.identifier * 1e4).astype(np.int64)
-        hashed = hashlib.md5(as_int.tobytes(order='C')).hexdigest()
-        return hashed
+        return util.hash_func(as_int.tobytes(order='C'))
 
     @property
     def path_valid(self):
diff --git a/trimesh/points.py b/trimesh/points.py
index 6e914f12..60c1b2c9 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -418,7 +418,7 @@ class PointCloud(Geometry3D):
           Metadata about points
         """
         self._data = caching.DataStore()
-        self._cache = caching.Cache(self._data.md5)
+        self._cache = caching.Cache(self._data.hash)
         self.metadata = {}
 
         if metadata is not None:
@@ -490,16 +490,16 @@ class PointCloud(Geometry3D):
 
         return copied
 
-    def md5(self):
+    def hash(self):
         """
-        Get an MD5 hash of the current vertices.
+        Get a hash of the current vertices.
 
         Returns
         ----------
-        md5 : str
+        hash : str
           Hash of self.vertices
         """
-        return self._data.md5()
+        return self._data.hash()
 
     def crc(self):
         """
diff --git a/trimesh/ray/ray_pyembree.py b/trimesh/ray/ray_pyembree.py
index ffddf4cf..5b9603c5 100644
--- a/trimesh/ray/ray_pyembree.py
+++ b/trimesh/ray/ray_pyembree.py
@@ -235,7 +235,7 @@ class RayMeshIntersector(object):
             else:
                 break
 
-        # stack the dequeues into nice 1D numpy arrays
+        # stack the deques into nice 1D numpy arrays
         index_tri = np.hstack(result_triangle)
         index_ray = np.hstack(result_ray_idx)
 
diff --git a/trimesh/resources/schema/primitive/box.schema.json b/trimesh/resources/schema/primitive/box.schema.json
index 9227dadb..3626219e 100644
--- a/trimesh/resources/schema/primitive/box.schema.json
+++ b/trimesh/resources/schema/primitive/box.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/box.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 3D box primitive.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/capsule.schema.json b/trimesh/resources/schema/primitive/capsule.schema.json
index fc223c1a..c3442f4d 100644
--- a/trimesh/resources/schema/primitive/capsule.schema.json
+++ b/trimesh/resources/schema/primitive/capsule.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/capsule.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 3D capsule primitive: a cylinder capped by tangent hemispheres.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/cylinder.schema.json b/trimesh/resources/schema/primitive/cylinder.schema.json
index e7e7e66b..10605979 100644
--- a/trimesh/resources/schema/primitive/cylinder.schema.json
+++ b/trimesh/resources/schema/primitive/cylinder.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/cylinder.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 3D cylinder primitive.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/extrusion.schema.json b/trimesh/resources/schema/primitive/extrusion.schema.json
index 5bbc23b0..af1f4ab6 100644
--- a/trimesh/resources/schema/primitive/extrusion.schema.json
+++ b/trimesh/resources/schema/primitive/extrusion.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/extrusion.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 2D polygon extruded from Z=0 along positive Z by `height` then transformed by the `transform` matrix.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/primitive.schema.json b/trimesh/resources/schema/primitive/primitive.schema.json
index f611c830..f8122c3d 100644
--- a/trimesh/resources/schema/primitive/primitive.schema.json
+++ b/trimesh/resources/schema/primitive/primitive.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/primitive.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "anyOf": [
     {
       "$ref": "box.schema.json"
diff --git a/trimesh/resources/schema/primitive/sphere.schema.json b/trimesh/resources/schema/primitive/sphere.schema.json
index 92dd6b74..893e6cf4 100644
--- a/trimesh/resources/schema/primitive/sphere.schema.json
+++ b/trimesh/resources/schema/primitive/sphere.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/sphere.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 3D sphere primitive.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/transform.schema.json b/trimesh/resources/schema/primitive/transform.schema.json
index 2e8a75ae..20d61cd6 100644
--- a/trimesh/resources/schema/primitive/transform.schema.json
+++ b/trimesh/resources/schema/primitive/transform.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/transform.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "description": "A homogeneous transformation matrix. If not passed it is assumed to be an identity matrix ",
   "items": {
     "items": {
diff --git a/trimesh/resources/schema/primitive/trimesh.schema.json b/trimesh/resources/schema/primitive/trimesh.schema.json
index 58842deb..d798434f 100644
--- a/trimesh/resources/schema/primitive/trimesh.schema.json
+++ b/trimesh/resources/schema/primitive/trimesh.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/trimesh.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "additionalProperties": false,
   "description": "A 3D triangular mesh in the simplest possible JSON format.",
   "properties": {
diff --git a/trimesh/resources/schema/primitive/wkt.polygon.schema.json b/trimesh/resources/schema/primitive/wkt.polygon.schema.json
index cab6f5fc..53aefb79 100644
--- a/trimesh/resources/schema/primitive/wkt.polygon.schema.json
+++ b/trimesh/resources/schema/primitive/wkt.polygon.schema.json
@@ -1,6 +1,6 @@
 {
   "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/wkt.polygon.schema.json",
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$schema": "https://json-schema.org/draft-07/schema",
   "description": "A single polygon in the Well Known Text format (WKT).",
   "pattern": " *POLYGON *\\( *\\( *([+-]?([0-9]*[.])?[0-9]+ *[+-]?([0-9]*[.])?[0-9]+ *,* *)*\\) *\\) *",
   "type": "string"
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 61e79078..740c663d 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -1,4 +1,3 @@
-import hashlib
 import numpy as np
 import collections
 
@@ -63,7 +62,7 @@ class Scene(Geometry3D):
         self.graph = SceneGraph(base_frame=base_frame)
 
         # create our cache
-        self._cache = caching.Cache(id_function=self.md5)
+        self._cache = caching.Cache(id_function=self.hash)
 
         # add passed geometry to scene
         self.add_geometry(geometry)
@@ -234,18 +233,18 @@ class Scene(Geometry3D):
             if util.is_instance_named(geometry, 'Trimesh'):
                 geometry.visual = ColorVisuals(mesh=geometry)
 
-    def md5(self):
+    def hash(self):
         """
-        MD5 of scene which will change when meshes or
+        hash of scene which will change when meshes or
         transforms are changed
 
         Returns
         --------
         hashed : str
-          MD5 hash of scene
+          hash of scene
         """
         # start with transforms hash
-        return hashlib.md5(self._hashable()).hexdigest()
+        return util.hash_func(self._hashable())
 
     def crc(self):
         """
@@ -481,14 +480,14 @@ class Scene(Geometry3D):
     @caching.cache_decorator
     def geometry_identifiers(self):
         """
-        Look up geometries by identifier MD5
+        Look up geometries by identifier hash
 
         Returns
         ---------
         identifiers : dict
-          {Identifier MD5: key in self.geometry}
+          {Identifier hash: key in self.geometry}
         """
-        identifiers = {mesh.identifier_md5: name
+        identifiers = {mesh.identifier_hash: name
                        for name, mesh in self.geometry.items()}
         return identifiers
 
@@ -509,10 +508,10 @@ class Scene(Geometry3D):
         if len(self.geometry) == 0:
             return []
 
-        # geometry name : md5 of mesh
-        hashes = {k: int(m.identifier_md5, 16)
+        # geometry name : hash of mesh
+        hashes = {k: int(m.identifier_hash, 16)
                   for k, m in self.geometry.items()
-                  if hasattr(m, 'identifier_md5')}
+                  if hasattr(m, 'identifier_hash')}
 
         # bring into local scope for loop
         graph = self.graph
diff --git a/trimesh/smoothing.py b/trimesh/smoothing.py
index 94670dbb..04df9684 100644
--- a/trimesh/smoothing.py
+++ b/trimesh/smoothing.py
@@ -341,7 +341,7 @@ def get_vertices_normals(mesh):
 
 def dilate_slope(vertices, faces, normals, v, eps):
     """
-    Get de derivate of dilation scalar by the volume variation by finite differences
+    Get de derivate of dilation scalar by the volume variation by finite diferences
     Thus, Vertices += vertex_normals*dilate_slope*(Initial_Volume - Srinked_Volume)
     Parameters
       -------------
@@ -357,7 +357,7 @@ def dilate_slope(vertices, faces, normals, v, eps):
         derivative
     """
 
-    # finite difference derivative
+    # finite diference derivative
     vertices2 = vertices + normals * eps
     v2 = mass_properties(vertices2[faces], skip_inertia=True)["volume"]
 
diff --git a/trimesh/util.py b/trimesh/util.py
index 3627a224..5e375435 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -861,7 +861,7 @@ def decimal_to_digits(decimal, min_digits=None):
 
 
 def hash_file(file_obj,
-              hash_function=hashlib.md5):
+              hash_function=None):
     """
     Get the hash of an open file-like object.
 
@@ -878,11 +878,14 @@ def hash_file(file_obj,
     # in the file (which is probably 0)
     file_position = file_obj.tell()
     # create an instance of the hash object
-    hasher = hash_function()
-    # read all data from the file into the hasher
-    hasher.update(file_obj.read())
-    # get a hex version of the result
-    hashed = hasher.hexdigest()
+    if hash_function is None:
+        hashed = hash_func(file_obj.read())
+    else:
+        hasher = hash_function()
+        # read all data from the file into the hasher
+        hasher.update(file_obj.read())
+        # get a hex version of the result
+        hashed = hasher.hexdigest()
     # return the file object to its original position
     file_obj.seek(file_position)
 
@@ -1676,10 +1679,9 @@ def jsonify(obj, **kwargs):
 
     Parameters
     --------------
-    obj : list, dict
-      A JSON-serializable blob
-    kwargs : dict
-      Passed to json.dumps
+    obj : JSON-serializable blob
+    **kwargs :
+        Passed to json.dumps
 
     Returns
     --------------
@@ -2412,3 +2414,22 @@ def is_ccw(points):
     ccw = area < 0
 
     return ccw
+
+
+def hash_func(val):
+    """
+    Hash a value val
+
+    Parameters
+    -----------
+    val : bytes string
+
+    Returns
+    ----------
+    hash : str
+        hash of val in str form
+    """
+    if PY3:
+        return hashlib.blake2b(val).hexdigest()
+    else:
+        return hashlib.sha256(val).hexdigest()
diff --git a/trimesh/version.py b/trimesh/version.py
index f3bb9e91..7499b9d6 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.15.1'
+__version__ = '3.14.1'
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index f7006496..85780717 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -851,8 +851,8 @@ def geometry_hash(geometry):
     if hasattr(geometry, 'crc'):
         # for most of our trimesh objects
         h = str(geometry.crc())
-    elif hasattr(geometry, 'md5'):
-        h = geometry.md5()
+    elif hasattr(geometry, 'hash'):
+        h = geometry.hash()
     elif hasattr(geometry, 'tostring'):
         # for unwrap ndarray objects
         h = str(hash(geometry.tostring()))
diff --git a/trimesh/voxel/base.py b/trimesh/voxel/base.py
index 3496fa1f..d04429ef 100644
--- a/trimesh/voxel/base.py
+++ b/trimesh/voxel/base.py
@@ -47,8 +47,8 @@ class VoxelGrid(Geometry):
             raise ValueError(
                 'metadata should be a dict or None, got %s' % str(metadata))
 
-    def md5(self):
-        return self._data.md5()
+    def hash(self):
+        return self._data.hash()
 
     def crc(self):
         return self._data.crc()
diff --git a/trimesh/voxel/creation.py b/trimesh/voxel/creation.py
index cd2499b5..e232a4b9 100644
--- a/trimesh/voxel/creation.py
+++ b/trimesh/voxel/creation.py
@@ -21,14 +21,10 @@ def voxelize_subdivide(mesh,
 
     Parameters
     -----------
-    mesh : trimesh.Trimesh
-      Source mesh
-    pitch : float
-      Side length of a single voxel cube
-    max_iter : int
-      Cap maximum subdivisions or None for no limit.
-    edge_factor : float
-      Proportion of pitch maximum edge length.
+    mesh:        Trimesh object
+    pitch:       float, side length of a single voxel cube
+    max_iter:    int, cap maximum subdivisions or None for no limit.
+    edge_factor: float,
 
     Returns
     -----------
@@ -37,20 +33,17 @@ def voxelize_subdivide(mesh,
     max_edge = pitch / edge_factor
 
     if max_iter is None:
-        longest_edge = np.linalg.norm(
-            mesh.vertices[mesh.edges[:, 0]] -
-            mesh.vertices[mesh.edges[:, 1]],
-            axis=1).max()
-        max_iter = max(int(np.ceil(np.log2(
-            longest_edge / max_edge))), 0)
+        longest_edge = np.linalg.norm(mesh.vertices[mesh.edges[:, 0]] -
+                                      mesh.vertices[mesh.edges[:, 1]],
+                                      axis=1).max()
+        max_iter = max(int(np.ceil(np.log2(longest_edge / max_edge))), 0)
 
     # get the same mesh sudivided so every edge is shorter
     # than a factor of our pitch
-    v, f, idx = remesh.subdivide_to_size(mesh.vertices,
-                                         mesh.faces,
-                                         max_edge=max_edge,
-                                         max_iter=max_iter,
-                                         return_index=True)
+    v, f = remesh.subdivide_to_size(mesh.vertices,
+                                    mesh.faces,
+                                    max_edge=max_edge,
+                                    max_iter=max_iter)
 
     # convert the vertices to their voxel grid position
     hit = v / pitch
diff --git a/trimesh/voxel/encoding.py b/trimesh/voxel/encoding.py
index f7dc42c7..484c772b 100644
--- a/trimesh/voxel/encoding.py
+++ b/trimesh/voxel/encoding.py
@@ -118,8 +118,8 @@ class Encoding(ABC):
     def _flip(self, axes):
         return FlippedEncoding(self, axes)
 
-    def md5(self):
-        return self._data.md5()
+    def hash(self):
+        return self._data.hash()
 
     def crc(self):
         return self._data.crc()
@@ -451,8 +451,8 @@ class RunLengthEncoding(Encoding):
     def dtype(self):
         return self._dtype
 
-    def md5(self):
-        return self._data.md5()
+    def hash(self):
+        return self._data.hash()
 
     def crc(self):
         return self._data.crc()
diff --git a/trimesh/voxel/transforms.py b/trimesh/voxel/transforms.py
index 93bb202e..70927b80 100644
--- a/trimesh/voxel/transforms.py
+++ b/trimesh/voxel/transforms.py
@@ -35,16 +35,16 @@ class Transform(object):
         # dump cache when matrix changes
         self._cache = caching.Cache(id_function=self._data.fast_hash)
 
-    def md5(self):
+    def hash(self):
         """
-        Get the MD5 hash of the current transformation matrix.
+        Get the hash of the current transformation matrix.
 
         Returns
         ------------
-        md5 : str
+        hash : str
           Hash of transformation matrix
         """
-        return self._data.md5()
+        return self._data.hash()
 
     def crc(self):
         """


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 79e97628..0ae4e25b 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -31,6 +31,8 @@ from collections import deque
 from copy import deepcopy
 
 
+from distutils.spawn import find_executable
+
 try:
     # Python 3
     from http.server import SimpleHTTPRequestHandler
@@ -69,15 +71,6 @@ trimesh.constants.tol.strict = True
 trimesh.constants.tol_path.strict = True
 
 
-try:
-    from mapbox_earcut import triangulate_float64
-    has_earcut = True
-except BaseException as E:
-    if all_dep:
-        raise E
-    else:
-        has_earcut = False
-
 try:
     from shapely.geometry import Point, Polygon, LineString
     has_path = True
@@ -479,7 +472,7 @@ data = _load_data()
 
 # find executables to run with subprocess
 # formats supported by meshlab for export tests
-if any(trimesh.util.which(i) is None
+if any(find_executable(i) is None
        for i in ['xfvb-run', 'meshlabserver']):
     meshlab_formats = []
 else:
diff --git a/tests/test_cache.py b/tests/test_cache.py
index fc013706..afb271fd 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -16,7 +16,7 @@ class CacheTest(g.unittest.TestCase):
         count = 10000
 
         g.timeit.timeit(setup=setup,
-                        stmt='t._modified_m=True;t.md5()',
+                        stmt='t._modified_m=True;t.hash()',
                         number=count)
         g.timeit.timeit(setup=setup,
                         stmt='t._modified_c=True;t.crc()',
@@ -27,8 +27,8 @@ class CacheTest(g.unittest.TestCase):
 
         m = g.get_mesh('featuretype.STL')
         # log result values
-        g.log.info('\nResult\nMD5:\n{}\nCRC:\n{}\nXX:\n{}'.format(
-            m.vertices.md5(),
+        g.log.info('\nResult\nhash:\n{}\nCRC:\n{}\nXX:\n{}'.format(
+            m.vertices.hash(),
             m.vertices.crc(),
             m.vertices.fast_hash()))
 
@@ -42,30 +42,30 @@ class CacheTest(g.unittest.TestCase):
             g.np.random.random(TEST_DIM))
         modified = []
 
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         a[0][0] = 10
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a[0][0] += 0.1
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a[:10] = g.np.fliplr(a[:10])
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a[1] = 5
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         a[2:] = 2
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
@@ -76,20 +76,20 @@ class CacheTest(g.unittest.TestCase):
 
         # now do slice operations which don't alter data
         modified = []
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         b = a[[0, 1, 2]]  # NOQA
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         c = a[1:]  # NOQA
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         # double slice brah
         a = a[::-1][::-1]
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
@@ -102,11 +102,11 @@ class CacheTest(g.unittest.TestCase):
         a = g.trimesh.caching.tracked_array([0, 0, 4])
         modified = []
 
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
         a += 10
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
@@ -114,40 +114,40 @@ class CacheTest(g.unittest.TestCase):
         a = g.trimesh.caching.tracked_array([.125, 115.32444, 4],
                                             dtype=g.np.float64)
 
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a += [10, 0, 0]
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a *= 10
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         # itruediv rather than idiv
         a /= 2.0
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         # idiv
         a /= 2.123
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         a -= 1.0
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
         # in place floor division :|
         a //= 2
-        modified.append([int(a.md5(), 16),
+        modified.append([int(a.hash(), 16),
                          a.crc(),
                          a.fast_hash()])
 
@@ -162,8 +162,8 @@ class CacheTest(g.unittest.TestCase):
 
         # hashing will fail on non- contiguous arrays
         # make sure our utility function has handled this properly
-        # for both MD5 and CRC
-        assert t.md5() != t[::-1].md5()
+        # for both hash and CRC
+        assert t.hash() != t[::-1].hash()
         assert t.crc() != t[::-1].crc()
         assert t.fast_hash() != t[::-1].fast_hash()
 
diff --git a/tests/test_copy.py b/tests/test_copy.py
index dcc796b6..96340f6b 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -13,7 +13,7 @@ class CopyTests(g.unittest.TestCase):
         for mesh in g.get_meshes(raise_error=True):
             if not isinstance(mesh, g.trimesh.Trimesh) or len(mesh.faces) == 0:
                 continue
-            start = {mesh.md5(), mesh.crc()}
+            start = {mesh.hash(), mesh.crc()}
 
             # make sure some stuff is populated
             mesh.kdtree
@@ -51,7 +51,7 @@ class CopyTests(g.unittest.TestCase):
                                      mesh.identifier)
 
             # ...still shouldn't have changed anything
-            assert start == {mesh.md5(), mesh.crc()}
+            assert start == {mesh.hash(), mesh.crc()}
 
 
 if __name__ == '__main__':
diff --git a/tests/test_creation.py b/tests/test_creation.py
index f1e265da..3e5d0dff 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -143,8 +143,7 @@ class CreationTest(g.unittest.TestCase):
 
         # Extrude
         for engine in self.engines:
-            mesh = g.trimesh.creation.sweep_polygon(
-                poly, path, engine=engine)
+            mesh = g.trimesh.creation.sweep_polygon(poly, path, engine=engine)
             assert mesh.is_volume
 
     def test_annulus(self):
@@ -228,43 +227,36 @@ class CreationTest(g.unittest.TestCase):
         assert g.np.isclose(donut.area,
                             bigger.area - smaller.area)
 
-        times = {'earcut': 0.0, 'triangle': 0.0}
+        times = {}
         iterations = 50
         # get a polygon to benchmark times with including interiors
-        bench = [bigger, smaller, donut]
-        bench.extend(g.get_mesh(
-            '2D/ChuteHolderPrint.DXF').polygons_full)
-        bench.extend(g.get_mesh(
-            '2D/wrench.dxf').polygons_full)
-
+        bench = g.get_mesh('2D/wrench.dxf').polygons_full[0]
         # check triangulation of both meshpy and triangle engine
         # including an example that has interiors
         for engine in self.engines:
             # make sure all our polygons triangulate resonably
-            for poly in bench:
+            for poly in [bigger, smaller, donut, bench]:
                 v, f = g.trimesh.creation.triangulate_polygon(
                     poly, engine=engine)
                 # run asserts
                 check_triangulation(v, f, poly.area)
-                try:
-                    # do a quick benchmark per engine
-                    # in general triangle appears to be 2x
-                    # faster than
-                    times[engine] += min(
-                        g.timeit.repeat(
-                            't(p, engine=e)',
-                            repeat=3,
-                            number=iterations,
-                            globals={
-                                't': g.trimesh.creation.triangulate_polygon,
-                                'p': poly,
-                                'e': engine})) / iterations
-                except BaseException:
-                    g.log.error(
-                        'failed to benchmark triangle', exc_info=True)
-        g.log.info(
-            'benchmarked triangulation on {} polygons: {}'.format(
-                len(bench), str(times)))
+            try:
+                # do a quick benchmark per engine
+                # in general triangle appears to be 2x
+                # faster than meshpy
+                times[engine] = min(
+                    g.timeit.repeat(
+                        't(p, engine=e)',
+                        repeat=3,
+                        number=iterations,
+                        globals={'t': g.trimesh.creation.triangulate_polygon,
+                                 'p': bench,
+                                 'e': engine})) / iterations
+            except BaseException:
+                g.log.error(
+                    'failed to benchmark triangle', exc_info=True)
+        g.log.warning(
+            'benchmarked triangle interfaces: {}'.format(str(times)))
 
     def test_triangulate_plumbing(self):
         """
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index c3457b13..e6ed3f99 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -6,7 +6,7 @@ except BaseException:
 # Khronos' official file validator
 # can be installed with the helper script:
 # `trimesh/docker/builds/gltf_validator.bash`
-_gltf_validator = g.trimesh.util.which('gltf_validator')
+_gltf_validator = g.find_executable('gltf_validator')
 
 
 def validate_glb(data, name=None):
@@ -52,7 +52,7 @@ def validate_glb(data, name=None):
             g.log.error(content)
             if name is not None:
                 g.log.error('failed on: %s', name)
-            raise ValueError(content)
+            raise ValueError('Khronos GLTF validator error!')
 
         if any(decode['issues'][i] > 0 for i in
                ['numWarnings', 'numInfos', 'numHints']):
@@ -514,7 +514,7 @@ class GLTFTest(g.unittest.TestCase):
         # when you add a uint16/int16 the gltf-validator
         # complains about the 4-byte boundaries even though
         # all their lengths and offsets mod 4 are zero
-        # not sure if that's a validator bug or what
+        # not sure if thats a validator bug or what
         sphere.vertex_attributes[
             '_CustomUInt16Scalar'] = g.np.random.randint(
                 0, 1000, size=(v_count, 1)).astype(g.np.uint16)
diff --git a/tests/test_identifier.py b/tests/test_identifier.py
index 2adc3b9c..601e29c9 100644
--- a/tests/test_identifier.py
+++ b/tests/test_identifier.py
@@ -17,14 +17,14 @@ class IdentifierTest(g.unittest.TestCase):
                 continue
 
             g.log.info('Trying hash at %d random transforms', count)
-            md5 = g.deque()
+            hash_val = g.deque()
             idf = g.deque()
             for i in range(count):
                 permutated = mesh.permutate.transform()
-                md5.append(permutated.identifier_md5)
+                hash_val.append(permutated.identifier_hash)
                 idf.append(permutated.identifier)
 
-            result = g.np.array(md5)
+            result = g.np.array(hash_val)
             ok = (result[0] == result[1:]).all()
 
             if not ok:
@@ -39,8 +39,8 @@ class IdentifierTest(g.unittest.TestCase):
 
                 raise ValueError('values differ after transform!')
 
-            if md5[-1] == permutated.permutate.noise(
-                    mesh.scale / 100.0).identifier_md5:
+            if hash_val[-1] == permutated.permutate.noise(
+                    mesh.scale / 100.0).identifier_hash:
                 raise ValueError('Hashes on %s didn\'t change after noise!',
                                  mesh.metadata['file_name'])
 
@@ -63,13 +63,13 @@ class IdentifierTest(g.unittest.TestCase):
                     m = s.geometry[geo].copy()
                     m.apply_transform(T)
                     meshes.append(m)
-                if not all(meshes[0].identifier_md5 == i.identifier_md5
+                if not all(meshes[0].identifier_hash == i.identifier_hash
                            for i in meshes):
                     raise ValueError(
                         '{} differs after transform!'.format(geom_name))
 
-        assert (scenes[0].geometry['disc_cam_B'].identifier_md5 !=
-                scenes[0].geometry['disc_cam_A'].identifier_md5)
+        assert (scenes[0].geometry['disc_cam_B'].identifier_hash !=
+                scenes[0].geometry['disc_cam_A'].identifier_hash)
 
 
 if __name__ == '__main__':
diff --git a/tests/test_medial.py b/tests/test_medial.py
index 70bafcfa..7d7af8e7 100644
--- a/tests/test_medial.py
+++ b/tests/test_medial.py
@@ -12,10 +12,8 @@ class MedialTests(g.unittest.TestCase):
 
         assert len(p.polygons_full) == 1
         poly = p.polygons_full[0]
-        medial = p.medial_axis()
-        points = medial.vertices.view(g.np.ndarray)
-        assert all(poly.contains(g.Point(v))
-                   for v in points)
+        med = p.medial_axis()
+        assert all(poly.contains(g.Point(v)) for v in med.vertices)
 
         # circles are a special case for medial axis
         poly = g.Point([0, 0]).buffer(1.0)
@@ -23,8 +21,7 @@ class MedialTests(g.unittest.TestCase):
         med = g.trimesh.path.Path2D(
             **g.trimesh.path.exchange.misc.edges_to_path(
                 *g.trimesh.path.polygons.medial_axis(poly)))
-        # should have returned a single tiny line
-        # with midpoint at origin
+        # should have returned a single tiny line with midpoint at origin
         assert len(med.vertices) == 2
         assert len(med.entities) == 1
         assert float(med.vertices.mean(axis=0).ptp()) < 1e-8
diff --git a/tests/test_mesh.py b/tests/test_mesh.py
index 2fc67e16..11a0d981 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -26,7 +26,7 @@ class MeshTests(g.unittest.TestCase):
                 continue
 
             g.log.info('Testing %s', file_name)
-            start = {mesh.md5(), mesh.crc()}
+            start = {mesh.hash(), mesh.crc()}
             assert len(mesh.faces) > 0
             assert len(mesh.vertices) > 0
 
@@ -58,13 +58,13 @@ class MeshTests(g.unittest.TestCase):
             assert mesh.bounding_primitive.volume > 0.0
 
             # none of these should have mutated anything
-            assert start == {mesh.md5(), mesh.crc()}
+            assert start == {mesh.hash(), mesh.crc()}
 
             # run processing, again
             mesh.process()
 
             # still shouldn't have changed anything
-            assert start == {mesh.md5(), mesh.crc()}
+            assert start == {mesh.hash(), mesh.crc()}
 
             if not (mesh.is_watertight and
                     mesh.is_winding_consistent):
@@ -141,7 +141,7 @@ class MeshTests(g.unittest.TestCase):
                                      file_name, name)
 
             # ...still shouldn't have changed anything
-            assert start == {mesh.md5(), mesh.crc()}
+            assert start == {mesh.hash(), mesh.crc()}
 
             # log the names of properties we need to make read-only
             if len(writeable) > 0:
diff --git a/tests/test_mutate.py b/tests/test_mutate.py
index 159ef26e..747515b0 100644
--- a/tests/test_mutate.py
+++ b/tests/test_mutate.py
@@ -133,7 +133,7 @@ class MutateTests(g.unittest.TestCase):
         mesh.mass_properties
         mesh.scene()
         mesh.identifier
-        mesh.identifier_md5
+        mesh.identifier_hash
         mesh.to_dict()
         mesh.face_angles
         mesh.face_angles_sparse
diff --git a/tests/test_path_creation.py b/tests/test_path_creation.py
index 090b35ee..3389f22b 100644
--- a/tests/test_path_creation.py
+++ b/tests/test_path_creation.py
@@ -26,7 +26,7 @@ class CreationTests(g.unittest.TestCase):
         assert g.np.isclose(circle.area, g.np.pi, rtol=0.01)
         # should be centered at 0
         assert g.np.allclose(
-            circle.polygons_full[0].centroid.coords, [
+            circle.polygons_full[0].centroid, [
                 1.0, 1.0], atol=1e-3)
         assert len(circle.entities) == 1
         assert len(circle.polygons_closed) == 1
@@ -58,12 +58,13 @@ class CreationTests(g.unittest.TestCase):
 
     def test_grid(self):
         grid = g.trimesh.path.creation.grid(side=5.0)
+        print(grid.extents)
         assert g.np.allclose(grid.extents, [10, 10, 0])
+
         # check grid along a plane
-        grid = g.trimesh.path.creation.grid(
-            side=10.0,
-            plane_origin=[5.0, 0, 0],
-            plane_normal=[1, 0, 0])
+        grid = g.trimesh.path.creation.grid(side=10.0,
+                                            plane_origin=[5.0, 0, 0],
+                                            plane_normal=[1, 0, 0])
         # make sure plane is applied correctly
         assert g.np.allclose(grid.extents, [0, 20, 20])
         assert g.np.allclose(grid.bounds, [[5, -10, -10], [5, 10, 10]])
diff --git a/tests/test_paths.py b/tests/test_paths.py
index fbe4f950..726daf3d 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -8,8 +8,8 @@ class VectorTests(g.unittest.TestCase):
 
     def test_discrete(self):
         for d in g.get_2D():
-            # store md5 before requesting passive functions
-            md5 = d.md5()
+            # store hash before requesting passive functions
+            hash_val = d.hash()
 
             # make sure various methods return
             # basically the same bounds
@@ -38,7 +38,7 @@ class VectorTests(g.unittest.TestCase):
             copied = d.copy()
 
             # these operations shouldn't have mutated anything!
-            assert d.md5() == md5
+            assert d.hash() == hash_val
 
             # copy should have saved the metadata
             assert set(copied.metadata.keys()) == set(d.metadata.keys())
diff --git a/tests/test_permutate.py b/tests/test_permutate.py
index 4ab5bc0c..7109b474 100644
--- a/tests/test_permutate.py
+++ b/tests/test_permutate.py
@@ -43,7 +43,7 @@ class PermutateTest(g.unittest.TestCase):
                              mesh.faces)
             assert not close(test.vertices,
                              mesh.vertices)
-            assert not test.md5() == mesh.md5()
+            assert not test.hash() == mesh.hash()
 
             # rigid transforms don't change area or volume
             if rigid:
@@ -80,7 +80,7 @@ class PermutateTest(g.unittest.TestCase):
                 make_assertions(mesh, tessellate, rigid=True)
 
             # make sure permutate didn't alter the original mesh
-            assert original.md5() == mesh.md5()
+            assert original.hash() == mesh.hash()
 
     def test_tesselation(self):
         for mesh in g.get_meshes(5):
diff --git a/tests/test_points.py b/tests/test_points.py
index 3a6204ce..d1d60a6f 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -22,7 +22,7 @@ class PointsTest(g.unittest.TestCase):
         # create a pointcloud object
         cloud = g.trimesh.points.PointCloud(points)
 
-        initial_md5 = cloud.md5()
+        initial_hash = cloud.hash()
 
         assert cloud.convex_hull.volume > 0.0
 
@@ -32,7 +32,7 @@ class PointsTest(g.unittest.TestCase):
         assert cloud.extents.shape == (3,)
         assert cloud.bounds.shape == (2, 3)
 
-        assert cloud.md5() == initial_md5
+        assert cloud.hash() == initial_hash
 
         # set some random colors
         cloud.colors = g.np.random.random((shape[0], 4))
@@ -45,7 +45,7 @@ class PointsTest(g.unittest.TestCase):
         # make sure vertices and colors are new shape
         assert cloud.vertices.shape == new_shape
         assert len(cloud.colors) == new_shape[0]
-        assert cloud.md5() != initial_md5
+        assert cloud.hash() != initial_hash
 
         # AABB volume should be same as points
         assert g.np.isclose(cloud.bounding_box.volume,
diff --git a/tests/test_repair.py b/tests/test_repair.py
index 6af454ed..e5d18e9e 100644
--- a/tests/test_repair.py
+++ b/tests/test_repair.py
@@ -25,7 +25,7 @@ class RepairTests(g.unittest.TestCase):
                 continue
 
             hashes = [{mesh._data.crc(),
-                       mesh._data.md5(),
+                       mesh._data.hash(),
                        mesh._data.fast_hash()}]
 
             mesh.faces = mesh.faces[1:-1]
@@ -37,7 +37,7 @@ class RepairTests(g.unittest.TestCase):
                 mesh, color=[255, 0, 0, 255])
 
             hashes.append({mesh._data.crc(),
-                           mesh._data.md5(),
+                           mesh._data.hash(),
                            mesh._data.fast_hash()})
 
             assert hashes[0] != hashes[1]
@@ -50,7 +50,7 @@ class RepairTests(g.unittest.TestCase):
             assert mesh.is_winding_consistent
 
             hashes.append({mesh._data.crc(),
-                           mesh._data.md5(),
+                           mesh._data.hash(),
                            mesh._data.fast_hash()})
             assert hashes[1] != hashes[2]
 
diff --git a/tests/test_scene.py b/tests/test_scene.py
index cc7434bd..5e6f5ea6 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -20,11 +20,11 @@ class SceneTests(g.unittest.TestCase):
             scene_split.convert_units('in')
             scene_base = g.trimesh.Scene(mesh)
 
-            # save MD5 of scene before concat
-            pre = [scene_split.md5(), scene_base.md5()]
-            # make sure MD5's give the same result twice
-            assert scene_split.md5() == pre[0]
-            assert scene_base.md5() == pre[1]
+            # save hash of scene before concat
+            pre = [scene_split.hash(), scene_base.hash()]
+            # make sure hash's give the same result twice
+            assert scene_split.hash() == pre[0]
+            assert scene_base.hash() == pre[1]
 
             assert isinstance(scene_base.crc(), int)
 
@@ -32,15 +32,15 @@ class SceneTests(g.unittest.TestCase):
             concat = scene_split + scene_base
 
             # make sure concat didn't mess with original scenes
-            assert scene_split.md5() == pre[0]
-            assert scene_base.md5() == pre[1]
+            assert scene_split.hash() == pre[0]
+            assert scene_base.hash() == pre[1]
 
             # make sure concatenate appended things, stuff
             assert len(concat.geometry) == (len(scene_split.geometry) +
                                             len(scene_base.geometry))
 
             for s in [scene_split, scene_base]:
-                pre = s.md5()
+                pre = s.hash()
                 assert len(s.geometry) > 0
                 assert s.is_valid
 
@@ -56,8 +56,8 @@ class SceneTests(g.unittest.TestCase):
                 assert g.trimesh.util.is_shape(s.triangles, (-1, 3, 3))
                 assert len(s.triangles) == len(s.triangles_node)
 
-                assert s.md5() == pre
-                assert s.md5() is not None
+                assert s.hash() == pre
+                assert s.hash() is not None
 
                 # should be some duplicate nodes
                 assert len(s.duplicate_nodes) > 0
@@ -111,7 +111,7 @@ class SceneTests(g.unittest.TestCase):
 
         scene = g.get_mesh('cycloidal.3DXML')
 
-        md5 = scene.md5()
+        hash_val = scene.hash()
         extents = scene.bounding_box_oriented.primitive.extents.copy()
 
         # TODO: have OBB return sorted extents
@@ -132,8 +132,8 @@ class SceneTests(g.unittest.TestCase):
         assert scene.bounding_primitive.volume > 0.0
 
         # we shouldn't have modified the original scene
-        assert scene.md5() == md5
-        assert scaled.md5() != md5
+        assert scene.hash() == hash_val
+        assert scaled.hash() != hash_val
 
         # 3DXML comes in as mm
         assert all(m.units == 'mm'
@@ -159,8 +159,8 @@ class SceneTests(g.unittest.TestCase):
         assert scene.units == 'mm'
 
         # we shouldn't have modified the original scene
-        assert scene.md5() == md5
-        assert converted.md5() != md5
+        assert scene.hash() == hash_val
+        assert converted.hash() != hash_val
 
     def test_add_geometry(self):
         # list-typed geometry should create multiple nodes,
@@ -385,7 +385,7 @@ class SceneTests(g.unittest.TestCase):
         # concatenating the meshes somewhere.
         original = g.trimesh.creation.icosphere(
             radius=0.123312)
-        original_hash = original.identifier_md5
+        original_hash = original.identifier_hash
 
         scene = g.trimesh.Scene()
         scene.add_geometry(original)
@@ -398,7 +398,7 @@ class SceneTests(g.unittest.TestCase):
                 assert g.np.isclose(loaded.volume,
                                     original.volume)
         # nothing should have changed
-        assert original.identifier_md5 == original_hash
+        assert original.identifier_hash == original_hash
 
     def test_append_scenes(self):
         scene_0 = g.trimesh.Scene(base_frame='not_world')
diff --git a/tests/test_section.py b/tests/test_section.py
index 8ed09b64..d604f525 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -272,8 +272,13 @@ class SliceTest(g.unittest.TestCase):
         # check to see if we handle capping with
         # existing coplanar faces correctly
 
-        if not g.has_earcut:
-            return
+        try:
+            from triangle import triangulate  # NOQA
+        except BaseException as E:
+            if g.all_dep:
+                raise E
+            else:
+                return
 
         s = g.get_mesh('cap.zip')
         mesh = next(iter(s.geometry.values()))
@@ -314,8 +319,13 @@ class SliceTest(g.unittest.TestCase):
         # check to see if we handle capping with
         # non-intersecting planes well
 
-        if not g.has_earcut:
-            return
+        try:
+            from triangle import triangulate  # NOQA
+        except BaseException as E:
+            if g.all_dep:
+                raise E
+            else:
+                return
 
         for i in range(100):
             from trimesh.transformations import random_rotation_matrix
@@ -334,8 +344,15 @@ class SliceTest(g.unittest.TestCase):
             assert len(result.faces) > 0
 
     def test_cap(self):
-        if not g.has_earcut:
-            return
+
+        try:
+            from triangle import triangulate  # NOQA
+        except BaseException as E:
+            if g.all_dep:
+                raise E
+            else:
+                return
+
         mesh = g.trimesh.creation.box()
 
         # Cut corner off of box and make sure the bounds and number of faces is correct
diff --git a/tests/test_simplify.py b/tests/test_simplify.py
index 8880b904..923b9ec7 100644
--- a/tests/test_simplify.py
+++ b/tests/test_simplify.py
@@ -11,14 +11,14 @@ class SimplifyTest(g.unittest.TestCase):
         # loading the polygon will make all arcs discrete
         path = g.trimesh.load_path(polygon)
 
-        # save the md5 before doing operations
-        md5_pre = g.deepcopy(path.md5())
+        # save the hash before doing operations
+        hash_pre = g.deepcopy(path.hash())
 
         # this should return a copy of the path
         simplified = path.simplify()
 
         # make sure the simplify call didn't alter our original mesh
-        assert path.md5() == md5_pre
+        assert path.hash() == hash_pre
 
         for garbage in range(2):
             # the simplified version shouldn't have lost area
@@ -43,7 +43,7 @@ class SimplifyTest(g.unittest.TestCase):
             simplified._cache.clear()
 
         # make sure the simplify call didn't alter our original mesh
-        assert path.md5() == md5_pre
+        assert path.hash() == hash_pre
 
     def test_simplify(self):
 
diff --git a/tests/test_svg.py b/tests/test_svg.py
index de444365..99d8b770 100644
--- a/tests/test_svg.py
+++ b/tests/test_svg.py
@@ -12,8 +12,7 @@ class ExportTest(g.unittest.TestCase):
                 continue
             # export and reload the exported SVG
             loaded = g.trimesh.load(
-                g.trimesh.util.wrap_as_stream(
-                    d.export(file_type='svg')),
+                g.trimesh.util.wrap_as_stream(d.export(file_type='svg')),
                 file_type='svg')
 
             # we only have line and arc primitives as SVG
@@ -25,10 +24,11 @@ class ExportTest(g.unittest.TestCase):
                 assert g.np.isclose(d.length,
                                     loaded.length,
                                     rtol=.01)
-                path_str = g.trimesh.path.exchange.svg_io.export_svg(
-                    d, return_path=True)
-                assert isinstance(path_str, str)
-                assert len(path_str) > 0
+
+            path_str = g.trimesh.path.exchange.svg_io.export_svg(
+                d, return_path=True)
+            assert isinstance(path_str, str)
+            assert len(path_str) > 0
 
     def test_layer(self):
         from shapely.geometry import Point
@@ -65,10 +65,9 @@ class ExportTest(g.unittest.TestCase):
         assert len(aX.entities) == 1
 
         # make
-        aR = g.trimesh.load(g.io_wrap(c.export(
-            file_type='dxf',
-            only_layers=['ACIRCLE'])),
-            file_type='dxf')
+        aR = g.trimesh.load(g.io_wrap(c.export(file_type='dxf',
+                                               only_layers=['ACIRCLE'])),
+                            file_type='dxf')
 
         assert g.np.isclose(aR.area, a.area)
 
@@ -150,4 +149,9 @@ class ExportTest(g.unittest.TestCase):
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
-    g.unittest.main()
+
+    import pyinstrument
+    profiler = pyinstrument.Profiler()
+    with profiler:
+        g.unittest.main()
+    print(profiler.output_text())
diff --git a/tests/test_util.py b/tests/test_util.py
index eb7258a2..0637a04a 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -143,16 +143,16 @@ class UtilTests(unittest.TestCase):
         a = g.get_mesh('ballA.off')
         b = g.get_mesh('ballB.off')
 
-        hA = a.md5()
-        hB = b.md5()
+        hA = a.hash()
+        hB = b.hash()
 
         # make sure we're not mutating original mesh
         for i in range(4):
             c = a + b
             assert g.np.isclose(c.volume,
                                 a.volume + b.volume)
-            assert a.md5() == hA
-            assert b.md5() == hB
+            assert a.hash() == hA
+            assert b.hash() == hB
 
         count = 5
         meshes = []
@@ -165,7 +165,7 @@ class UtilTests(unittest.TestCase):
         r = g.trimesh.util.concatenate(meshes)
         assert g.np.isclose(r.volume,
                             a.volume * count)
-        assert a.md5() == hA
+        assert a.hash() == hA
 
     def test_unique_id(self):
         num_ids = 10000