Repository: mikedh/trimesh
Pull request #1546 (2022-04-02 10:08:49)

Keeping STL Vertex Order
Hey Mike,

thank you for this fantastic library. As mentioned by another user in #1310, STL vertex ordering can be important to some use cases. In this PR, I have implemented a `unique_ordered` function that also returns the `inverse` list, which means we can use it in `merge_vertices`.

I have attached some timings, with one of the test models, and a subdivided suzanne model that has ~30k vertices. What do you think about this?

```python
In [2]: import trimesh_fork.trimesh as tmf

In [3]: import trimesh as tm

In [4]: import numpy as np

In [5]: data = np.random.random((500_000, 3))

In [9]: %timeit -n 10 tm.grouping.unique_rows(data)
155 ms ± 5.79 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [10]: %timeit -n 10 tmf.grouping.unique_rows(data)
276 ms ± 8.31 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [12]: %timeit -n 10 tm.load("./trimesh_fork/models/featuretype.STL")
4.35 ms ± 1.61 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [13]: %timeit -n 10 tmf.load("./trimesh_fork/models/featuretype.STL")
4.02 ms ± 320 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [14]: %timeit -n 10 tm.load("...snip.../suzanne-30000v.stl")
66 ms ± 611 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [15]: %timeit -n 10 tmf.load("...snip.../suzanne-30000v.stl")
69.8 ms ± 1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

...

In [3]: %timeit -n 10 tm.load("...snip.../suzanne-500_000v.stl")
1.69 s ± 47 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [4]: %timeit -n 10 tmf.load("...snip.../suzanne-500_000v.stl")
1.8 s ± 36.3 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

```


Patch:
diff --git a/trimesh/base.py b/trimesh/base.py
index 80a65798..d37a2cbb 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1109,6 +1109,8 @@ class Trimesh(Geometry3D):
           Number of digits to consider for unit normals
         digits_uv : int
           Number of digits to consider for UV coordinates
+        keep_vertex_order: bool
+          If True, vertex order will be preserved
         """
         if 'textured' in kwargs:
             kwargs['merge_tex'] = not kwargs.pop('textured')
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index e35094a0..119accb5 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -1,5 +1,5 @@
 import numpy as np
-from .constants import log, now
+from .constants import log
 
 from . import util
 from . import convex
@@ -163,7 +163,7 @@ def oriented_bounds(obj,
             'Oriented bounds must be passed a mesh or a set of points!')
 
     min_volume = np.inf
-    tic = now()
+    tic = util.now()
 
     # matrices which will rotate each hull normal to [0,0,1]
     if normal is None:
@@ -228,7 +228,7 @@ def oriented_bounds(obj,
         min_extents = min_extents[order]
 
     log.debug('oriented_bounds checked %d vectors in %0.4fs',
-              len(matrices), now() - tic)
+              len(matrices), util.now() - tic)
 
     return to_origin, min_extents
 
@@ -353,12 +353,12 @@ def minimum_cylinder(obj, sample_count=6, angle_tol=.001):
             (samples,
              util.vector_to_spherical(obj.principal_inertia_vectors)))
 
-    tic = [now()]
+    tic = [util.now()]
     # the projected volume at each sample
     volumes = np.array([volume_from_angles(i) for i in samples])
     # the best vector in (2,) spherical coordinates
     best = samples[volumes.argmin()]
-    tic.append(now())
+    tic.append(util.now())
 
     # since we already explored the global space, set the bounds to be
     # just around the sample that had the lowest volume
@@ -372,7 +372,7 @@ def minimum_cylinder(obj, sample_count=6, angle_tol=.001):
                           method='SLSQP',
                           bounds=bounds)
 
-    tic.append(now())
+    tic.append(util.now())
     log.debug('Performed search in %f and minimize in %f', *np.diff(tic))
 
     # actually chunk the information about the cylinder
diff --git a/trimesh/constants.py b/trimesh/constants.py
index c1f0a507..0ed0d401 100644
--- a/trimesh/constants.py
+++ b/trimesh/constants.py
@@ -1,13 +1,6 @@
 import numpy as np
 
-from .util import log, PY3
-
-if PY3:
-    # will be the highest granularity clock available
-    from time import perf_counter as now
-else:
-    # perf_counter not available on python 2
-    from time import time as now
+from .util import log, now
 
 
 class ToleranceMesh(object):
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index a3129868..e95e76a9 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -1287,8 +1287,8 @@ def _read_buffers(header,
                 names_original[index].append(name)
                 # make name unique across multiple meshes
                 if name in meshes:
-                    name += "_" + util.unique_id(length=5)
-                    assert name not in meshes
+                    name += "_" + util.unique_id(
+                        length=5, increment=index)
                 if mode == _GL_LINES:
                     # load GL_LINES into a Path object
                     from ..path.entities import Line
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index 34b65e68..e5dccd1e 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -851,7 +851,7 @@ def export_obj(mesh,
                 log.debug('failed to convert vertex normals',
                           exc_info=True)
 
-        if include_texture and hasattr(mesh.visual, 'uv'):
+        if include_texture and len(np.shape(getattr(mesh.visual, 'uv', None))) == 2:
             try:
                 material = mesh.visual.material
                 if hasattr(material, 'to_simple'):
@@ -859,18 +859,15 @@ def export_obj(mesh,
                 (tex_data,
                  tex_name,
                  mtl_name) = material.to_obj()
-
-                if len(np.shape(getattr(mesh.visual, 'uv', None))) == 2:
-                    converted = util.array_to_string(
-                        mesh.visual.uv,
-                        col_delim=' ',
-                        row_delim='\nvt ',
-                        digits=digits)
-                    # if vertex texture exists and is the right shape
-                    face_type.append('vt')
-                    # add the uv coordinates
-                    export.append('vt ' + converted)
-
+                converted = util.array_to_string(
+                    mesh.visual.uv,
+                    col_delim=' ',
+                    row_delim='\nvt ',
+                    digits=digits)
+                # if vertex texture exists and is the right shape
+                face_type.append('vt')
+                # add the uv coordinates
+                export.append('vt ' + converted)
                 # add the reference to the MTL file
                 objects.appendleft('mtllib {}'.format(mtl_name))
                 # add the directive to use the exported material
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 19152350..15c5e932 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -26,6 +26,7 @@ def merge_vertices(mesh,
                    digits_vertex=None,
                    digits_norm=2,
                    digits_uv=4,
+                   keep_vertex_order=False,
                    **kwargs):
     """
     Removes duplicate vertices, grouped by position and
@@ -47,6 +48,8 @@ def merge_vertices(mesh,
       Number of digits to consider for unit normals
     digits_uv : int
       Number of digits to consider for UV coordinates
+    keep_vertex_order: bool
+      If True, vertex order will be preserved
     """
     # no vertices so exit early
     if len(mesh.vertices) == 0:
@@ -88,7 +91,7 @@ def merge_vertices(mesh,
     stacked = np.column_stack(stacked).round().astype(np.int64)
 
     # check unique rows of referenced vertices
-    u, i = unique_rows(stacked[referenced])
+    u, i = unique_rows(stacked[referenced], keep_order=keep_vertex_order)
 
     # construct an inverse using the subset
     inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
@@ -252,7 +255,7 @@ def float_to_int(data, digits=None, dtype=np.int32):
     return as_int
 
 
-def unique_ordered(data):
+def unique_ordered(data, return_index=False, return_inverse=False):
     """
     Returns the same as np.unique, but ordered as per the
     first occurrence of the unique value in data.
@@ -268,9 +271,23 @@ def unique_ordered(data):
     Out[3]: array([0, 3, 4, 1, 2])
     """
     data = np.asanyarray(data)
-    order = np.sort(np.unique(data, return_index=True)[1])
-    result = data[order]
-    return result
+    if not return_index and not return_inverse:
+        order = np.sort(np.unique(data, return_index=True)[1])
+        result = data[order]
+        return result
+
+    uniques, uidxs, inverse = np.unique(data, return_index=True, return_inverse=True)
+
+    sorted2ordered = np.argsort(uidxs)
+    uniques_ordered = uniques[sorted2ordered]
+
+    ordered2sorted = np.argsort(sorted2ordered)
+    inv_ordered = ordered2sorted[inverse]
+
+    if return_index and not return_inverse:
+        return uniques_ordered, uidxs[sorted2ordered]
+
+    return uniques_ordered, uidxs[sorted2ordered], inv_ordered
 
 
 def unique_bincount(values,
@@ -402,7 +419,7 @@ def unique_float(data,
     return tuple(result)
 
 
-def unique_rows(data, digits=None):
+def unique_rows(data, digits=None, keep_order=False):
     """
     Returns indices of unique rows. It will return the
     first occurrence of a row that is duplicated:
@@ -424,7 +441,10 @@ def unique_rows(data, digits=None):
       Example: data[unique][inverse] == data
     """
     rows = hashable_rows(data, digits=digits)
-    _, unique, inverse = np.unique(
+
+    unique_fun = unique_ordered if keep_order else np.unique
+
+    _, unique, inverse = unique_fun(
         rows,
         return_index=True,
         return_inverse=True)
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 42edf930..ce420261 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -672,8 +672,7 @@ def repair_invalid(polygon, scale=None, rtol=.5):
 def projected(mesh,
               normal,
               origin=None,
-              rpad=1e-5,
-              apad=None,
+              pad=1e-5,
               tol_dot=0.01,
               max_regions=200):
     """
@@ -683,12 +682,6 @@ def projected(mesh,
     Note that this will ignore back-faces, which is only
     relevant if the source mesh isn't watertight.
 
-    Also padding: this generates a result by unioning the
-    polygons of multiple connected regions, which requires
-    the polygons be padded by a distance so that a polygon
-    union produces a single coherent result. This distance
-    is calculated as: `apad + (rpad * scale)`
-
     Parameters
     ----------
     mesh : trimesh.Trimesh
@@ -699,12 +692,9 @@ def projected(mesh,
       Normal to extract flat pattern along
     origin : None or (3,) float
       Origin of plane to project mesh onto
-    rpad : float
+    pad : float
       Proportion to pad polygons by before unioning
       and then de-padding result by to avoid zero-width gaps.
-    apad : float
-      Absolute padding to pad polygons by before unioning
-      and then de-padding result by to avoid zero-width gaps.
     tol_dot : float
       Tolerance for discarding on-edge triangles.
     max_regions : int
@@ -784,16 +774,6 @@ def projected(mesh,
         polygons.extend(edges_to_polygons(
             edges=edge[group], vertices=vertices_2D))
 
-    padding = 0.0
-    if apad is not None:
-        # set padding by absolute value
-        padding += float(apad)
-    if rpad is not None:
-        # get the 2D scale as the longest side of the AABB
-        scale = vertices_2D.ptp(axis=0).max()
-        # apply the scale-relative padding
-        padding += float(rpad) * scale
-
     # some types of errors will lead to a bajillion disconnected
     # regions and the union will take forever to fail
     # so exit here early
@@ -802,13 +782,23 @@ def projected(mesh,
 
     # if there is only one region we don't need to run a union
     elif len(polygons) == 1:
-        polygon = polygons[0].buffer(padding).buffer(-padding)
+        polygon = polygons[0]
+        # we do however need to double buffer to de-garbage the polygon
+        scale = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).max()
+        padding = scale * pad
+        polygon = polygon.buffer(padding).buffer(-padding)
     elif len(polygons) == 0:
         return None
     else:
+        # get all points for every AABB
+        extrema = np.reshape([p.bounds for p in polygons], (-1, 2))
+        # extract the model scale from the maximum AABB side length
+        scale = extrema.ptp(axis=0).max()
+        # pad each polygon proportionally to that scale
+        distance = abs(scale * pad)
         # inflate each polygon before unioning to remove zero-size
         # gaps then deflate the result after unioning by the same amount
         polygon = ops.unary_union(
-            [p.buffer(padding) for p in polygons]).buffer(-padding)
+            [p.buffer(distance) for p in polygons]).buffer(-distance)
 
     return polygon
diff --git a/trimesh/path/raster.py b/trimesh/path/raster.py
index 25208585..664dd106 100644
--- a/trimesh/path/raster.py
+++ b/trimesh/path/raster.py
@@ -65,7 +65,7 @@ def rasterize(path,
     resolution = tuple(resolution.tolist())
 
     # convert all discrete paths to pixel space
-    discrete = [((i - origin) / pitch).round().astype(np.int64)
+    discrete = [((i - origin) / pitch).astype(np.int64)
                 for i in path.discrete]
 
     # the path indexes that are exteriors
diff --git a/trimesh/repair.py b/trimesh/repair.py
index 10e74924..33ecba31 100644
--- a/trimesh/repair.py
+++ b/trimesh/repair.py
@@ -357,7 +357,7 @@ def fill_holes(mesh):
     return mesh.is_watertight
 
 
-def stitch(mesh, faces=None, insert_vertices=False):
+def fan_stitch(mesh, faces=None):
     """
     Create a fan stitch over the boundary of the specified
     faces. If the boundary is non-convex a triangle fan
@@ -369,15 +369,11 @@ def stitch(mesh, faces=None, insert_vertices=False):
       Vertices in space.
     faces : (n,) int
       Face indexes to stitch with triangle fans.
-    insert_vertices : bool
-      Allow stitching to insert new vertices?
 
     Returns
     ----------
     fan : (m, 3) int
       New triangles referencing mesh.vertices.
-    vertices : (p, 3) float
-      Inserted vertices (only returned `if insert_vertices`)
     """
     if faces is None:
         faces = np.arange(len(mesh.faces))
@@ -390,6 +386,13 @@ def stitch(mesh, faces=None, insert_vertices=False):
               if len(e.points) > 3 and
               e.points[0] == e.points[-1]]
 
+    # now create a triangle fan for each boundary curve
+    fan = [np.column_stack((
+        np.ones(len(p) - 3, dtype=int) * p[0],
+        p[1:-2],
+        p[2:-1]))
+        for p in points]
+
     # get properties to avoid querying in loop
     vertices = mesh.vertices
     normals = mesh.face_normals
@@ -398,30 +401,6 @@ def stitch(mesh, faces=None, insert_vertices=False):
     edges_face = mesh.edges_face
     tree_edge = mesh.edges_sorted_tree
 
-    if insert_vertices:
-        # create one new vertex per curve at the centroid
-        centroids = np.array([vertices[p].mean(axis=0)
-                              for p in points])
-        # save the original length of the vertices
-        count = len(vertices)
-        # for the normal check stack our local vertices
-        vertices = np.vstack((vertices, centroids))
-        # create a triangle between our new centroid vertex
-        # and each one of the boundary curves
-        fan = [np.column_stack((
-            np.ones(len(p) - 1, dtype=int) * (count + i),
-            p[:-1],
-            p[1:]))
-            for i, p in enumerate(points)]
-    else:
-        # since we're not allowed to insert new vertices
-        # create a triangle fan for each boundary curve
-        fan = [np.column_stack((
-            np.ones(len(p) - 3, dtype=int) * p[0],
-            p[1:-2],
-            p[2:-1]))
-            for p in points]
-
     # now we do a normal check against an adjacent face
     # to see if each region needs to be flipped
     for i, p, t in zip(range(len(fan)), points, fan):
@@ -455,8 +434,4 @@ def stitch(mesh, faces=None, insert_vertices=False):
         if sign.mean() < 0:
             fan[i] = np.fliplr(t)
 
-    fan = np.vstack(fan)
-
-    if insert_vertices:
-        return fan, centroids
-    return fan
+    return np.vstack(fan)
diff --git a/trimesh/sample.py b/trimesh/sample.py
index eafb7961..ef312240 100644
--- a/trimesh/sample.py
+++ b/trimesh/sample.py
@@ -9,10 +9,9 @@ import numpy as np
 
 from . import util
 from . import transformations
-from .visual import uv_to_interpolated_color
 
 
-def sample_surface(mesh, count, face_weight=None, sample_color=False):
+def sample_surface(mesh, count, face_weight=None):
     """
     Sample the surface of a mesh, returning the specified
     number of points
@@ -29,18 +28,13 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False):
     face_weight : None or len(mesh.faces) float
       Weight faces by a factor other than face area.
       If None will be the same as face_weight=mesh.area
-    sample_color : bool
-      Option to calculate the color of the sampled points.
-      Default is False.
+
     Returns
     ---------
     samples : (count, 3) float
       Points in space on the surface of mesh
     face_index : (count,) int
       Indices of faces for each sampled point
-    colors : (count, 4) float
-      Colors of each sampled point
-      Returns only when the sample_color is True
     """
 
     if face_weight is None:
@@ -57,22 +51,14 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False):
     face_index = np.searchsorted(weight_cum, face_pick)
 
     # pull triangles into the form of an origin + 2 vectors
-    tri_origins = mesh.vertices[mesh.faces[:, 0]]
-    tri_vectors = mesh.vertices[mesh.faces[:, 1:]].copy()
+    tri_origins = mesh.triangles[:, 0]
+    tri_vectors = mesh.triangles[:, 1:].copy()
     tri_vectors -= np.tile(tri_origins, (1, 2)).reshape((-1, 2, 3))
 
     # pull the vectors for the faces we are going to sample from
     tri_origins = tri_origins[face_index]
     tri_vectors = tri_vectors[face_index]
 
-    if sample_color:
-        uv_origins = mesh.visual.uv[mesh.faces[:, 0]]
-        uv_vectors = mesh.visual.uv[mesh.faces[:, 1:]].copy()
-        uv_origins_tile = np.tile(uv_origins, (1, 2)).reshape((-1, 2, 2))
-        uv_vectors -= uv_origins_tile
-        uv_origins = uv_origins[face_index]
-        uv_vectors = uv_vectors[face_index]
-
     # randomly generate two 0-1 scalar components to multiply edge vectors by
     random_lengths = np.random.random((len(tri_vectors), 2, 1))
 
@@ -91,14 +77,6 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False):
     # (n,3) points in space on the triangle
     samples = sample_vector + tri_origins
 
-    if sample_color:
-        sample_uv_vector = (uv_vectors * random_lengths).sum(axis=1)
-        uv_samples = sample_uv_vector + uv_origins
-        texture = mesh.visual.material.image
-        colors = uv_to_interpolated_color(uv_samples, texture)
-
-        return samples, face_index, colors
-
     return samples, face_index
 
 
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 071e6971..75d74ac1 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -180,10 +180,9 @@ class Scene(Geometry3D):
             # if the name of the geometry is also a transform node
             if name in self.graph.nodes:
                 # a random unique identifier
-                unique = util.unique_id()
+                unique = util.unique_id(increment=len(self.geometry))
                 # geometry name + UUID
                 node_name = name + '_' + unique.upper()
-                assert node_name not in self.graph.nodes
             else:
                 # otherwise make the transform node name the same as the geom
                 node_name = name
diff --git a/trimesh/util.py b/trimesh/util.py
index 29b09a67..d863f234 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -13,9 +13,7 @@ import abc
 import sys
 import copy
 import json
-import uuid
 import base64
-import random
 import shutil
 import logging
 import hashlib
@@ -39,6 +37,8 @@ if PY3:
     basestring = str
     # Python 3
     from io import BytesIO, StringIO
+    # will be the highest granularity clock available
+    from time import perf_counter as now
 else:
     # Python 2
     from StringIO import StringIO
@@ -46,6 +46,8 @@ else:
     StringIO.__enter__ = lambda a: a
     StringIO.__exit__ = lambda a, b, c, d: a.close()
     BytesIO = StringIO
+    # perf_counter not available on python 2
+    from time import time as now
 
 
 try:
@@ -1533,13 +1535,11 @@ def submesh(mesh,
     Parameters
     ------------
     mesh : Trimesh
-        Source mesh to take geometry from
+       Source mesh to take geometry from
     faces_sequence : sequence (p,) int
         Indexes of mesh.faces
-    repair : bool
-        Try to make submeshes watertight
     only_watertight : bool
-        Only return submeshes which are watertight
+        Only return submeshes which are watertight.
     append : bool
         Return a single mesh which has the faces appended,
         if this flag is set, only_watertight is ignored
@@ -2121,23 +2121,45 @@ def write_encoded(file_obj,
     return stuff
 
 
-def unique_id(length=12):
+def unique_id(length=12, increment=0):
     """
-    Generate a random alphanumeric unique identifier
-    using UUID logic.
+    Generate a decent looking alphanumeric unique identifier.
+    First 16 bits are time-incrementing, followed by randomness.
+
+    This function is used as a nicer looking alternative to:
+    >>> uuid.uuid4().hex
+
+    Follows the advice in:
+    https://eager.io/blog/how-long-does-an-id-need-to-be/
 
     Parameters
     ------------
     length : int
       Length of desired identifier
+    increment : int
+      Number to add to header uint16
+      useful if calling this function repeatedly
+      in a tight loop executing faster than time
+      can increment the header
 
     Returns
     ------------
     unique : str
       Unique alphanumeric identifier
     """
-    return uuid.UUID(int=random.getrandbits(128),
-                     version=4).hex[:length]
+    # head the identifier with 16 bits of time information
+    # this provides locality and reduces collision chances
+    head = np.array((increment + now() * 10) % 2**16,
+                    dtype=np.uint16).tobytes()
+    # get a bunch of random bytes
+    random = np.random.random(int(np.ceil(length / 5))).tobytes()
+    # encode the time header and random information as base64
+    # replace + and / with spaces
+    unique = base64.b64encode(head + random,
+                              b'  ').decode('utf-8')
+    # remove spaces and cut to length
+    unique = unique.replace(' ', '')[:length]
+    return unique
 
 
 def generate_basis(z, epsilon=1e-12):
diff --git a/trimesh/version.py b/trimesh/version.py
index 59279112..4b2207c2 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.10.8'
+__version__ = '3.10.7'
diff --git a/trimesh/visual/__init__.py b/trimesh/visual/__init__.py
index a99f4888..ac86af77 100644
--- a/trimesh/visual/__init__.py
+++ b/trimesh/visual/__init__.py
@@ -12,7 +12,6 @@ from .color import (ColorVisuals,
                     DEFAULT_COLOR,
                     interpolate,
                     uv_to_color,
-                    uv_to_interpolated_color,
                     linear_color_map)
 
 from .texture import TextureVisuals
@@ -38,5 +37,4 @@ __all__ = ['color',
            'DEFAULT_COLOR',
            'interpolate',
            'linear_color_map',
-           'uv_to_color',
-           'uv_to_interpolated_color']
+           'uv_to_color']
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 3e595808..800aa28d 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -898,65 +898,5 @@ def uv_to_color(uv, image):
     return colors
 
 
-def uv_to_interpolated_color(uv, image):
-    """
-    Get the color from texture image using bilinear sampling.
-
-    Parameters
-    -------------
-    uv : (n, 2) float
-      UV coordinates on texture image
-    image : PIL.Image
-      Texture image
-
-    Returns
-    ----------
-    colors : (n, 4) float
-      RGBA color at each of the UV coordinates
-    """
-    if image is None or uv is None:
-        return None
-
-    # UV coordinates should be (n, 2) float
-    uv = np.asanyarray(uv, dtype=np.float64)
-
-    # get texture image pixel positions of UV coordinates
-    x = (uv[:, 0] * (image.width - 1))
-    y = ((1 - uv[:, 1]) * (image.height - 1))
-
-    x_floor = np.floor(x).astype(np.int64) % image.width
-    y_floor = np.floor(y).astype(np.int64) % image.height
-
-    x_ceil = np.ceil(x).astype(np.int64) % image.width
-    y_ceil = np.ceil(y).astype(np.int64) % image.height
-
-    dx = x % image.width - x_floor
-    dy = y % image.height - y_floor
-
-    img = np.asanyarray(image.convert('RGBA'))
-
-    colors00 = img[y_floor, x_floor]
-    colors01 = img[y_ceil, x_floor]
-    colors10 = img[y_floor, x_ceil]
-    colors11 = img[y_ceil, x_ceil]
-
-    a00 = (1 - dx) * (1 - dy)
-    a01 = dx * (1 - dy)
-    a10 = (1 - dx) * dy
-    a11 = dx * dy
-
-    a00 = np.repeat(a00[:, None], 4, axis=1)
-    a01 = np.repeat(a01[:, None], 4, axis=1)
-    a10 = np.repeat(a10[:, None], 4, axis=1)
-    a11 = np.repeat(a11[:, None], 4, axis=1)
-
-    colors = a00 * colors00 + a01 * colors01 + a10 * colors10 + a11 * colors11
-
-    # conversion to RGBA should have corrected shape
-    assert colors.ndim == 2 and colors.shape[1] == 4
-
-    return colors
-
-
 # set an arbitrary grey as the default color
 DEFAULT_COLOR = np.array([102, 102, 102, 255], dtype=np.uint8)


Test patch:
diff --git a/tests/test_color.py b/tests/test_color.py
index 0058fae3..83076faf 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -221,27 +221,6 @@ class VisualTest(g.unittest.TestCase):
 
         g.np.testing.assert_allclose(colors, colors_expected, rtol=0, atol=0)
 
-    def test_uv_to_interpolated_color(self):
-        try:
-            import PIL.Image
-        except ImportError:
-            return
-
-        uv = g.np.array([[0.25, 0.2], [0.4, 0.5]], dtype=float)
-
-        tmp = g.np.arange(32)
-        x, y = g.np.meshgrid(tmp, tmp)
-        z = x + 128
-
-        texture = g.np.stack([x, y, z], axis=-1).astype(g.np.uint8)
-
-        img = PIL.Image.fromarray(texture)
-        colors = g.trimesh.visual.uv_to_interpolated_color(uv, img)
-        colors_expected = [[7.75, 24.8, 128 + 7.75, 255],
-                           [12.4, 15.5, 128 + 12.4, 255]]
-
-        g.np.testing.assert_allclose(colors, colors_expected, rtol=0, atol=0.1)
-
     def test_iterset(self):
         m = g.trimesh.creation.box()
         color = [100, 0, 0, 200]
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
index 73329b88..e42c7f79 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -288,6 +288,23 @@ class GroupTests(g.unittest.TestCase):
         # even though faces are wrong
         mesh.merge_vertices()
 
+    def test_unique_ordered(self):
+        a = g.np.array([9, 9, 9, 8, 8, 7, 7, 6,
+                        6, 5, 5, 4, 4, 3, 3, 0,
+                        2, 1, 1, 0, 0, -1, -1, -1],
+                       dtype=g.np.int64)
+
+        u, ind, inv = g.trimesh.grouping.unique_ordered(
+            a,
+            return_index=True,
+            return_inverse=True
+        )
+
+        # indices are increasing, because we kept original order
+        assert (g.np.diff(ind) > 0).all()
+        # we can reconstruct original data
+        assert (u[inv] == a).all()
+
 
 def check_roll_wrap(**kwargs):
     """
diff --git a/tests/test_obj.py b/tests/test_obj.py
index e514255f..bf6819c7 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -267,14 +267,6 @@ class OBJTest(g.unittest.TestCase):
             mesh.export(file_type='obj'), file_type='obj')
         assert g.np.isclose(mesh.area, rec.area)
 
-    def test_no_uv_but_mtl(self):
-        sphere = g.trimesh.creation.uv_sphere()
-        sphere.visual = g.trimesh.visual.TextureVisuals(
-            uv=None,
-            material=g.trimesh.visual.material.empty_material())
-        output = sphere.export('sphere.obj')
-        assert('usemtl' in output)
-
     def test_chair(self):
         mesh = next(iter(g.get_mesh('chair.zip').geometry.values()))
 
diff --git a/tests/test_repair.py b/tests/test_repair.py
index 6af454ed..8016bdfd 100644
--- a/tests/test_repair.py
+++ b/tests/test_repair.py
@@ -189,7 +189,7 @@ class RepairTests(g.unittest.TestCase):
         assert m.is_winding_consistent
 
         # create a triangle fan to cover the hole
-        stitch = g.trimesh.repair.stitch(m)
+        stitch = g.trimesh.repair.fan_stitch(m)
         # should be an (n, 3) int
         assert len(stitch.shape) == 2
         assert stitch.shape[1] == 3
diff --git a/tests/test_stl.py b/tests/test_stl.py
index a0f8c3c0..51f68e01 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -90,6 +90,32 @@ class STLTests(g.unittest.TestCase):
                 return
             raise ValueError("Shouldn't export empty scenes!")
 
+    def test_vertex_order(self):
+        for stl in ['featuretype.STL', 'ADIS16480.STL', '1002_tray_bottom.STL']:
+            # removing doubles should respect the vertex order
+            m_raw = g.get_mesh(stl, process=False)
+            m_proc = g.get_mesh(stl, process=True, keep_vertex_order=True)
+
+            verts_raw = g.trimesh.grouping.hashable_rows(m_raw.vertices)
+            verts_proc = g.trimesh.grouping.hashable_rows(m_proc.vertices)
+
+            # go through all processed verts
+            # find index in unprocessed mesh
+            idxs = []
+            for vert in verts_proc:
+                idxs.append(g.np.where(verts_raw == vert)[0][0])
+
+            # indices should be increasing
+            assert (g.np.diff(idxs) > 0).all()
+
+            tris_raw = m_raw.triangles
+            tris_proc = m_proc.triangles
+
+            # of course mesh needs to have same faces as before
+            assert g.np.allclose(
+                g.np.sort(tris_raw, axis=0), g.np.sort(tris_proc, axis=0)
+            )
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_util.py b/tests/test_util.py
index eb7258a2..de382a5a 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -167,27 +167,6 @@ class UtilTests(unittest.TestCase):
                             a.volume * count)
         assert a.md5() == hA
 
-    def test_unique_id(self):
-        num_ids = 10000
-
-        g.trimesh.util.random.seed(0)
-        unique_ids_0 = []
-        for i in range(num_ids):
-            s = g.trimesh.util.unique_id()
-            unique_ids_0.append(s)
-
-        # make sure every id is truly unique
-        assert len(unique_ids_0) == len(g.np.unique(unique_ids_0))
-
-        g.trimesh.util.random.seed(0)
-        unique_ids_1 = []
-        for i in range(num_ids):
-            s = g.trimesh.util.unique_id()
-            unique_ids_1.append(s)
-
-            # make sure id's can be reproduced
-            assert s == unique_ids_0[i]
-
 
 class ContainsTest(unittest.TestCase):