Repository: mikedh/trimesh
Pull request #1842 (2023-02-14 21:58:37)

Ensure that packed UV output order matches original order for multiple materials with duplicates.
This is intended to fix a specific corner case I ran into where the returned array of UVs of a mesh using multiple textures (of which two or more are identical) does not match the order of the original array of UVs after the deduplication of the textures is applied in this pack function.  As a result of this issue, downstream processes end up with a scrambled texture applied.  I am relatively new both to Python and to the Trimesh library, so I apologize if my proposed solution introduces new complications.


Patch:
diff --git a/README.md b/README.md
index 8b446741..5a2fd164 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 [![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)  [![Docker Image Version (latest by date)](https://img.shields.io/docker/v/trimesh/trimesh?label=docker)](https://hub.docker.com/r/trimesh/trimesh/tags)
+[![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)
 
 
 Trimesh is a pure Python (2.7-3.5+) 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).
diff --git a/docs/contributing.md b/docs/contributing.md
index 690d6330..f789eb4e 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -82,10 +82,6 @@ autopep8 --recursive --verbose --in-place --aggressive trimesh
 flake8 trimesh
 ```
 
-## Docstrings
-
-Trimesh uses the [Sphinx Numpy-style](https://www.sphinx-doc.org/en/master/usage/extensions/example_numpy.html#example-numpy) docstrings which get parsed into the API reference page. 
-
 ## 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:
diff --git a/examples/sinter.py b/examples/sinter.py
index a5c57918..c534f8ce 100644
--- a/examples/sinter.py
+++ b/examples/sinter.py
@@ -17,7 +17,7 @@ models = os.path.abspath(os.path.join(
     os.path.expanduser(os.path.dirname(__file__)), '..', 'models'))
 
 
-def collect_meshes(count=None, max_size=20.0):
+def collect_meshes(count=None):
     """
     Collect single body watertight meshes from our
     models folder.
@@ -41,8 +41,7 @@ def collect_meshes(count=None, max_size=20.0):
             pass
         for ori in scene.geometry.values():
             if (not isinstance(ori, trimesh.Trimesh) or
-                    not ori.is_watertight or ori.volume < 0.001 or
-                    ori.extents.max() > max_size):
+                    not ori.is_watertight or ori.volume < 0.001):
                 continue
 
             # split into single body meshes
@@ -59,17 +58,16 @@ def collect_meshes(count=None, max_size=20.0):
 
 if __name__ == '__main__':
 
-    size = 10.0
     # get some sample data
-    meshes = collect_meshes(max_size=size)
+    meshes = collect_meshes(count=100)
 
     print('loaded {} meshes'.format(len(meshes)))
 
     # place the meshes into the volume
     with Profiler() as P:
         placed, transforms, consume = packing.meshes(
-            meshes, size=[size] * 3, spacing=0.1)
-    P.print(show_all=True)
+            meshes, spacing=0.05)
+    P.print()
 
     # none of the placed meshes should have overlapping AABB
     assert not packing.bounds_overlap([i.bounds for i in placed])
@@ -85,3 +83,6 @@ if __name__ == '__main__':
         plane_origin=concat.bounds[0],
         plane_normal=[0, 0, 1],
         heights=np.linspace(0.0, 10.0, 100))
+
+    # show a cross section in the middle
+    sections[50].show()
diff --git a/pyproject.toml b/pyproject.toml
index 27839c49..2c51db23 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,14 +7,7 @@ requires = [
 
 [tool.ruff]
 select = ["E", "F"] # the default rules
-          # "T201",   # disallow print statements
-	  # "B"]      # pass bugbear
-          #"I001",   # isort
-          #"D"]      # pydocstyle
-ignore = ["B905", # `zip()` without an explicit `strict=`
-          "B904"] # `raise ... from err` is silly
-line-length = 90
-
-
-[tool.ruff.pydocstyle]
-convention = "numpy"
\ No newline at end of file
+         # "T201",   # disallow print statements
+	 # "B"]      # pass bugbear
+ignore = ["B905"] # `zip()` without an explicit `strict=`
+line-length = 90
\ No newline at end of file
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 337dc28e..d2c7e0f4 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -18,10 +18,6 @@ except BaseException as E:
     ConvexHull = exceptions.ExceptionWrapper(E)
     optimize = exceptions.ExceptionWrapper(E)
 
-try:
-    from scipy.spatial import QhullError
-except BaseException:
-    QhullError = BaseException
 
 # a 90 degree rotation
 _flip = transformations.planar_matrix(theta=np.pi / 2)
@@ -113,8 +109,7 @@ def oriented_bounds_2D(points, qhull_options='QbB'):
 def oriented_bounds(obj,
                     angle_digits=1,
                     ordered=True,
-                    normal=None,
-                    coplanar_tol=1e-12):
+                    normal=None):
     """
     Find the oriented bounding box for a Trimesh
 
@@ -130,11 +125,7 @@ def oriented_bounds(obj,
     ordered : bool
       Return a consistent order for bounds
     normal : None or (3,) float
-      Override search for normal on 3D meshes.
-    coplanar_tol : float
-      If a convex hull fails and we are checking to see if the
-      points are coplanar this is the maximum deviation from
-      a plane where the points will be considered coplanar.
+      Override search for normal on 3D meshes
 
     Returns
     ----------
@@ -145,79 +136,28 @@ def oriented_bounds(obj,
       The extents of the mesh once transformed with to_origin
     """
 
-    def oriented_bounds_coplanar(points):
-        """
-        Find an oriented bounding box for an array of coplanar 3D points.
-
-        Parameters
-        ----------
-        points : (n, 3) float
-          Points in 3D that occupy a 2D subspace.
-
-        Returns
-        ----------
-        to_origin : (4, 4) float
-          Transformation matrix which will move the center of the
-          bounding box of the input mesh to the origin.
-        extents : (3,) float
-          The extents of the mesh once transformed with to_origin
-        """
-        # Shift points about the origin and rotate into the xy plane
-        points_mean = np.mean(points, axis=0)
-        points_demeaned = points - points_mean
-        _, _, vh = np.linalg.svd(points_demeaned, full_matrices=False)
-        points_2d = np.matmul(points_demeaned, vh.T)
-        if np.any(np.abs(points_2d[:, 2]) > coplanar_tol):
-            raise ValueError('Points must be coplanar')
-
-        # Construct a homogeneous matrix representing the transformation above
-        to_2d = np.eye(4)
-        to_2d[:3, :3] = vh
-        to_2d[:3, 3] = -np.matmul(vh, points_mean)
-
-        # Find the 2D bounding box using the polygon
-        to_origin_2d, extents_2d = oriented_bounds_2D(points_2d[:, :2])
-        # Make extents 3D
-        extents = np.append(extents_2d, 0.0)
-        # convert transformation from 2D to 3D and combine
-        to_origin = np.matmul(
-            transformations.planar_matrix_to_3D(to_origin_2d), to_2d)
-        return to_origin, extents
-
-    try:
-        # extract a set of convex hull vertices and normals from the input
-        # we bother to do this to avoid recomputing the full convex hull if
-        # possible
-        if hasattr(obj, 'convex_hull'):
-            # if we have been passed a mesh, use its existing convex hull to pull from
-            # cache rather than recomputing. This version of the cached convex hull has
-            # normals pointing in arbitrary directions (straight from qhull)
-            # using this avoids having to compute the expensive corrected normals
-            # that mesh.convex_hull uses since normal directions don't matter
-            # here
-            hull = obj.convex_hull
-        elif util.is_sequence(obj):
-            # we've been passed a list of points
-            points = np.asanyarray(obj)
-            if util.is_shape(points, (-1, 2)):
-                return oriented_bounds_2D(points)
-            elif util.is_shape(points, (-1, 3)):
-                hull = convex.convex_hull(points, repair=False)
-            else:
-                raise ValueError('Points are not (n,3) or (n,2)!')
+    # extract a set of convex hull vertices and normals from the input
+    # we bother to do this to avoid recomputing the full convex hull if
+    # possible
+    if hasattr(obj, 'convex_hull'):
+        # if we have been passed a mesh, use its existing convex hull to pull from
+        # cache rather than recomputing. This version of the cached convex hull has
+        # normals pointing in arbitrary directions (straight from qhull)
+        # using this avoids having to compute the expensive corrected normals
+        # that mesh.convex_hull uses since normal directions don't matter here
+        hull = obj.convex_hull
+    elif util.is_sequence(obj):
+        # we've been passed a list of points
+        points = np.asanyarray(obj)
+        if util.is_shape(points, (-1, 2)):
+            return oriented_bounds_2D(points)
+        elif util.is_shape(points, (-1, 3)):
+            hull = convex.convex_hull(points, repair=False)
         else:
-            raise ValueError(
-                'Oriented bounds must be passed a mesh or a set of points!')
-    except QhullError:
-        # Try to recover from Qhull error if due to mesh being less than 3
-        # dimensional
-        if hasattr(obj, 'vertices'):
-            points = obj.vertices.view(np.ndarray)
-        elif util.is_sequence(obj):
-            points = np.asanyarray(obj)
-        else:
-            raise
-        return oriented_bounds_coplanar(points)
+            raise ValueError('Points are not (n,3) or (n,2)!')
+    else:
+        raise ValueError(
+            'Oriented bounds must be passed a mesh or a set of points!')
 
     vertices = hull.vertices
     hull_adj = hull.face_adjacency.T
diff --git a/trimesh/comparison.py b/trimesh/comparison.py
index 68fa1dc6..7cbbce5a 100644
--- a/trimesh/comparison.py
+++ b/trimesh/comparison.py
@@ -15,7 +15,7 @@ from .constants import tol
 # how many significant figures to use for each
 # field of the identifier based on hand-tuning
 id_sigfig = np.array(
-    [5,   # area
+    [4,  # area
      10,  # euler number
      5,  # area/volume ratio
      2,  # convex/mesh area ratio
@@ -70,6 +70,31 @@ def identifier_simple(mesh):
             # 1.0 for cubes, different values for other things
             identifier[2] = (((mesh_area / 6.0) ** (1.0 / 2.0)) /
                              (mesh.volume ** (1.0 / 3.0)))
+            # save vertices for radius calculation
+            vertices = mesh.vertices - mesh.center_mass
+            # we are going to special case radially symmetric meshes
+            # to replace their surface area with ratio of their
+            # surface area to a primitive sphere or cylinder surface area
+            # this is because tessellated curved surfaces are really rough
+            # to reliably hash as they are very sensitive to floating point
+            # and tessellation error. By making area proportionate to a fit
+            # primitive area we are able to reliably hash at more sigfigs
+            if mesh.symmetry == 'radial':
+                # cylinder height
+                h = np.dot(vertices, mesh.symmetry_axis).ptp()
+                # section radius summed per row then overall max
+                R2V = np.dot((np.dot(vertices, mesh.symmetry_section.T)
+                              ** 2), [1, 1])
+                R2 = R2V[R2V > R2V.max() * 0.999].mean()
+                # replace area in this case with cylinder area
+                identifier[0] = (2 * np.pi * np.sqrt(R2)
+                                 * h) + (2 * np.pi * R2)
+            elif mesh.symmetry == 'spherical':
+                # handle a spherically symmetric mesh
+                # section radius summed per row then overall max
+                R2V = np.dot((vertices ** 2), [1, 1, 1])
+                R2 = R2V[R2V > R2V.max() * 0.999].mean()
+                identifier[0] = 4 * np.pi * R2
         else:
             # if we don't have a watertight mesh add information about the
             # convex hull which is slow to compute and unreliable
@@ -99,9 +124,9 @@ def identifier_simple(mesh):
     # lengths to differentiate identical but mirrored meshes
     # this doesn't work well on meshes with a small number of faces
     if len(mesh.faces) > 50:
-        # does this mesh have edges that differ substantially in length
+        # does this mesh have edges that differ substantially in length?
         # if not this method for detecting reflection will not work
-        # and the result will definitely be garbage
+        # and the result will definitly be garbage
         edges_length = mesh.edges_unique_length
         variance = edges_length.std() / edges_length.mean()
         if variance > 0.25:
diff --git a/trimesh/convex.py b/trimesh/convex.py
index 824f3fce..164da3e6 100644
--- a/trimesh/convex.py
+++ b/trimesh/convex.py
@@ -23,11 +23,6 @@ except ImportError as E:
     from .exceptions import ExceptionWrapper
     ConvexHull = ExceptionWrapper(E)
 
-try:
-    from scipy.spatial import QhullError
-except BaseException:
-    QhullError = BaseException
-
 
 def convex_hull(obj, qhull_options='QbB Pp Qt', repair=True):
     """
@@ -60,14 +55,7 @@ def convex_hull(obj, qhull_options='QbB Pp Qt', repair=True):
         if not util.is_shape(points, (-1, 3)):
             raise ValueError('Object must be Trimesh or (n,3) points!')
 
-    try:
-        hull = ConvexHull(points, qhull_options=qhull_options)
-    except QhullError:
-        util.log.debug(
-            'Failed to compute convex hull: retrying with `Qj`',
-            exc_info=True)
-        # try with "joggle" enabled
-        hull = ConvexHull(points, qhull_options='Qj')
+    hull = ConvexHull(points, qhull_options=qhull_options)
 
     # hull object doesn't remove unreferenced vertices
     # create a mask to re- index faces for only referenced vertices
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index 3831c9e4..eaf995ce 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -7,12 +7,8 @@ import numpy as np
 from .. import util
 from .. import visual
 
-from ..util import unique_name
 from ..constants import log
 
-_EYE = np.eye(4)
-_EYE.flags.writeable = False
-
 
 def load_collada(file_obj,
                  resolver=None,
@@ -64,16 +60,13 @@ def load_collada(file_obj,
 
     # name : kwargs
     meshes = {}
-    # increments to enable `unique_name` to avoid n^2 behavior
-    meshes_count = {}
     # list of dict
     graph = []
     for node in c.scene.nodes:
         _parse_node(node=node,
-                    parent_matrix=_EYE,
+                    parent_matrix=np.eye(4),
                     material_map=material_map,
                     meshes=meshes,
-                    meshes_count=meshes_count,
                     graph=graph,
                     resolver=resolver)
 
@@ -171,7 +164,6 @@ def _parse_node(node,
                 parent_matrix,
                 material_map,
                 meshes,
-                meshes_count,
                 graph,
                 resolver=None):
     """
@@ -194,7 +186,7 @@ def _parse_node(node,
                 local_material_map[symbol] = _parse_material(m, resolver)
 
         # Iterate over primitives of geometry
-        for primitive in geometry.primitives:
+        for i, primitive in enumerate(geometry.primitives):
             if isinstance(primitive, collada.polylist.Polylist):
                 primitive = primitive.triangleset()
             if isinstance(primitive, collada.triangleset.TriangleSet):
@@ -239,19 +231,17 @@ def _parse_node(node,
                     vis = visual.texture.TextureVisuals(
                         uv=uv, material=material)
 
-                geom_name = unique_name(geometry.id,
-                                        contains=meshes,
-                                        counts=meshes_count)
-                meshes[geom_name] = {
+                primid = u'{}.{}'.format(geometry.id, i)
+                meshes[primid] = {
                     'vertices': vertices,
                     'faces': faces,
                     'vertex_normals': normals,
                     'vertex_colors': colors,
                     'visual': vis}
 
-                graph.append({'frame_to': geom_name,
+                graph.append({'frame_to': primid,
                               'matrix': parent_matrix,
-                              'geometry': geom_name})
+                              'geometry': primid})
 
     # recurse down tree for nodes with children
     elif isinstance(node, collada.scene.Node):
@@ -265,7 +255,6 @@ def _parse_node(node,
                     parent_matrix=matrix,
                     material_map=material_map,
                     meshes=meshes,
-                    meshes_count=meshes_count,
                     graph=graph,
                     resolver=resolver)
 
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index eaa8cca9..f46b9a7f 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -635,10 +635,6 @@ def _create_gltf_structure(scene,
             # fail here if data isn't json compatible
             # only export the extras if there is something there
             tree['scenes'][0]['extras'] = _jsonify(scene.metadata)
-            extensions = tree['scenes'][0]['extras'].pop(
-                'gltf_extensions', None)
-            if isinstance(extensions, dict):
-                tree['scenes'][0]['extensions'] = extensions
         except BaseException:
             log.debug(
                 'failed to export scene metadata!', exc_info=True)
@@ -689,22 +685,6 @@ def _create_gltf_structure(scene,
         scene=scene, mesh_index=mesh_index)
     tree.update(nodes)
 
-    extensions_used = set()
-    # Add any scene extensions used
-    if 'extensions' in tree['scenes'][0]:
-        extensions_used = extensions_used.union(
-            set(tree['scenes'][0]['extensions'].keys()))
-    # Add any mesh extensions used
-    for mesh in tree['meshes']:
-        if 'extensions' in mesh:
-            extensions_used = extensions_used.union(
-                set(mesh['extensions'].keys()))
-    # Add any extensions already in the tree (e.g. node extensions)
-    if 'extensionsUsed' in tree:
-        extensions_used = extensions_used.union(set(tree['extensionsUsed']))
-    if len(extensions_used) > 0:
-        tree['extensionsUsed'] = list(extensions_used)
-
     if buffer_postprocessor is not None:
         buffer_postprocessor(buffer_items, tree)
 
@@ -789,11 +769,6 @@ def _append_mesh(mesh,
         # skip jsonify any metadata, skipping internal keys
         current['extras'] = _jsonify(mesh.metadata)
 
-        # extract extensions if any
-        extensions = current['extras'].pop('gltf_extensions', None)
-        if isinstance(extensions, dict):
-            current['extensions'] = extensions
-
         if mesh.units not in [None, 'm', 'meters', 'meter']:
             current["extras"]["units"] = str(mesh.units)
     except BaseException:
@@ -1321,6 +1296,7 @@ def _read_buffers(header,
                 start = 0
             end = start + view["byteLength"]
             views[i] = buffers[view["buffer"]][start:end]
+
             assert len(views[i]) == view["byteLength"]
 
         # load data from buffers into numpy arrays
@@ -1332,7 +1308,6 @@ def _read_buffers(header,
             # what is the datatype
             dtype = np.dtype(_dtypes[a["componentType"]])
             # basically how many columns
-            # for types like (4, 4)
             per_item = _shapes[a["type"]]
             # use reported count to generate shape
             shape = np.append(count, per_item)
@@ -1361,7 +1336,7 @@ def _read_buffers(header,
                     # the total block we're looking at
                     length = count * stride
                     # we want to get the bytes for every row
-                    per_row = per_count * dtype.itemsize
+                    per_row = per_item * dtype.itemsize
                     # we have to offset the (already offset) buffer
                     # and then pull chunks per-stride
                     # do as a list comprehension as the numpy
@@ -1390,10 +1365,7 @@ def _read_buffers(header,
     # load data from accessors into Trimesh objects
     meshes = collections.OrderedDict()
 
-    # keep track of how many times each name has been attempted to
-    # be inserted to avoid a potentially slow search through our
-    # dict of names
-    name_counts = {}
+    names_original = collections.defaultdict(list)
 
     for index, m in enumerate(header.get("meshes", [])):
 
@@ -1403,9 +1375,6 @@ def _read_buffers(header,
             # try to load all mesh metadata
             if isinstance(m.get('extras'), dict):
                 metadata.update(m['extras'])
-            # put any mesh extensions in a field of the metadata
-            if 'extensions' in m:
-                metadata['gltf_extensions'] = m['extensions']
 
             for p in m["primitives"]:
                 # if we don't have a triangular mesh continue
@@ -1420,8 +1389,9 @@ def _read_buffers(header,
                 attr = p['attributes']
                 # create a unique mesh name per- primitive
                 name = m.get('name', 'GLTF')
+                names_original[index].append(name)
                 # make name unique across multiple meshes
-                name = unique_name(name, meshes, counts=name_counts)
+                name = unique_name(name, meshes)
 
                 if mode == _GL_LINES:
                     # load GL_LINES into a Path object
@@ -1493,6 +1463,7 @@ def _read_buffers(header,
                     # each primitive gets it's own Trimesh object
                     if len(m["primitives"]) > 1:
                         kwargs['metadata']['from_gltf_primitive'] = True
+                        name = unique_name(name, meshes)
                     else:
                         kwargs['metadata']['from_gltf_primitive'] = False
 
@@ -1574,13 +1545,9 @@ def _read_buffers(header,
     # to a dict comprehension as it will be checking
     # the mutated dict in every loop
     name_index = {}
-    name_counts = {}
     for i, n in enumerate(nodes):
         name_index[unique_name(
-            n.get('name', str(i)),
-            name_index,
-            counts=name_counts
-        )] = i
+            n.get('name', str(i)), name_index)] = i
     # invert the dict so we can look up by index
     # node index (int) : name (str)
     names = {v: k for k, v in name_index.items()}
@@ -1666,16 +1633,9 @@ def _read_buffers(header,
                 kwargs["matrix"],
                 np.diag(np.concatenate((child['scale'], [1.0]))))
 
-        # treat node metadata similarly to mesh metadata
-        if isinstance(child.get("extras"), dict):
+        if "extras" in child:
             kwargs["metadata"] = child["extras"]
 
-        # put any node extensions in a field of the metadata
-        if "extensions" in child:
-            if "metadata" not in kwargs:
-                kwargs["metadata"] = {}
-            kwargs["metadata"]["gltf_extensions"] = child["extensions"]
-
         if "mesh" in child:
             geometries = mesh_prim[child["mesh"]]
 
@@ -1718,15 +1678,6 @@ def _read_buffers(header,
             header['scene']]['extras']
     except BaseException:
         pass
-    try:
-        # load any scene extensions into a field of scene.metadata
-        # use a try except to avoid nested key checks
-        if "metadata" not in result:
-            result["metadata"] = {}
-        result['metadata']['gltf_extensions'] = header['scenes'][
-            header['scene']]['extensions']
-    except BaseException:
-        pass
 
     return result
 
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index b4e522d5..403d16b3 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -131,24 +131,24 @@ def load_obj(file_obj,
         # maxsplit=1 means that it can stop working
         # after it finds the first newline
         # passed as arg as it's not a kwarg in python2
-        face_lines = [i.split('\n', 1)[0].strip()
+        face_lines = [i.split('\n', 1)[0]
                       for i in chunk.split('f ')[1:]]
 
-        # check every face for mixed tri-quad-ngon
-        columns = len(face_lines[0].replace('/', ' ').split())
-        flat_array = all(columns == len(f.replace('/', ' ').split())
-                         for f in face_lines)
+        # then we are going to replace all slashes with spaces
+        joined = ' '.join(face_lines).replace('/', ' ')
 
-        # make sure we have the right number of values for vectorized
-        if flat_array:
-            # the fastest way to get to a numpy array
-            # processes the whole string at once into a 1D array
-            array = np.fromstring(' '.join(face_lines).replace('/', ' '),
-                                  sep=' ', dtype=np.int64)
-            # also wavefront is 1-indexed (vs 0-indexed) so offset
-            # only applies to positive indices
-            array[array > 0] -= 1
+        # the fastest way to get to a numpy array
+        # processes the whole string at once into a 1D array
+        array = np.fromstring(joined, sep=' ', dtype=np.int64)
+        # also wavefront is 1-indexed (vs 0-indexed) so offset
+        # only applies to positive indices
+        array[array > 0] -= 1
+
+        # get the number of raw 2D columns in a sample line
+        columns = len(face_lines[0].strip().replace('/', ' ').split())
 
+        # make sure we have the right number of values for vectorized
+        if len(array) == (columns * len(face_lines)):
             # everything is a nice 2D array
             faces, faces_tex, faces_norm = _parse_faces_vectorized(
                 array=array,
diff --git a/trimesh/exchange/stl.py b/trimesh/exchange/stl.py
index 813ed7e4..4ff5018a 100644
--- a/trimesh/exchange/stl.py
+++ b/trimesh/exchange/stl.py
@@ -17,7 +17,7 @@ _stl_dtype = np.dtype([('normals', '<f4', (3)),
                        ('attributes', '<u2')])
 # define a numpy datatype for the header of a binary STL file
 _stl_dtype_header = np.dtype([('header', np.void, 80),
-                              ('face_count', '<u4')])
+                              ('face_count', '<i4')])
 
 
 def load_stl(file_obj, **kwargs):
diff --git a/trimesh/inertia.py b/trimesh/inertia.py
index 29ef128d..93f03ee7 100644
--- a/trimesh/inertia.py
+++ b/trimesh/inertia.py
@@ -194,14 +194,12 @@ def radial_symmetry(mesh):
     """
 
     # shortcuts to avoid typing and hitting cache
-    scalar = mesh.principal_inertia_components.copy()
+    scalar = mesh.principal_inertia_components
 
     # exit early if inertia components are all zero
-    if (scalar < 1e-12).any():
+    if scalar.ptp() < 1e-4:
         return None, None, None
 
-    # normalize the PCI so we can compare them
-    scalar = scalar / np.linalg.norm(scalar)
     vector = mesh.principal_inertia_vectors
     # the sorted order of the principal components
     order = scalar.argsort()
@@ -209,9 +207,14 @@ def radial_symmetry(mesh):
     # we are checking if a geometry has radial symmetry
     # if 2 of the PCI are equal, it is a revolved 2D profile
     # if 3 of the PCI (all of them) are equal it is a sphere
+    # thus we take the diff of the sorted PCI, scale it as a ratio
+    # of the largest PCI, and then scale to the tolerance we care about
+    # if tol is 1e-3, that means that 2 components are identical if they
+    # are within .1% of the maximum PCI.
     diff = np.abs(np.diff(scalar[order]))
+    diff /= np.abs(scalar).max()
     # diffs that are within tol of zero
-    diff_zero = diff < 1e-4
+    diff_zero = (diff / 1e-3).astype(int) == 0
 
     if diff_zero.all():
         # this is the case where all 3 PCI are identical
@@ -237,8 +240,8 @@ def radial_symmetry(mesh):
 
         # since two vectors are the same, we know the middle
         # one is one of those two
-        section_index = order[
-            np.array([[0, 1], [1, -1]])[diff_zero]].flatten()
+        section_index = order[np.array([[0, 1],
+                                        [1, -1]])[diff_zero]].flatten()
         section = vector[section_index]
 
         # we know the rotation axis is the sole unique value
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index bcc22689..5709c7ea 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -71,6 +71,7 @@ class RectangleBin:
           Position of insertion in the tree or None
           if the insertion was unsuccessful.
         """
+
         for child in self.child:
             # try inserting into child cells
             attempt = child.insert(size=size, rotate=rotate)
@@ -183,7 +184,7 @@ def rectangles_single(extents, size=None, shuffle=False, rotate=True):
     bounds : (m, 2, dim) float
       Axis aligned resulting bounds in space
     transforms : (m, dim + 1, dim + 1) float
-      Homogeneous transformation including rotation.
+      Homogenous transformation including rotation.
     consume : (n,) bool
       Which of the original rectangles were packed,
       i.e. `consume.sum() == m`
@@ -322,7 +323,7 @@ def paths(paths, **kwargs):
     packed : trimesh.path.Path2D
       All paths packed into a single path object.
     transforms : (m, 3, 3) float
-      Homogeneous transforms to move paths from their
+      Homogenous transforms to move paths from their
       original position to the new one.
     consume : (n,) bool
       Which of the original paths were inserted,
@@ -552,7 +553,7 @@ def meshes(meshes, **kwargs):
     placed : (m,) trimesh.Trimesh
       Meshes moved into the rectangular volume.
     transforms : (m, 4, 4) float
-      Homogeneous transform moving mesh from original
+      Homogenous transform moving mesh from original
       position to being packed in a rectangular volume.
     consume : (n,) bool
       Which of the original meshes were inserted,
@@ -616,7 +617,7 @@ def visualize(extents, bounds):
 def roll_transform(bounds, extents):
     """
     Packing returns rotations with integer "roll" which
-    needs to be converted into a homogeneous rotation matrix.
+    needs to be converted into a homogenous rotation matrix.
 
     Currently supports `dimension=2` and `dimension=3`.
 
@@ -631,7 +632,7 @@ def roll_transform(bounds, extents):
     Returns
     ----------
     transforms : (n, dimension + 1, dimension + 1) float
-      Homogeneous transformation to move cuboid at the origin
+      Homogenous transformation to move cuboid at the origin
       into the position determined by `bounds`.
     """
     if len(bounds) != len(extents):
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index e46140fe..4ec5c9f4 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -254,7 +254,7 @@ def polygon_bounds(polygon, matrix=None):
     polygon : shapely.geometry.Polygon
       Polygon pre-transform
     matrix : (3, 3) float or None.
-      Homogeneous transform moving polygon in space
+      Homogenous transform moving polygon in space
 
     Returns
     ------------
@@ -275,7 +275,7 @@ def polygon_bounds(polygon, matrix=None):
     return bounds
 
 
-def plot(polygon=None, show=True, axes=None, **kwargs):
+def plot(polygon, show=True, axes=None, **kwargs):
     """
     Plot a shapely polygon using matplotlib.
 
@@ -303,7 +303,7 @@ def plot(polygon=None, show=True, axes=None, **kwargs):
         [plot_single(i) for i in polygon.geoms]
     elif hasattr(polygon, '__iter__'):
         [plot_single(i) for i in polygon]
-    elif polygon is not None:
+    else:
         plot_single(polygon)
 
     if show:
@@ -885,91 +885,39 @@ def projected(mesh,
     return polygon
 
 
-def second_moments(polygon, return_centered=False):
+def second_moment(coords):
     """
-    Calculate the second moments of area of a polygon
+    Calculate the second moment of area of a polygon
     from the boundary.
 
     Parameters
     ------------
-    polygon : shapely.geometry.Polygon
+    coords : (n, 2) float or Polygon
       Closed polygon.
-    return_centered : bool
-      Get second moments for a frame with origin at the centroid
-      and perform a principal axis transformation.
 
     Returns
     ----------
     moments : (3,) float
-      The values of `[Ixx, Iyy, Ixy]`
-    principal_moments : (2,) float
-      Principal second moments of inertia: `[Imax, Imin]`
-      Only returned if `centered`.
-    alpha : float
-      Angle by which the polygon needs to be rotated, so the
-      principal axis align with the X and Y axis.
-      Only returned if `centered`.
-    transform : (3, 3) float
-      Transformation matrix which rotates the polygon by alpha.
-      Only returned if `centered`.
+      The values of `[Ix, Iy, Ixy]`
     """
+    if hasattr(coords, 'exterior'):
+        # if we have been passed a shapely.geometry.Polygon
+        exterior = second_moment(np.array(coords.exterior.coords))
+        interiors = np.sum([second_moment(np.array(i.coords))
+                            for i in coords.interiors],
+                           axis=0)
+        return exterior - interiors
 
-    transform = np.eye(3)
-    if return_centered:
-        # calculate centroid and move polygon
-        transform[:2, 2] = - np.array(polygon.centroid.coords)
-        polygon = transform_polygon(polygon, transform)
-
-    # start with the exterior
-    coords = np.array(polygon.exterior.coords)
+    coords = np.asanyarray(coords, dtype=np.float64)
     # shorthand the coordinates
     x1, y1 = np.vstack((coords[-1], coords[:-1])).T
     x2, y2 = coords.T
     # do vectorized operations
     v = x1 * y2 - x2 * y1
-    Ixx = np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
-    Iyy = np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
-    Ixy = np.sum(v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)) / 24.0
-
-    for interior in polygon.interiors:
-        coords = np.array(interior.coords)
-        # shorthand the coordinates
-        x1, y1 = np.vstack((coords[-1], coords[:-1])).T
-        x2, y2 = coords.T
-        # do vectorized operations
-        v = x1 * y2 - x2 * y1
-        Ixx -= np.sum(v * (y1 * y1 + y1 * y2 + y2 * y2)) / 12.0
-        Iyy -= np.sum(v * (x1 * x1 + x1 * x2 + x2 * x2)) / 12.0
-        Ixy -= np.sum(v * (x1 * y2 + 2 * x1 * y1 +
-                      2 * x2 * y2 + x2 * y1)) / 24.0
-
-    moments = [Ixx, Iyy, Ixy]
-
-    if not return_centered:
-        return moments
-
-    # get the principal moments
-    root = np.sqrt(((Iyy - Ixx) / 2.0)**2 + Ixy**2)
-    Imax = (Ixx + Iyy) / 2.0 + root
-    Imin = (Ixx + Iyy) / 2.0 - root
-    principal_moments = [Imax, Imin]
-
-    # do the principal axis transform
-    if np.isclose(Ixy, 0.0, atol=1e-12):
-        alpha = 0
-    elif np.isclose(Ixx, Iyy):
-        # prevent division by 0
-        alpha = 0.25 * np.pi
-    else:
-        alpha = 0.5 * np.arctan(2.0 * Ixy / (Ixx - Iyy))
-
-    # construct transformation matrix
-    cos_alpha = np.cos(alpha)
-    sin_alpha = np.sin(alpha)
-
-    transform[0, 0] = cos_alpha
-    transform[1, 1] = cos_alpha
-    transform[0, 1] = - sin_alpha
-    transform[1, 0] = sin_alpha
-
-    return moments, principal_moments, alpha, transform
+    Ix = v * (y1 * y1 + y1 * y2 + y2 * y2)
+    Iy = v * (x1 * x1 + x1 * x2 + x2 * x2)
+    Ixy = v * (x1 * y2 + 2 * x1 * y1 + 2 * x2 * y2 + x2 * y1)
+    # divide by constants and return
+    return np.array([Ix.sum() / 12.0,
+                     Iy.sum() / 12.0,
+                     Ixy.sum() / 24.0], dtype=np.float64)
diff --git a/trimesh/resolvers.py b/trimesh/resolvers.py
index 09ead18f..3d116dd7 100644
--- a/trimesh/resolvers.py
+++ b/trimesh/resolvers.py
@@ -17,10 +17,10 @@ from . import caching
 # URL parsing for remote resources via WebResolver
 try:
     # Python 3
-    from urllib.parse import urlparse
+    from urllib.parse import urlparse, urljoin
 except ImportError:
     # Python 2
-    from urlparse import urlparse
+    from urlparse import urlparse, urljoin
 
 
 class Resolver(util.ABC):
@@ -328,23 +328,18 @@ class WebResolver(Resolver):
 
         # if the last item in the url path is a filename
         # move up a "directory" for the base path
-        if len(split) == 0:
-            path = ''
-        elif '.' in split[-1]:
+        if len(split) > 0 and '.' in split[-1]:
             # clip off last item
             path = '/'.join(split[:-1])
         else:
             # recombine into string ignoring any double slashes
             path = '/'.join(split)
-        self.base_url = '/'.join(i for i in [
-            parsed.scheme + ':/',
-            parsed.netloc.strip('/'),
-            path.strip('/')] if len(i) > 0) + '/'
 
-        # our string handling should have never inserted double slashes
-        assert '//' not in self.base_url[len(parsed.scheme) + 3:]
-        # we should always have ended with a single slash
-        assert self.base_url.endswith('/')
+        # store the base url
+        self.base_url = '{scheme}://{netloc}/{path}'.format(
+            scheme=parsed.scheme,
+            netloc=parsed.netloc,
+            path=path)
 
     def get(self, name):
         """
@@ -361,17 +356,15 @@ class WebResolver(Resolver):
         # remove leading and trailing whitespace
         name = name.strip()
         # fetch the data from the remote url
-
-        # base url has been carefully formatted
-        url = self.base_url + name
-
-        response = requests.get(url)
+        response = requests.get(urljoin(
+            self.base_url, name))
 
         if response.status_code != 200:
             # try to strip off filesystem crap
             if name.startswith('./'):
                 name = name[2:]
-            response = requests.get(self.base_url + name)
+            response = requests.get(urljoin(
+                self.base_url, name))
 
         if response.status_code == '404':
             raise ValueError(response.content)
@@ -394,7 +387,8 @@ class WebResolver(Resolver):
           With sub-url: `https://example.com/{namespace}`
         """
         # join the base url and the namespace
-        return WebResolver(url=self.base_url + namespace)
+        return WebResolver(url=urljoin(
+            self.base_url, namespace))
 
     def write(self, key, value):
         raise NotImplementedError("can't write to remote")
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 3a52ee8b..2c5b8362 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -107,7 +107,7 @@ class Scene(Geometry3D):
                      geom_name=None,
                      parent_node_name=None,
                      transform=None,
-                     metadata=None):
+                     extras=None):
         """
         Add a geometry to the scene.
 
@@ -123,7 +123,7 @@ class Scene(Geometry3D):
         geom_name: Name of the added geometry.
         parent_node_name: Name of the parent node in the graph.
         transform: Transform that applies to the added node.
-        metadata: Optional metadata for the node.
+        extras: Optional metadata for the node.
 
         Returns
         ----------
@@ -143,13 +143,11 @@ class Scene(Geometry3D):
                 geom_name=geom_name,
                 parent_node_name=parent_node_name,
                 transform=transform,
-                metadata=metadata) for value in geometry]
+                extras=extras) for value in geometry]
         elif isinstance(geometry, dict):
             # if someone passed us a dict of geometry
-            return {k: self.add_geometry(
-                    geometry=v,
-                    geom_name=k,
-                    metadata=metadata) for k, v in geometry.items()}
+            return {k: self.add_geometry(v, geom_name=k, extras=extras)
+                    for k, v in geometry.items()}
 
         elif isinstance(geometry, Scene):
             # concatenate current scene with passed scene
@@ -203,7 +201,7 @@ class Scene(Geometry3D):
                           matrix=transform,
                           geometry=name,
                           geometry_flags={'visible': True},
-                          metadata=metadata)
+                          extras=extras)
 
         return node_name
 
@@ -446,7 +444,7 @@ class Scene(Geometry3D):
 
         Parameters
         transform : (4, 4) float
-          Homogeneous transformation matrix.
+          Homogenous transformation matrix.
 
         Returns
         -------------
@@ -1113,8 +1111,8 @@ class Scene(Geometry3D):
                             parent_node_name=p,
                             transform=result.graph.transforms.edge_data[(
                                 p, n)].get('matrix', None),
-                            metadata=result.graph.transforms.edge_data[(
-                                p, n)].get('metadata', None))
+                            extras=result.graph.transforms.edge_data[(
+                                p, n)].get('extras', None))
                     result.delete_geometry(geom_name)
 
             # Convert all 2D paths to 3D paths
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 28eac393..1a5c9f5c 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -5,10 +5,8 @@ from copy import deepcopy
 
 from .. import util
 from .. import caching
+from .. import transformations
 
-from ..transformations import (rotation_matrix,
-                               quaternion_matrix,
-                               fix_rigid)
 from ..caching import hash_fast
 
 # we compare to identity a lot
@@ -25,7 +23,7 @@ class SceneGraph(object):
     nodes.
     """
 
-    def __init__(self, base_frame='world', repair_rigid=1e-5):
+    def __init__(self, base_frame='world'):
         """
         Create a scene graph, holding homogeneous transformation
         matrices and instance information about geometry.
@@ -34,20 +32,11 @@ class SceneGraph(object):
         -----------
         base_frame : any
           The root node transforms will be positioned from.
-        repair_rigid : None or float
-          If a float will attempt to repair rotation matrices
-          where `M @ M.T` differs from an identity matrix by
-          more than floating point zero but less than this value.
-          This can happen in a deep tree with a lot of matrix
-          multiplies.
         """
         # a graph structure, subclass of networkx DiGraph
         self.transforms = EnforcedForest()
         # hashable, the base or root frame
         self.base_frame = base_frame
-        # if passed as a float try to repair rigid transforms
-        # that have accumulated floating point error
-        self.repair_rigid = repair_rigid
         # cache transformation matrices keyed with tuples
         self._cache = caching.Cache(self.__hash__)
 
@@ -74,7 +63,7 @@ class SceneGraph(object):
           Distance to translate
         geometry : hashable
           Geometry object name, e.g. 'mesh_0'
-        metadata: dictionary
+        extras: dictionary
           Optional metadata attached to the new frame
           (exports to glTF node 'extras').
         """
@@ -84,7 +73,7 @@ class SceneGraph(object):
 
         # pass through
         attr = {k: v for k, v in kwargs.items()
-                if k in {'geometry', 'metadata'}}
+                if k in {'geometry', 'extras'}}
         # convert various kwargs to a single matrix
         attr['matrix'] = kwargs_to_matrix(**kwargs)
 
@@ -180,14 +169,8 @@ class SceneGraph(object):
             else:
                 # multiply matrices into single transform
                 matrix = util.multi_dot(matrices)
-
-        # if instructed to repair rigid transforms do it here
-        if self.repair_rigid is not None:
-            matrix = fix_rigid(matrix, max_deviance=self.repair_rigid)
-
         # matrix being edited in-place leads to subtle bugs
         matrix.flags['WRITEABLE'] = False
-
         # store the result
         self._cache[key] = (matrix, geometry)
 
@@ -286,8 +269,6 @@ class SceneGraph(object):
         has_camera = scene.has_camera
         children = graph.children
 
-        extensions_used = set()
-
         # then iterate through to collect data
         for info in result:
             # name of the scene node
@@ -309,36 +290,23 @@ class SceneGraph(object):
 
             if node != base_frame:
                 parent = graph.parents[node]
-                node_edge = edge_data[(parent, node)]
 
                 # get the matrix from this edge
-                matrix = node_edge['matrix']
+                matrix = edge_data[(parent, node)]['matrix']
                 # only include if it's not an identify matrix
                 if not util.allclose(matrix, _identity):
                     info['matrix'] = matrix.T.reshape(-1).tolist()
 
                 # if an extra was stored on this edge
-                extras = node_edge.get('metadata')
+                extras = edge_data[(parent, node)].get('extras')
                 if extras:
-                    extras = extras.copy()
-
-                    # if extensionss were stored on this edge
-                    extensions = extras.pop('gltf_extensions', None)
-                    if isinstance(extensions, dict):
-                        info['extensions'] = extensions
-                        extensions_used = extensions_used.union(
-                            set(extensions.keys()))
-
                     # convert any numpy arrays to lists
                     extras.update(
                         {k: v.tolist() for k, v in extras.items()
                          if hasattr(v, 'tolist')})
                     info['extras'] = extras
 
-        gltf = {'nodes': result}
-        if len(extensions_used) > 0:
-            gltf['extensionsUsed'] = list(extensions_used)
-        return gltf
+        return {'nodes': result}
 
     def to_edgelist(self):
         """
@@ -754,17 +722,14 @@ class EnforcedForest(object):
         children : dict
           Keyed {node : [child, child, ...]}
         """
-        if 'children' in self._cache:
-            return self._cache['children']
         child = collections.defaultdict(list)
         # append children to parent references
         # skip self-references to avoid a node loop
         [child[v].append(u) for u, v in
          self.parents.items() if u != v]
 
-        # cache and return as a vanilla dict
-        self._cache['children'] = dict(child)
-        return self._cache['children']
+        # return as a vanilla dict
+        return dict(child)
 
     def successors(self, node):
         """
@@ -806,19 +771,15 @@ class EnforcedForest(object):
 
     def __hash__(self):
         """
-        Actually hash all of the data, but use a "dirty" mechanism
-        in functions that modify the data, which MUST
-        # all invalidate the hash by setting `self._hash = None`
+        Actually hash all of the data.
+
+        Previously we were relying on "dirty" flags but
+        that made the bookkeeping unreasonably critical.
 
         This was optimized a bit, and is evaluating on an
         older laptop on a scene with 77 nodes and 76 edges
         10,000 times in 0.7s which seems fast enough.
         """
-        # see if there is an available hash value
-        # if you are seeing cache bugs this is the thing
-        # to try eliminating because it is very likely that
-        # someone somewhere is modifying the data without
-        # setting `self._hash = None`
         hashed = getattr(self, '_hash', None)
         if hashed is not None:
             return hashed
@@ -855,9 +816,9 @@ def kwargs_to_matrix(
         # a matrix takes immediate precedence over other options
         return np.array(matrix, dtype=np.float64)
     elif quaternion is not None:
-        matrix = quaternion_matrix(quaternion)
+        matrix = transformations.quaternion_matrix(quaternion)
     elif axis is not None and angle is not None:
-        matrix = rotation_matrix(angle, axis)
+        matrix = transformations.rotation_matrix(angle, axis)
     else:
         matrix = np.eye(4)
 
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 2a905ff4..11c19268 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -2113,17 +2113,17 @@ def transform_points(points,
 
     Parameters
     ----------
-    points : (n, dim) float
-      Points where `dim` is 2 or 3.
+    points : (n, D) float
+      Points where D is 2 or 3
     matrix : (3, 3) or (4, 4) float
-      Homogeneous rotation matrix.
+      Homogeneous rotation matrix
     translate : bool
-      Apply translation from matrix or not.
+      Apply translation from matrix or not
 
     Returns
     ----------
-    transformed : (n, dim) float
-      Transformed points.
+    transformed : (n, d) float
+      Transformed points
     """
     points = np.asanyarray(points, dtype=np.float64)
     if len(points) == 0:
@@ -2134,55 +2134,16 @@ def transform_points(points,
     # shorthand the shape
     count, dim = points.shape
 
-    # quickly check to see if we've been passed an identity matrix
+    # check to see if we've been passed an identity matrix
     if np.abs(matrix - _IDENTITY[:dim + 1, :dim + 1]).max() < 1e-8:
         return np.ascontiguousarray(points.copy())
 
     if translate:
-        # apply translation and rotation
         stack = np.column_stack((points, np.ones(count)))
-        return np.dot(matrix, stack.T).T[:, :dim]
-
-    # only apply the rotation
-    return np.dot(matrix[:dim, :dim], points.T).T
-
-
-def fix_rigid(matrix, max_deviance=1e-5):
-    """
-    If a homogenous transformation matrix is *almost* a rigid
-    transform but many matrix-multiplies have accumulated some
-    floating point error try to restore the matrix using SVD.
-
-    Parameters
-    -----------
-    matrix : (4, 4) or (3, 3) float
-      Homogenous transformation matrix.
-    max_deviance : float
-      Do not alter the matrix if it is not rigid by more
-      than this amount.
-
-    Returns
-    ----------
-    repaired : (4, 4) or (3, 3) float
-      Repaired homogenous transformation matrix
-    """
-    dim = matrix.shape[0] - 1
-    check = np.abs(np.dot(matrix[:dim, :dim], matrix[:dim, :dim].T)
-                   - _IDENTITY[:dim, :dim]).max()
-    # if the matrix differs by more than float-zero and less
-    # than the threshold try to repair the matrix with SVD
-    if check > 1e-13 and check < max_deviance:
-        # reconstruct the rotation from the SVD
-        U, _, V = np.linalg.svd(matrix[:dim, :dim])
-        repaired = np.eye(dim + 1)
-        repaired[:dim, :dim] = np.dot(U, V)
-        # copy in the translation
-        repaired[:dim, dim] = matrix[:dim, dim]
-        # should be within tolerance of the original matrix
-        assert np.allclose(repaired, matrix, atol=max_deviance)
-        return repaired
+    else:
+        stack = np.column_stack((points, np.zeros(count)))
 
-    return matrix
+    return np.dot(matrix, stack.T).T[:, :dim]
 
 
 def is_rigid(matrix, epsilon=1e-8):
@@ -2213,7 +2174,7 @@ def is_rigid(matrix, epsilon=1e-8):
 
     # check dot product of rotation against transpose
     check = np.dot(matrix[:3, :3],
-                   matrix[:3, :3].T) - _IDENTITY[:3, :3]
+                   matrix[:3, :3].T) - np.eye(3)
 
     return check.ptp() < epsilon
 
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index 18cebcc9..9559d3a6 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -52,10 +52,10 @@ def area(triangles=None, crosses=None, sum=False):
     """
     if crosses is None:
         crosses = cross(triangles)
-    areas = np.sqrt((crosses ** 2).sum(axis=1)) / 2.0
+    area = (np.sum(crosses**2, axis=1)**.5) * .5
     if sum:
-        return areas.sum()
-    return areas
+        return np.sum(area)
+    return area
 
 
 def normals(triangles=None, crosses=None):
diff --git a/trimesh/util.py b/trimesh/util.py
index 76108058..302f8032 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -2360,7 +2360,7 @@ def to_ascii(text):
     return str(text)
 
 
-def is_ccw(points, return_all=False):
+def is_ccw(points):
     """
     Check if connected 2D points are counterclockwise.
 
@@ -2368,41 +2368,28 @@ def is_ccw(points, return_all=False):
     -----------
     points : (n, 2) float
       Connected points on a plane
-    return_all : bool
-      Return polygon area and centroid or just counter-clockwise.
 
     Returns
     ----------
     ccw : bool
       True if points are counter-clockwise
-    area : float
-      Only returned if `return_centroid`
-    centroid : (2,) float
-      Centroid of the polygon.
     """
-    points = np.array(points, dtype=np.float64)
-
-    if len(points.shape) != 2 or points.shape[1] != 2:
-        raise ValueError('only defined for `(n, 2)` points')
-
-    # the "shoelace formula"
-    product = np.subtract(*(points[:-1, [1, 0]] * points[1:]).T)
-    # the area of the polygon
-    area = product.sum() / 2.0
-    # check the sign of the area
-    ccw = area < 0.0
-
-    if not return_all:
-        return ccw
+    points = np.asanyarray(points, dtype=np.float64)
 
-    # the centroid of the polygon uses the same formula
-    centroid = ((points[:-1] + points[1:]) *
-                product.reshape((-1, 1))).sum(axis=0) / (6.0 * area)
+    if (len(points.shape) != 2 or points.shape[1] != 2):
+        raise ValueError('CCW is only defined for 2D')
+    xd = np.diff(points[:, 0])
+    # sum along axis=1 with a dot product
+    yd = np.dot(np.column_stack((
+        points[:, 1],
+        points[:, 1])).reshape(-1)[1:-1].reshape((-1, 2)), [1, 1])
+    area = np.sum(xd * yd) * .5
+    ccw = area < 0
 
-    return ccw, area, centroid
+    return ccw
 
 
-def unique_name(start, contains, counts=None):
+def unique_name(start, contains):
     """
     Deterministically generate a unique name not
     contained in a dict, set or other grouping with
@@ -2415,13 +2402,6 @@ def unique_name(start, contains, counts=None):
       Initial guess for name.
     contains : dict, set, or list
       Bundle of existing names we can *not* use.
-    counts : None or dict
-      Maps name starts encountered before to increments in
-      order to speed up finding a unique name as otherwise
-      it potentially has to iterate through all of contains.
-      Should map to "how many times has this `start`
-      been attempted, i.e. `counts[start]: int`.
-      Note that this *will be mutated* in-place by this function!
 
     Returns
     ---------
@@ -2434,16 +2414,13 @@ def unique_name(start, contains, counts=None):
             start not in contains):
         return start
 
-    # start checking with zero index unless found
-    if counts is None:
-        increment = 0
-    else:
-        increment = counts.get(start, 0)
+    # start checking with zero index
+    increment = 0
     if start is not None and len(start) > 0:
         formatter = start + '_{}'
         # split by our delimiter once
         split = start.rsplit('_', 1)
-        if len(split) == 2 and increment == 0:
+        if len(split) == 2:
             try:
                 # start incrementing from the existing
                 # trailing value
@@ -2460,8 +2437,6 @@ def unique_name(start, contains, counts=None):
     for i in range(increment + 1, 2 + increment + len(contains)):
         check = formatter.format(i)
         if check not in contains:
-            if counts is not None:
-                counts[start] = i
             return check
 
     # this should really never happen since we looped
diff --git a/trimesh/version.py b/trimesh/version.py
index 576f8cce..88d90c3e 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,4 +1,4 @@
-__version__ = '3.20.2'
+__version__ = '3.20.0'
 
 if __name__ == '__main__':
     # print version if run directly i.e. in a CI script
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index caa5c117..c0787743 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -737,6 +737,6 @@ def pack(materials, uvs, deduplicate=True):
         [new_uv.append(transform_uvs(uvs[i], scale, xy_off)) for i in idxs]
 
     # stack UV coordinates into single (n, 2) array
-    stacked = np.vstack(new_uv)
+    stacked = np.vstack([new_uv[n] for n in np.argsort([x for i in mat_idx for x in i])])
 
     return SimpleMaterial(image=final), stacked
diff --git a/trimesh/voxel/runlength.py b/trimesh/voxel/runlength.py
index b7d085da..fed372ad 100644
--- a/trimesh/voxel/runlength.py
+++ b/trimesh/voxel/runlength.py
@@ -666,7 +666,6 @@ def rle_strip(rle_data):
             break
         else:
             end += count
-
     rle_data = rle_data[i:None if j == 0 else -j].reshape((-1,))
     return rle_data, (start, end)
 


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 6c22760b..76a83aa4 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -26,8 +26,6 @@ import numpy as np
 
 import trimesh
 
-from uuid import uuid4
-
 from trimesh.base import Trimesh
 from trimesh.constants import tol, tol_path
 from collections import deque
@@ -174,48 +172,14 @@ io_wrap = trimesh.util.wrap_as_stream
 
 def random(*args, **kwargs):
     """
-    A random function always seeded from the same value so tests
-    can use random data but they execute mostly deterministically
-    in a large test matrix.
+    A random function always seeded from the same value.
 
-    Drop-in replacement for `np.random.random(*args, **kwargs)`
+    Replaces: np.random.random(*args, **kwargs)
     """
     state = np.random.RandomState(seed=1)
     return state.random_sample(*args, **kwargs)
 
 
-def random_transforms(count, translate=1000):
-    """
-    Deterministic generation of random transforms so unit
-    tests have limited levels of flake.
-
-    Parameters
-    -------------
-    count : int
-      Number of repeatable but random-ish transforms to generate
-    translate : float
-      The scale of translation to apply.
-
-    Yields
-    ------------
-    transform : (4, 4) float
-      Homogenous transformation matrix
-    """
-    quaternion = random((count, 3))
-    translate = (random((count, 3)) - 0.5) * float(translate)
-
-    for quat, trans in zip(quaternion, translate):
-        matrix = tf.random_rotation_matrix(rand=quat)
-        matrix[:3, 3] = trans
-        yield matrix
-
-
-# random should be deterministic
-assert np.allclose(random(10), random(10))
-assert np.allclose(list(random_transforms(10)),
-                   list(random_transforms(10)))
-
-
 def _load_data():
     """
     Load the JSON files from our truth directory.
diff --git a/tests/helpers/id_helper.py b/tests/helpers/id_helper.py
index d0116863..fb5f09a9 100644
--- a/tests/helpers/id_helper.py
+++ b/tests/helpers/id_helper.py
@@ -72,9 +72,8 @@ def permutations(mesh,
         identifiers.append(identifier)
 
         if (time.time() - start) > cutoff:
-            print('bailing for time:{} count:{}'.format(
-                time.time() - start,
-                i))
+            print('bailing for time:{} count:{}'.format(time.time() - start,
+                                                        i))
             return np.array(identifiers)
 
     return np.array(identifiers)
diff --git a/tests/test_align.py b/tests/test_align.py
index 8fa42fb8..b8c266b2 100644
--- a/tests/test_align.py
+++ b/tests/test_align.py
@@ -20,8 +20,8 @@ class AlignTests(g.unittest.TestCase):
         # start with some edge cases and make sure the transform works
         target = g.np.array([0, 0, -1], dtype=g.np.float64)
         vectors = g.np.vstack((
-            g.trimesh.unitize(g.random((1000, 3)) - .5),
-            g.random((1000, 3)) - .5,
+            g.trimesh.unitize(g.np.random.random((1000, 3)) - .5),
+            g.np.random.random((1000, 3)) - .5,
             [-target, target],
             g.trimesh.util.generate_basis(target),
             [[7.12106798e-07, -7.43194705e-08, 1.00000000e+00],
diff --git a/tests/test_arc.py b/tests/test_arc.py
index 5aba967b..9c3df525 100644
--- a/tests/test_arc.py
+++ b/tests/test_arc.py
@@ -34,11 +34,11 @@ class ArcTests(g.unittest.TestCase):
         min_angle = g.np.radians(2)
         count = 1000
 
-        center_3D = (g.random((count, 3)) - .5) * 50
+        center_3D = (g.np.random.random((count, 3)) - .5) * 50
         center_2D = center_3D[:, 0:2]
-        radii = g.np.clip(g.random(count) * 100, min_angle, g.np.inf)
+        radii = g.np.clip(g.np.random.random(count) * 100, min_angle, g.np.inf)
 
-        angles = g.random((count, 2)) * \
+        angles = g.np.random.random((count, 2)) * \
             (g.np.pi - min_angle) + min_angle
         angles = g.np.column_stack((g.np.zeros(count),
                                     g.np.cumsum(angles, axis=1)))
diff --git a/tests/test_base.py b/tests/test_base.py
index 722daaee..5a6a52e3 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -35,7 +35,7 @@ class MeshTests(g.unittest.TestCase):
         assert g.np.isclose(m.volume, pre_vol)
 
         # add some unreferenced vertices
-        m.vertices = g.np.vstack((m.vertices, g.random((100, 3))))
+        m.vertices = g.np.vstack((m.vertices, g.np.random.random((100, 3))))
         assert len(m.vertices) == pre_len + 100
         assert g.np.isclose(m.volume, pre_vol)
 
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 413636c1..9208bfe2 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -69,7 +69,7 @@ class BoundsTest(g.unittest.TestCase):
         """
         for dimension in [3, 2]:
             for i in range(25):
-                points = g.random((10, dimension))
+                points = g.np.random.random((10, dimension))
                 to_origin, extents = g.trimesh.bounds.oriented_bounds(points)
 
                 assert g.trimesh.util.is_shape(to_origin,
@@ -81,46 +81,19 @@ class BoundsTest(g.unittest.TestCase):
                 transformed_bounds = [transformed.min(axis=0),
                                       transformed.max(axis=0)]
 
-                for j in transformed_bounds:
+                for i in transformed_bounds:
                     # assert that the points once our obb to_origin transform is applied
                     # has a bounding box centered on the origin
-                    assert g.np.allclose(g.np.abs(j), extents / 2.0)
+                    assert g.np.allclose(g.np.abs(i), extents / 2.0)
 
                 extents_tf = g.np.diff(
                     transformed_bounds, axis=0).reshape(dimension)
                 assert g.np.allclose(extents_tf, extents)
 
-    def test_obb_coplanar_points(self):
-        """
-        Test OBB functions with coplanar points as input
-        """
-        for i in range(5):
-            points = g.np.zeros((5, 3))
-            points[:, :2] = g.np.random.random((points.shape[0], 2))
-            rot, _ = g.np.linalg.qr(g.np.random.randn(3, 3))
-            points = g.np.matmul(points, rot)
-            to_origin, extents = g.trimesh.bounds.oriented_bounds(points)
-
-            assert g.trimesh.util.is_shape(to_origin, (4, 4))
-            assert g.trimesh.util.is_shape(extents, (3,))
-
-            transformed = g.trimesh.transform_points(points, to_origin)
-
-            transformed_bounds = [transformed.min(axis=0),
-                                  transformed.max(axis=0)]
-
-            for j in transformed_bounds:
-                # assert that the points once our obb to_origin transform is applied
-                # has a bounding box centered on the origin
-                assert g.np.allclose(g.np.abs(j), extents / 2.0)
-
-            extents_tf = g.np.diff(transformed_bounds, axis=0).reshape(3)
-            assert g.np.allclose(extents_tf, extents)
-
     def test_2D(self):
         for theta in g.np.linspace(0, g.np.pi * 2, 2000):
             # create some random rectangular-ish 2D points
-            points = g.random((10, 2)) * [5, 1]
+            points = g.np.random.random((10, 2)) * [5, 1]
 
             # save the basic AABB of the points before rotation
             rectangle_pre = points.ptp(axis=0)
@@ -234,7 +207,7 @@ class BoundsTest(g.unittest.TestCase):
             # transform box randomly in rotation and translation
             mat = g.trimesh.transformations.random_rotation_matrix()
             # translate in box -100 : +100
-            mat[:3, 3] = (g.random(3) - .5) * 200
+            mat[:3, 3] = (g.np.random.random(3) - .5) * 200
 
             # source mesh to check
             b = g.trimesh.creation.box(extents=extents,
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 4119f80f..2d402f36 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -26,7 +26,7 @@ class CacheTest(g.unittest.TestCase):
 
             # generate test data and perform numpy operations
             a = g.trimesh.caching.tracked_array(
-                g.random(TEST_DIM))
+                g.np.random.random(TEST_DIM))
             modified = [hash(a)]
             a[0][0] = 10
             modified.append(hash(a))
@@ -95,7 +95,7 @@ class CacheTest(g.unittest.TestCase):
         g.trimesh.caching.hash_fast = original
 
     def test_contiguous(self):
-        a = g.random((100, 3))
+        a = g.np.random.random((100, 3))
         t = g.trimesh.caching.tracked_array(a)
 
         original = g.trimesh.caching.hash_fast
@@ -120,7 +120,7 @@ class CacheTest(g.unittest.TestCase):
         """
         d = g.trimesh.caching.DataStore()
 
-        d['hi'] = g.random(100)
+        d['hi'] = g.np.random.random(100)
         hash_initial = hash(d)
         # mutate internal data
         d['hi'][0] += 1
diff --git a/tests/test_color.py b/tests/test_color.py
index 51027148..de1dc33a 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -93,30 +93,31 @@ class VisualTest(g.unittest.TestCase):
         m.visual.vertex_colors[1] = test_color_transparent
         assert m.visual.transparency
 
-        test = (g.random((len(m.faces), 4)) * 255).astype(g.np.uint8)
+        test = (g.np.random.random((len(m.faces), 4)) * 255).astype(g.np.uint8)
         m.visual.face_colors = test
         assert bool((m.visual.face_colors == test).all())
         assert m.visual.kind == 'face'
 
-        test = (g.random((len(m.vertices), 4))
+        test = (g.np.random.random((len(m.vertices), 4))
                 * 255).astype(g.np.uint8)
         m.visual.vertex_colors = test
         assert bool((m.visual.vertex_colors == test).all())
         assert m.visual.kind == 'vertex'
 
-        test = (g.random(4) * 255).astype(g.np.uint8)
+        test = (g.np.random.random(4) * 255).astype(g.np.uint8)
         m.visual.face_colors = test
         assert bool((m.visual.vertex_colors == test).all())
         assert m.visual.kind == 'face'
-        m.visual.vertex_colors[0] = [0, 0, 0, 0]
+        m.visual.vertex_colors[0] = (
+            g.np.random.random(4) * 255).astype(g.np.uint8)
         assert m.visual.kind == 'vertex'
 
-        test = (g.random(4) * 255).astype(g.np.uint8)
+        test = (g.np.random.random(4) * 255).astype(g.np.uint8)
         m.visual.vertex_colors = test
         assert bool((m.visual.face_colors == test).all())
         assert m.visual.kind == 'vertex'
-        m.visual.face_colors[:2] = (
-            g.random((2, 4)) * 255).astype(g.np.uint8)
+        m.visual.face_colors[0] = (
+            g.np.random.random(4) * 255).astype(g.np.uint8)
         assert m.visual.kind == 'face'
 
     def test_smooth(self):
diff --git a/tests/test_export.py b/tests/test_export.py
index 386e2a6d..48ab5a35 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -264,7 +264,7 @@ class ExportTest(g.unittest.TestCase):
         RET_COUNT = 5
 
         # a path that doesn't exist
-        nonexists = '/banana{}'.format(g.random())
+        nonexists = '/banana{}'.format(g.np.random.random())
         assert not g.os.path.exists(nonexists)
 
         # loadable OBJ model
diff --git a/tests/test_geom.py b/tests/test_geom.py
index a82d291e..0e4c86d7 100644
--- a/tests/test_geom.py
+++ b/tests/test_geom.py
@@ -12,8 +12,8 @@ class GeomTests(g.unittest.TestCase):
     def test_triangulate(self):
         from trimesh.geometry import triangulate_quads as tq
         # create some triangles and quads
-        tri = (g.random((100, 3)) * 100).astype(g.np.int64)
-        quad = (g.random((100, 4)) * 100).astype(g.np.int64)
+        tri = (g.np.random.random((100, 3)) * 100).astype(g.np.int64)
+        quad = (g.np.random.random((100, 4)) * 100).astype(g.np.int64)
 
         # should just exit early for triangles
         assert g.np.allclose(tri, tq(tri.tolist()))
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 0bcabda4..e018e59f 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -547,12 +547,8 @@ class GLTFTest(g.unittest.TestCase):
         # if GLTF extras are defined, make sure they survive a round trip
         s = g.get_mesh('cycloidal.3DXML')
 
-        scene_extensions = {'mesh_ext': {'ext_data': 1.23}}
         # some dummy data
-        dummy = {
-            'who': 'likes cheese',
-            'potatoes': 25,
-            'gtlf_extensions': scene_extensions}
+        dummy = {'who': 'likes cheese', 'potatoes': 25}
 
         # export as GLB with extras passed to the exporter then re-load
         s.metadata = dummy
@@ -568,16 +564,13 @@ class GLTFTest(g.unittest.TestCase):
 
     def test_extras_nodes(self):
 
-        mesh_extensions = {'mesh_ext': {'ext_data': 1.23}}
         test_metadata = {
             'test_str': 'test_value',
             'test_int': 1,
             'test_float': 0.123456789,
             'test_bool': True,
             'test_array': [1, 2, 3],
-            'test_dict': {'a': 1, 'b': 2},
-            'gltf_extensions': mesh_extensions
-        }
+            'test_dict': {'a': 1, 'b': 2}}
 
         sphere1 = g.trimesh.primitives.Sphere(radius=1.0)
         sphere1.metadata.update(test_metadata)
@@ -592,18 +585,12 @@ class GLTFTest(g.unittest.TestCase):
             sphere1,
             node_name="Sphere1",
             geom_name="Geom Sphere1",
-            transform=tf1,
-            metadata={'field': 'extra_data1'})
-        node_extensions = {'mesh_ext': {'ext_data': 1.23}}
-        sphere2_metadata = {
-            'field': 'extra_data2',
-            'gltf_extensions': node_extensions}
+            transform=tf1)
         s.add_geometry(sphere2,
                        node_name="Sphere2",
                        geom_name="Geom Sphere2",
                        parent_node_name="Sphere1",
-                       transform=tf2,
-                       metadata=sphere2_metadata)
+                       transform=tf2)
 
         # Test extras appear in the exported model nodes
         files = s.export(None, "gltf")
@@ -618,27 +605,9 @@ class GLTFTest(g.unittest.TestCase):
             file_type='glb')
         files = r.export(None, "gltf")
         gltf_data = files["model.gltf"]
-        # Check that the mesh and node metadata/extras survived
         assert 'test_value' in gltf_data.decode('utf8')
-        assert 'extra_data1' in gltf_data.decode('utf8')
-        # Check that the extensions were removed from the metadata;
-        # they should be saved as 'extensions' in the gltf file
-        assert 'gltf_extensions' not in gltf_data.decode('utf8')
-
-        # Check that the node transforms and metadata/extras survived
         edge = r.graph.transforms.edge_data[("world", "Sphere1")]
         assert g.np.allclose(edge['matrix'], tf1)
-        assert edge['metadata']['field'] == 'extra_data1'
-
-        edge = r.graph.transforms.edge_data[("Sphere1", "Sphere2")]
-        assert g.np.allclose(edge['matrix'], tf2)
-        assert edge['metadata']['field'] == 'extra_data2'
-        # Check that the node's extensions survived
-        assert edge['metadata']['gltf_extensions'] == node_extensions
-
-        # Check that the mesh extensions survived
-        for mesh in r.geometry.values():
-            mesh.metadata['gltf_extensions'] == mesh_extensions
 
         # all geometry should be the same
         assert set(r.geometry.keys()) == set(s.geometry.keys())
@@ -726,7 +695,7 @@ class GLTFTest(g.unittest.TestCase):
         # test concatenation with texture
         m = g.get_mesh('fuze.obj')
 
-        colors = (g.random(
+        colors = (g.np.random.random(
             (len(m.vertices), 4)) * 255).astype(g.np.uint8)
 
         # set the color vertex attribute
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
index c23eddcc..2fbb0236 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -195,7 +195,7 @@ class GroupTests(g.unittest.TestCase):
     def test_cluster(self):
         # create some random points stacked with some zeros to cluster
         points = g.np.vstack(((
-            g.random((10000, 3)) * 5).astype(g.np.int64),
+            g.np.random.random((10000, 3)) * 5).astype(g.np.int64),
             g.np.zeros((100, 3))))
         # should be at least one cluster
         assert len(g.trimesh.grouping.clusters(points, .01)) > 0
@@ -304,7 +304,7 @@ class GroupTests(g.unittest.TestCase):
 
     def test_unique_ordered_rows(self):
         # check the ordering of unique_rows
-        v = g.random((100000, 3))
+        v = g.np.random.random((100000, 3))
         v = g.np.vstack((v, v, v, v))
 
         # index, inverse
diff --git a/tests/test_identifier.py b/tests/test_identifier.py
index 030e69b8..3df80a4a 100644
--- a/tests/test_identifier.py
+++ b/tests/test_identifier.py
@@ -7,6 +7,7 @@ except BaseException:
 class IdentifierTest(g.unittest.TestCase):
 
     def test_identifier(self, count=25):
+
         meshes = g.np.append(list(g.get_meshes(
             only_watertight=True, split=True, min_volume=0.001)),
             g.get_mesh('fixed_top.ply'))
@@ -19,8 +20,8 @@ class IdentifierTest(g.unittest.TestCase):
             g.log.info('Trying hash at %d random transforms', count)
             hashed = []
             identifier = []
-            for transform in g.random_transforms(count):
-                permutated = mesh.copy().apply_transform(transform)
+            for _ in range(count):
+                permutated = mesh.permutate.transform()
                 hashed.append(permutated.identifier_hash)
                 identifier.append(permutated.identifier)
 
@@ -28,18 +29,18 @@ class IdentifierTest(g.unittest.TestCase):
             ok = (result[0] == result[1:]).all()
 
             if not ok:
-                ptp = g.np.ptp(identifier, axis=0)
+                debug = [g.trimesh.util.sigfig_int(
+                    i, g.trimesh.comparison.id_sigfig)
+                    for i in identifier]
                 g.log.error('Hashes on %s differ after transform:\n %s\n',
-                            mesh.metadata['file_name'], str(ptp))
+                            mesh.metadata['file_name'],
+                            str(g.np.array(debug, dtype=g.np.int64)))
                 raise ValueError('values differ after transform!')
 
-            # stretch the mesh by a small amount
-            stretched = mesh.copy().apply_scale(
-                [0.99974507, 0.9995662, 1.0029832])
-            if hashed[-1] == stretched.identifier_hash:
-                raise ValueError(
-                    'Hashes on %s didn\'t change after stretching',
-                    mesh.metadata['file_name'])
+            if hashed[-1] == permutated.permutate.noise(
+                    mesh.scale / 100.0).identifier_hash:
+                raise ValueError('Hashes on %s didn\'t change after noise!',
+                                 mesh.metadata['file_name'])
 
     def test_scene_id(self):
         """
@@ -91,40 +92,6 @@ class IdentifierTest(g.unittest.TestCase):
         # hash should differ
         assert a.identifier_hash != b.identifier_hash
 
-    def test_duplicates(self):
-        def clean_name(name):
-            return name.split('_', 1)[0].split('#', 1)[0]
-
-        # a scene with instances
-        s = g.get_mesh('cycloidal.3DXML')
-        # a flat scene dump
-        d = g.trimesh.Scene(s.dump())
-
-        # should have the same number of geometry nodes
-        assert len(s.graph.nodes_geometry) == len(d.graph.nodes_geometry)
-
-        # uses the identifier to calculate
-        a = s.duplicate_nodes
-        b = d.duplicate_nodes
-
-        # should be the same in both forms
-        assert len(a) == len(b)
-
-        a_set = set([tuple(sorted([clean_name(i) for i in group]))
-                     for group in a])
-        b_set = set([tuple(sorted([clean_name(i) for i in group]))
-                     for group in b])
-        assert a_set == b_set
-
-        ptp = []
-        for group in b:
-            current = []
-            for node in group:
-                _, geom = d.graph[node]
-                current.append(d.geometry[geom].identifier)
-            ptp.append(g.np.ptp(current, axis=0))
-            assert (ptp[-1] < 0.01).all()
-
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_inertia.py b/tests/test_inertia.py
index 601b4eaa..64f43b8d 100644
--- a/tests/test_inertia.py
+++ b/tests/test_inertia.py
@@ -112,7 +112,7 @@ class InertiaTest(g.unittest.TestCase):
                     matrix = g.trimesh.transformations.random_rotation_matrix()
                     p.primitive.transform = matrix
                 elif hasattr(p.primitive, 'center'):
-                    p.primitive.center = g.random(3)
+                    p.primitive.center = g.np.random.random(3)
 
     def test_tetrahedron(self):
         # Based on the 'numerical example' of the paper:
diff --git a/tests/test_integralmeancurvature.py b/tests/test_integralmeancurvature.py
index 58937fff..4f98f593 100644
--- a/tests/test_integralmeancurvature.py
+++ b/tests/test_integralmeancurvature.py
@@ -32,7 +32,7 @@ class IntegralMeanCurvatureTest(g.unittest.TestCase):
         # how close do we need to be - relative tolerance
         tol = 1e-3
         n_tests = 4
-        extents = 1 - g.random((n_tests, 3))
+        extents = 1 - g.np.random.random((n_tests, 3))
         for extent in extents:
             m = g.trimesh.creation.box(extents=extent)
             IMC = m.integral_mean_curvature
diff --git a/tests/test_merge.py b/tests/test_merge.py
index 4166bc17..4d361d3d 100644
--- a/tests/test_merge.py
+++ b/tests/test_merge.py
@@ -19,7 +19,7 @@ class MargeTest(g.unittest.TestCase):
 
         # stack a bunch of unreferenced vertices
         m.vertices = g.np.vstack((
-            m.vertices, g.random((10000, 3))))
+            m.vertices, g.np.random.random((10000, 3))))
         assert m.euler_number == 2
         assert m.vertices.shape == (10008, 3)
         assert m.referenced_vertices.sum() == 8
diff --git a/tests/test_mutate.py b/tests/test_mutate.py
index 71d3ff8d..747515b0 100644
--- a/tests/test_mutate.py
+++ b/tests/test_mutate.py
@@ -143,21 +143,21 @@ class MutateTests(g.unittest.TestCase):
 
         # ray.intersects_id
         centre = mesh.vertices.mean(axis=0)
-        origins = g.random((100, 3)) * 1000
+        origins = g.np.random.random((100, 3)) * 1000
         directions = g.np.copy(origins)
         directions[:50, :] -= centre
         directions[50:, :] += centre
         mesh.ray.intersects_id(origins, directions)
 
         # nearest.vertex
-        points = g.random((500, 3)) * 100
+        points = g.np.random.random((500, 3)) * 100
         mesh.nearest.vertex(points)
 
         # section
         section_count = 20
-        origins = g.random((section_count, 3)) * 100
-        normals = g.random((section_count, 3)) * 100
-        heights = g.random((10,)) * 100
+        origins = g.np.random.random((section_count, 3)) * 100
+        normals = g.np.random.random((section_count, 3)) * 100
+        heights = g.np.random.random((10,)) * 100
         for o, n in zip(origins, normals):
             # try slicing at random origin and at center mass
             mesh.slice_plane(o, n)
diff --git a/tests/test_nsphere.py b/tests/test_nsphere.py
index f773a862..c33d322c 100644
--- a/tests/test_nsphere.py
+++ b/tests/test_nsphere.py
@@ -27,7 +27,7 @@ class NSphereTest(g.unittest.TestCase):
         # check minimum n-sphere for points in 2, 3, 4 dimensions
         for d in [2, 3, 4]:
             for i in range(5):
-                points = g.random((100, d))
+                points = g.np.random.random((100, d))
                 C, R = g.trimesh.nsphere.minimum_nsphere(points)
                 R_check = ((points - C)**2).sum(axis=1).max() ** .5
                 assert len(C) == d
@@ -38,7 +38,7 @@ class NSphereTest(g.unittest.TestCase):
         # make sure created spheres are uv sphere
         m = g.trimesh.creation.uv_sphere()
         # move the mesh around for funsies
-        m.apply_translation(g.random(3))
+        m.apply_translation(g.np.random.random(3))
         m.apply_transform(
             g.trimesh.transformations.random_rotation_matrix())
         # all vertices should be on nsphere
diff --git a/tests/test_packing.py b/tests/test_packing.py
index ecf7315c..0f698df7 100644
--- a/tests/test_packing.py
+++ b/tests/test_packing.py
@@ -107,7 +107,7 @@ class PackingTest(g.unittest.TestCase):
         paths = [g.trimesh.load_path(i) for i in polygons]
 
         with g.Profiler() as P:
-            r, tf, consume = packing.paths(paths, spacing=0.02)
+            r, tf, consume = packing.paths(paths)
         print(P.output_text())
         # number of paths inserted
         count = consume.sum()
@@ -179,8 +179,8 @@ class PackingTest(g.unittest.TestCase):
                 extents = []
                 for i in ori:
                     extents.append(
-                        g.np.roll(i, int(g.random() * 10)) +
-                        g.random(3))
+                        g.np.roll(i, int(g.np.random.random() * 10)) +
+                        g.np.random.random(3))
                 extents = g.np.array(extents)
 
                 bounds, consume = packing.rectangles(extents)
@@ -238,15 +238,13 @@ class PackingTest(g.unittest.TestCase):
                 density.append(viz.volume / viz.bounding_box.volume)
         print(P.output_text())
 
-    def test_meshes(self, count=20):
+    def test_meshes(self):
         from trimesh.path import packing
         # create some random rotation boxes
         meshes = [g.trimesh.creation.box(
-            extents=extents,
-            transform=transform)
-            for transform, extents in zip(
-            g.random_transforms(count),
-            (g.random((count, 3)) + 1) * 10)]
+            extents=g.np.random.random(3),
+            transform=g.tf.random_rotation_matrix())
+            for _ in range(20)]
         packed, transforms, consume = packing.meshes(
             meshes, spacing=0.01)
         scene = g.trimesh.Scene(packed)
diff --git a/tests/test_points.py b/tests/test_points.py
index cb2387bd..459ba984 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -12,7 +12,7 @@ class PointsTest(g.unittest.TestCase):
         """
         shape = (100, 3)
         # random points
-        points = g.random(shape)
+        points = g.np.random.random(shape)
         # make sure randomness never gives duplicates by offsetting
         points += g.np.arange(shape[0]).reshape((-1, 1))
 
@@ -35,7 +35,7 @@ class PointsTest(g.unittest.TestCase):
         assert hash(cloud) == initial_hash
 
         # set some random colors
-        cloud.colors = g.random((shape[0], 4))
+        cloud.colors = g.np.random.random((shape[0], 4))
         # remove the duplicates we created
         cloud.merge_vertices()
 
@@ -80,8 +80,8 @@ class PointsTest(g.unittest.TestCase):
         vertices and no faces for some unknowable reason
         """
 
-        v = g.random((1000, 3))
-        v[g.np.floor(g.random(90) * len(v)).astype(int)] = v[0]
+        v = g.np.random.random((1000, 3))
+        v[g.np.floor(g.np.random.random(90) * len(v)).astype(int)] = v[0]
 
         mesh = g.trimesh.Trimesh(v)
 
@@ -94,7 +94,7 @@ class PointsTest(g.unittest.TestCase):
             # create a random rotation
             matrix = g.trimesh.transformations.random_rotation_matrix()
             # create some random points in spacd
-            p = g.random((1000, 3))
+            p = g.np.random.random((1000, 3))
             # make them all lie on the XY plane so we know
             # the correct normal to check against
             p[:, 2] = 0
@@ -116,7 +116,7 @@ class PointsTest(g.unittest.TestCase):
             matrices = [g.trimesh.transformations.random_rotation_matrix()
                         for _ in range(nb_points_sets)]
             # create some random points in spacd
-            p = g.random((nb_points_sets, 1000, 3))
+            p = g.np.random.random((nb_points_sets, 1000, 3))
             # make them all lie on the XY plane so we know
             # the correct normal to check against
             p[..., 2] = 0
@@ -171,7 +171,7 @@ class PointsTest(g.unittest.TestCase):
         for dimension in [2, 3]:
             for count in [2, 10, 100]:
                 for i in range(10):
-                    points = g.random((count, dimension))
+                    points = g.np.random.random((count, dimension))
 
                     # find a path that visits every point quickly
                     idx, dist = g.trimesh.points.tsp(points, start=0)
@@ -225,7 +225,7 @@ class PointsTest(g.unittest.TestCase):
         # initial color CRC
         initial = hash(p.visual)
         # set to random colors
-        p.colors = g.random(
+        p.colors = g.np.random.random(
             (len(p.vertices), 4))
         # visual CRC should have changed
         assert hash(p.visual) != initial
@@ -255,8 +255,8 @@ class PointsTest(g.unittest.TestCase):
         assert mask.shape == (200,)
 
     def test_add_operator(self):
-        points_1 = g.random((10, 3))
-        points_2 = g.random((20, 3))
+        points_1 = g.np.random.random((10, 3))
+        points_2 = g.np.random.random((20, 3))
         colors_1 = [[123, 123, 123, 255]] * len(points_1)
         colors_2 = [[255, 0, 123, 255]] * len(points_2)
 
diff --git a/tests/test_polygons.py b/tests/test_polygons.py
index 808d9fb1..72472d2e 100644
--- a/tests/test_polygons.py
+++ b/tests/test_polygons.py
@@ -90,7 +90,7 @@ class PolygonTests(g.unittest.TestCase):
         m = g.trimesh.creation.icosphere(subdivisions=4)
 
         p = [g.trimesh.path.polygons.projected(m, normal=n)
-             for n in g.random((100, 3))]
+             for n in g.np.random.random((100, 3))]
 
         # sphere projection should never have interiors
         assert all(len(i.interiors) == 0 for i in p)
@@ -144,28 +144,6 @@ class PolygonTests(g.unittest.TestCase):
                 holes = []
             return Polygon(shell=shell, holes=holes)
 
-        def poly_corner(bh):
-            # return a rectangle with one corner
-            # at the origin and the rest in positive space
-            shell = rectangle(bh)
-            shell += shell.min(axis=0)
-            return Polygon(shell=shell)
-
-        def poly_doublecorner(bh):
-            # returns two equal sized rectangles as one polygon
-            # Same as poly_corner(), but the rectangle gets
-            # mirrored by 180 deg at the origin
-            # This puts the centroid in the origin with Ixy != 0
-            shell_1 = rectangle(bh)
-            shell_1 += shell_1.min(axis=0)
-            shell_2 = - shell_1
-            shell = g.np.concatenate((shell_1[2:, :],
-                                      shell_1[:2, :],
-                                      shell_2[2:, :],
-                                      shell_2[:2, :]
-                                      ), axis=0)
-            return Polygon(shell=shell)
-
         def truth(bh, bhi=None):
             # return the analytical second moment of area
             # for a rectangle with centroid at the origin
@@ -183,91 +161,46 @@ class PolygonTests(g.unittest.TestCase):
             # check a rectangle with one corner
             # at the origin and the rest in positive space
             b, h = bh
-            return g.np.array([b * h**3 / 3.0,
-                               h * b**3 / 3.0,
-                               0.5 * b**2 * 0.5 * h**2], dtype=g.np.float64)
-
-        from trimesh.path.polygons import second_moments
-        from trimesh.path.polygons import transform_polygon
+            return g.np.array([b * h**3,
+                               h * b**3], dtype=g.np.float64) / 3.0
 
+        from trimesh.path.polygons import second_moment
         from shapely.geometry import Polygon
-
         heights = g.np.array([[0.01, 0.01],
                               [1, 1],
                               [10, 2],
                               [3, 21]])
         for bh in heights:
             # check the second moment of a rectangle
-            # as polygon is already centered, centered doesn't have any effect
-            O_moments, O_principal_moments, O_alpha, O_transform = second_moments(
-                poly(bh), return_centered=True)
+            # as both a numpy array and as a Polygon
+            a = second_moment(rectangle(bh))
+            b = second_moment(poly(bh))
             # check against wikipedia
             t = truth(bh)
-            # for a centered rectangle, the principal axis are alread aligned
-            # with the frame axis
-            assert g.np.allclose(O_moments, t)
-            assert g.np.any(g.np.isclose(O_moments, O_principal_moments[0]))
-            assert g.np.isclose(O_moments[2], 0)  # Ixy = 0
-            assert g.np.isclose(O_alpha, 0)
-            assert g.np.allclose(O_transform, g.np.eye(3))
-
-            # now check a rectangle with the corner, so Ixy != 0
-
-            # First we test with centering. The results should be same as
-            # with the initally centered rectangles
-            C_moments, C_principal_moments, C_alpha, C_transform = second_moments(
-                poly_corner(bh), return_centered=True)
-            assert g.np.allclose(O_moments, C_moments)
-            assert g.np.allclose(O_principal_moments, C_principal_moments)
-            assert g.np.isclose(O_alpha, C_alpha)
-            assert g.np.allclose(O_transform[:, :2], C_transform[:, :2])
-
-            # Now without centering
-            moments = second_moments(poly_corner(bh), return_centered=False)
-            t = truth_corner(bh)
-            assert g.np.allclose(moments, t)
-
-            # Now we will get the transform for a double rectangle. Then we will apply
-            # the transform and test if Ixy == 0, alpha == 0 etc.
-            C_moments, C_principal_moments, C_alpha, C_transform = second_moments(
-                poly_doublecorner(bh), return_centered=True)
-            # apply the outputted transform to the polygon
-            T_polygon = transform_polygon(poly_doublecorner(bh), C_transform)
-            # call the function on the transformed polygon
-            T_moments, T_principal_moments, T_alpha, T_transform = second_moments(
-                T_polygon, return_centered=True)
-            assert g.np.any(g.np.isclose(T_moments, C_principal_moments[0]))
-            assert g.np.allclose(C_principal_moments, T_principal_moments)
-            assert g.np.isclose(T_alpha, 0, atol=1e-7)
-            assert g.np.allclose(T_transform, g.np.eye(3))
-
-            # check polygons with interior
+            assert g.np.allclose(a, b)
+            assert g.np.allclose(a, t)
+
+            # now add some interiors
             for bhi in heights:
                 # only check if interior is smaller than exterior
                 if not (bhi < bh).all():
                     continue
 
                 # check a rectangle with interiors
-                c = second_moments(poly(bh, bhi), return_centered=False)
+                c = second_moment(poly(bh, bhi))
                 t = truth(bh, bhi)
                 assert g.np.allclose(c, t)
 
-    def test_native_centroid(self):
-        # check our native implementation of the shoelace algorithm
-        # against the results from shapely which we use for native
-        # checks of counter-clockwise 2D coordinate loops
-
-        # get some polygons without interiors
-        polygons = [g.Polygon(i) for i in g.data['nestable']]
-        # same data as (n, 2) float arrays
-        coords = [g.np.array(i) for i in g.data['nestable']]
-
-        for p, c in zip(polygons, coords):
-            # area will be signed with respect to counter-clockwise
-            ccw, area, centroid = g.trimesh.util.is_ccw(c, return_all=True)
-            assert g.np.allclose(
-                centroid, g.np.array(p.centroid.coords)[0])
-            assert g.np.isclose(abs(area), p.area)
+            # now check a rectangle with the corner
+            # at the origin to see if we've done something
+            # silly with regards to offsets
+            r = rectangle(bh)
+            r += r.min(axis=0)
+            a = second_moment(r)
+            t = truth_corner(bh)
+            # not sure what Ixy is analytically because
+            # wikipedia didn't list it for this case
+            assert g.np.allclose(a[:2], t)
 
 
 if __name__ == '__main__':
diff --git a/tests/test_proximity.py b/tests/test_proximity.py
index 63be516a..d1015274 100644
--- a/tests/test_proximity.py
+++ b/tests/test_proximity.py
@@ -36,7 +36,7 @@ class NearestTest(g.unittest.TestCase):
     def test_helper(self):
         # just make sure the plumbing returns something
         for mesh in g.get_meshes(2):
-            points = (g.random((100, 3)) - .5) * 100
+            points = (g.np.random.random((100, 3)) - .5) * 100
 
             a = mesh.nearest.on_surface(points)
             assert a is not None
@@ -210,7 +210,7 @@ class NearestTest(g.unittest.TestCase):
 
     def test_candidates(self):
         mesh = g.trimesh.creation.random_soup(2000)
-        points = g.random((2000, 3))
+        points = g.np.random.random((2000, 3))
         g.trimesh.proximity.nearby_faces(
             mesh=mesh, points=points)
 
diff --git a/tests/test_ray.py b/tests/test_ray.py
index f3e6e77b..d94ea744 100644
--- a/tests/test_ray.py
+++ b/tests/test_ray.py
@@ -33,7 +33,7 @@ class RayTests(g.unittest.TestCase):
             sphere = g.get_mesh('unit_sphere.STL',
                                 use_embree=use_embree)
 
-            ray_origins = g.random(dimension)
+            ray_origins = g.np.random.random(dimension)
             ray_directions = g.np.tile([0, 0, 1], (dimension[0], 1))
             ray_origins[:, 2] = -5
 
@@ -66,7 +66,7 @@ class RayTests(g.unittest.TestCase):
             sphere = g.get_mesh('unit_sphere.STL',
                                 use_embree=use_embree)
             # should never hit the sphere
-            ray_origins = g.random(dimension)
+            ray_origins = g.np.random.random(dimension)
             ray_directions = g.np.tile([0, 1, 0], (dimension[0], 1))
             ray_origins[:, 2] = -5
 
@@ -94,7 +94,7 @@ class RayTests(g.unittest.TestCase):
             assert not test_out.any()
 
             points_way_out = (
-                g.random(
+                g.np.random.random(
                     (30, 3)) * 100) + 1.0 + mesh.bounds[1]
             test_way_out = mesh.ray.contains_points(points_way_out)
             assert not test_way_out.any()
diff --git a/tests/test_registration.py b/tests/test_registration.py
index 10267ac0..4519c80d 100644
--- a/tests/test_registration.py
+++ b/tests/test_registration.py
@@ -13,11 +13,11 @@ class RegistrationTest(g.unittest.TestCase):
         opt = g.itertools.combinations([True, False] * 6, 6)
         for reflection, translation, scale, a_flip, a_scale, weight in opt:
             # create random points in space
-            points_a = (g.random((1000, 3)) - .5) * 1000
+            points_a = (g.np.random.random((1000, 3)) - .5) * 1000
             # create a random transform
             matrix = g.trimesh.transformations.random_rotation_matrix()
             # add a translation component to transform
-            matrix[:3, 3] = g.random(3) * 100
+            matrix[:3, 3] = g.np.random.random(3) * 100
             # apply a flip (reflection) to test data
             if a_flip:
                 matrix = g.np.dot(
@@ -34,7 +34,7 @@ class RegistrationTest(g.unittest.TestCase):
 
             # weight points or not
             if weight:
-                weights = (g.random(len(points_a)) + 9) / 10
+                weights = (g.np.random.random(len(points_a)) + 9) / 10
             else:
                 weights = None
 
@@ -114,7 +114,7 @@ class RegistrationTest(g.unittest.TestCase):
     def test_icp_points(self):
         # see if ICP alignment works with point clouds
         # create random points in space
-        points_a = (g.random((1000, 3)) - .5) * 1000
+        points_a = (g.np.random.random((1000, 3)) - .5) * 1000
         # create a random transform
         # matrix = g.trimesh.transformations.random_rotation_matrix()
         # create a small transform
@@ -150,7 +150,7 @@ class RegistrationTest(g.unittest.TestCase):
         mesh = mesh.permutate.noise(noise)
         # randomly rotation with translation
         transform = g.trimesh.transformations.random_rotation_matrix()
-        transform[:3, 3] = (g.random(3) - .5) * 1000
+        transform[:3, 3] = (g.np.random.random(3) - .5) * 1000
 
         mesh.apply_transform(transform)
 
diff --git a/tests/test_render.py b/tests/test_render.py
index 8cbac6db..2a56f752 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -50,13 +50,13 @@ class RenderTest(g.unittest.TestCase):
         assert len(args) == 6
         assert len(args_auto) == len(args)
 
-        P22 = g.random((100, 2))
+        P22 = g.np.random.random((100, 2))
         args = rendering.points_to_vertexlist(P22)
         args_auto = rendering.convert_to_vertexlist(P22)
         assert len(args) == 6
         assert len(args_auto) == len(args)
 
-        P31 = g.random((100, 3))
+        P31 = g.np.random.random((100, 3))
         args = rendering.points_to_vertexlist(P31)
         args_auto = rendering.convert_to_vertexlist(P31)
         assert len(args) == 6
diff --git a/tests/test_resolvers.py b/tests/test_resolvers.py
index 3017e071..42a51e29 100644
--- a/tests/test_resolvers.py
+++ b/tests/test_resolvers.py
@@ -29,27 +29,16 @@ class ResolverTest(g.unittest.TestCase):
 
     def test_web_namespace(self):
         base = 'https://example.com'
-        name = 'stuff'
-        target = 'hi.gltf'
-
+        ns = 'stuff'
         # check with a trailing slash
         a = g.trimesh.resolvers.WebResolver(base + '/')
-        b = g.trimesh.resolvers.WebResolver(base + '//')
-        c = g.trimesh.resolvers.WebResolver(base)
-        d = a.namespaced(name)
-
-        # base URL's should always be the same with one trailing slash
-        assert a.base_url == b.base_url
-        assert b.base_url == c.base_url
-        assert c.base_url == base + '/'
-        # check namespaced
-        assert d.base_url == base + '/' + name + '/'
+        b = a.namespaced(ns)
 
         # should have correct slashes
-        truth = '/'.join([base, name, target])
-
-        assert a.base_url + name + '/' + target == truth
-        assert d.base_url + target == truth
+        truth = base + '/' + ns
+        assert b.base_url == truth
+        # should not have altered original
+        assert a.base_url == base + '/'
 
     def test_items(self):
         # check __getitem__ and __setitem__
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 7c3fcc4f..c34d7cb7 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -7,7 +7,7 @@ from trimesh.scene.transforms import EnforcedForest
 
 
 def random_chr():
-    return chr(ord('a') + int(round(g.random() * 25)))
+    return chr(ord('a') + int(round(g.np.random.random() * 25)))
 
 
 class GraphTests(g.unittest.TestCase):
@@ -82,7 +82,7 @@ class GraphTests(g.unittest.TestCase):
         assert g.np.allclose(f(), g.np.eye(4))
 
         # a passed matrix should return immediately
-        fix = g.random((4, 4))
+        fix = g.np.random.random((4, 4))
         assert g.np.allclose(f(matrix=fix), fix)
 
         quat = g.trimesh.unitize([1, 2, 3, 1])
@@ -205,13 +205,13 @@ class GraphTests(g.unittest.TestCase):
             n=1000, seed=0, create_using=g.nx.DiGraph)
         for e in tree.edges:
             data = {}
-            if g.random() > .5:
+            if g.np.random.random() > .5:
                 # if a matrix is omitted but an edge exists it is
                 # the same as passing an identity matrix
                 data['matrix'] = tf.random_rotation_matrix()
-            if g.random() > .4:
+            if g.np.random.random() > .4:
                 # a geometry is not required for a node
-                data['geometry'] = str(int(g.random() * 1e8))
+                data['geometry'] = str(int(g.np.random.random() * 1e8))
             edgelist[e] = data
 
         # now apply the random data to an EnforcedForest
diff --git a/tests/test_section.py b/tests/test_section.py
index 752821ed..d05f90bc 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -136,11 +136,11 @@ class PlaneLine(g.unittest.TestCase):
         z = g.np.linspace(-1, 1, count)
 
         plane_origins = g.np.column_stack((
-            g.random((count, 2)), z))
+            g.np.random.random((count, 2)), z))
         plane_normals = g.np.tile([0, 0, -1], (count, 1))
 
         line_origins = g.np.tile([0, 0, 0], (count, 1))
-        line_directions = g.random((count, 3))
+        line_directions = g.np.random.random((count, 3))
 
         i, valid = g.trimesh.intersections.planes_lines(
             plane_origins=plane_origins,
diff --git a/tests/test_segments.py b/tests/test_segments.py
index 3a127559..b48fc56f 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -12,7 +12,7 @@ class SegmentsTest(g.unittest.TestCase):
         # check 2D and 3D
         for dimension in [2, 3]:
             # a bunch of random line segments
-            s = g.random((100, 2, dimension))
+            s = g.np.random.random((100, 2, dimension))
             # convert segment to point on line closest to origin
             # as well as a vector and two distances along vector
             param = segments.segments_to_parameters(s)
diff --git a/tests/test_stl.py b/tests/test_stl.py
index 1e9e1a81..eb3855e3 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -36,7 +36,7 @@ class STLTests(g.unittest.TestCase):
 
         len_vertices = len(m.vertices)
         # assign some random vertex attributes
-        random = g.random(len(m.vertices))
+        random = g.np.random.random(len(m.vertices))
         m.vertex_attributes['random'] = random
         m.vertex_attributes['nah'] = 20
 
diff --git a/tests/test_transformations.py b/tests/test_transformations.py
index a32fd9e2..42dd60f5 100644
--- a/tests/test_transformations.py
+++ b/tests/test_transformations.py
@@ -65,11 +65,11 @@ class TransformTest(g.unittest.TestCase):
 
     def test_around(self):
         # check transform_around on 2D points
-        points = g.random((100, 2))
+        points = g.np.random.random((100, 2))
         for i, p in enumerate(points):
-            offset = g.random(2)
+            offset = g.np.random.random(2)
             matrix = g.trimesh.transformations.planar_matrix(
-                theta=g.random() + .1,
+                theta=g.np.random.random() + .1,
                 offset=offset,
                 point=p)
 
@@ -82,7 +82,7 @@ class TransformTest(g.unittest.TestCase):
             assert compare.all(axis=1).sum() == 1
 
         # check transform_around on 3D points
-        points = g.random((100, 3))
+        points = g.np.random.random((100, 3))
         for i, p in enumerate(points):
             matrix = g.trimesh.transformations.random_rotation_matrix()
             matrix = g.trimesh.transformations.transform_around(matrix, p)
@@ -106,9 +106,9 @@ class TransformTest(g.unittest.TestCase):
                                       [0, 0, 0, 1]),
                              [1, -1, 0, 1])
 
-        angle = (g.random() - 0.5) * (2 * g.np.pi)
-        direc = g.random(3) - 0.5
-        point = g.random(3) - 0.5
+        angle = (g.np.random.random() - 0.5) * (2 * g.np.pi)
+        direc = g.np.random.random(3) - 0.5
+        point = g.np.random.random(3) - 0.5
         R0 = rotation_matrix(angle, direc, point)
         R1 = rotation_matrix(angle - 2 * g.np.pi, direc, point)
         assert g.trimesh.transformations.is_same_transform(R0, R1)
diff --git a/tests/test_util.py b/tests/test_util.py
index 75bd6020..6d720982 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -48,7 +48,7 @@ class UtilTests(unittest.TestCase):
     def test_bounds_tree(self):
         for attempt in range(3):
             for dimension in [2, 3]:
-                t = g.random((1000, 3, dimension))
+                t = g.np.random.random((1000, 3, dimension))
                 bounds = g.np.column_stack((t.min(axis=1), t.max(axis=1)))
                 tree = g.trimesh.util.bounds_tree(bounds)
                 self.assertTrue(0 in tree.intersection(bounds[0]))
@@ -57,7 +57,7 @@ class UtilTests(unittest.TestCase):
         # shortcut to the function
         f = g.trimesh.util.stack_3D
         # start with some random points
-        p = g.random((100, 2))
+        p = g.np.random.random((100, 2))
         stack = f(p)
         # shape should be 3D
         assert stack.shape == (100, 3)
@@ -205,36 +205,6 @@ class UtilTests(unittest.TestCase):
             names.add(unique_name('', names))
         assert len(names) == count
 
-        # Try with a larger set of names
-        # get some random strings
-        names = [g.uuid4().hex for _ in range(20)]
-        # make it a whole lotta duplicates
-        names = names * 1000
-        # add a non-int postfix to test
-        names.extend(['suppp_hi'] * 10)
-
-        assigned = set()
-        with g.Profiler() as P:
-            for name in names:
-                assigned.add(unique_name(name, assigned))
-        print(P.output_text())
-
-        assigned_new = set()
-        # tracker = UniqueName()\
-        counts = {}
-        with g.Profiler() as P:
-            for name in names:
-                assigned_new.add(unique_name(
-                    name,
-                    contains=assigned_new,
-                    counts=counts))
-        print(P.output_text())
-
-        # new scheme should match the old one
-        assert assigned_new == assigned
-        # de-duplicated set should match original length
-        assert len(assigned) == len(names)
-
 
 class ContainsTest(unittest.TestCase):
 
@@ -264,7 +234,7 @@ class IOWrapTests(unittest.TestCase):
         util = g.trimesh.util
 
         # check wrap_as_stream
-        test_b = g.random(1).tobytes()
+        test_b = g.np.random.random(1).tobytes()
         test_s = 'this is a test yo'
         res_b = util.wrap_as_stream(test_b).read()
         res_s = util.wrap_as_stream(test_s).read()
diff --git a/tests/test_vector.py b/tests/test_vector.py
index 61cdb1f4..ff356adc 100644
--- a/tests/test_vector.py
+++ b/tests/test_vector.py
@@ -11,7 +11,7 @@ class SphericalTests(g.unittest.TestCase):
         Convert vectors to spherical coordinates
         """
         # random unit vectors
-        v = g.trimesh.unitize(g.random((1000, 3)) - .5)
+        v = g.trimesh.unitize(g.np.random.random((1000, 3)) - .5)
         # (n, 2) angles in radians
         spherical = g.trimesh.util.vector_to_spherical(v)
         # back to unit vectors
@@ -26,7 +26,7 @@ class HemisphereTests(g.unittest.TestCase):
         for dimension in [2, 3]:
             # random unit vectors
             v = g.trimesh.unitize(
-                g.random((10000, dimension)) - .5)
+                g.np.random.random((10000, dimension)) - .5)
 
             # add some on- axis points
             v[:dimension] = g.np.eye(dimension)