Repository: mikedh/trimesh
Pull request #1564 (2022-04-24 06:40:21)

bugfix: perpendicular vectors in merge_colinear
The perpendicular vectors in `merge_colinear` miss changing sign of one axis, thus not perpendicular to the direction vectors.

Example to reproduce:
~~~python
num=100
dists=np.linspace(0,1000,num=num)
direction=trimesh.unitize([1,1])
points=direction*dists.reshape((-1,1))
print(points.shape)

merged=trimesh.path.simplify.merge_colinear(points,scale=1000)
print(merged.shape)
~~~
output: 
~~~
points: (100, 2)
merged: (100, 2)
~~~


Patch:
diff --git a/trimesh/__init__.py b/trimesh/__init__.py
index 09a50990..e97f2ae3 100644
--- a/trimesh/__init__.py
+++ b/trimesh/__init__.py
@@ -33,10 +33,6 @@ from .exchange.load import (
 
 # avoid a circular import in trimesh.base
 from . import voxel
-from . import bounds
-from . import nsphere
-from . import collision
-from . import smoothing
 from . import primitives
 
 try:
@@ -55,10 +51,6 @@ __all__ = [__version__,
            'Scene',
            'voxel',
            'unitize',
-           'bounds',
-           'nsphere',
-           'collision',
-           'smoothing',
            'tol',
            'path',
            'load',
diff --git a/trimesh/base.py b/trimesh/base.py
index bcea26db..80a65798 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1,3 +1,4 @@
+# flake8: noqa
 """
 github.com/mikedh/trimesh
 ----------------------------
@@ -14,16 +15,19 @@ from . import sample
 from . import repair
 from . import convex
 from . import remesh
+from . import bounds
 from . import caching
 from . import inertia
+from . import nsphere
 from . import boolean
 from . import grouping
 from . import geometry
 from . import permutate
 from . import proximity
 from . import triangles
+from . import collision
 from . import curvature
-from . import smoothing  # noqa
+from . import smoothing
 from . import comparison
 from . import registration
 from . import decomposition
@@ -55,8 +59,6 @@ class Trimesh(Geometry3D):
                  metadata=None,
                  process=True,
                  validate=False,
-                 merge_tex=None,
-                 merge_norm=None,
                  use_embree=True,
                  initial_cache=None,
                  visual=None,
@@ -187,17 +189,12 @@ class Trimesh(Geometry3D):
         # process will remove NaN and Inf values and merge vertices
         # if validate, will remove degenerate and duplicate faces
         if process or validate:
-            self.process(validate=validate,
-                         merge_tex=merge_tex,
-                         merge_norm=merge_norm)
+            self.process(validate=validate, **kwargs)
 
         # save reference to kwargs
         self._kwargs = kwargs
 
-    def process(self,
-                validate=False,
-                merge_tex=None,
-                merge_norm=None):
+    def process(self, validate=False, **kwargs):
         """
         Do processing to make a mesh useful.
 
@@ -205,9 +202,8 @@ class Trimesh(Geometry3D):
             1) removing NaN and Inf values
             2) merging duplicate vertices
         If validate:
-            3) Remove triangles which have one edge
-               of their 2D oriented bounding box
-               shorter than tol.merge
+            3) Remove triangles which have one edge of their rectangular 2D
+               oriented bounding box shorter than tol.merge
             4) remove duplicated triangles
             5) ensure triangles are consistently wound
                and normals face outwards
@@ -215,7 +211,7 @@ class Trimesh(Geometry3D):
         Parameters
         ------------
         validate : bool
-          Remove degenerate and duplicate faces.
+          If True, remove degenerate and duplicate faces
 
         Returns
         ------------
@@ -229,8 +225,7 @@ class Trimesh(Geometry3D):
         # avoid clearing the cache during operations
         with self._cache:
             self.remove_infinite_values()
-            self.merge_vertices(merge_tex=merge_tex,
-                                merge_norm=merge_norm)
+            self.merge_vertices(**kwargs)
             # if we're cleaning remove duplicate
             # and degenerate faces
             if validate:
@@ -539,8 +534,9 @@ class Trimesh(Geometry3D):
         if len(in_mesh) == 0:
             return None
         # get mesh bounds with min and max
-        return np.array([in_mesh.min(axis=0),
-                         in_mesh.max(axis=0)])
+        mesh_bounds = np.array([in_mesh.min(axis=0),
+                                in_mesh.max(axis=0)])
+        return mesh_bounds
 
     @caching.cache_decorator
     def extents(self):
@@ -726,7 +722,7 @@ class Trimesh(Geometry3D):
           Three vectors pointing along the
           principal axis of inertia directions
         """
-        _ = self.principal_inertia_components
+        populate = self.principal_inertia_components
         return self._cache['principal_inertia_vectors']
 
     @caching.cache_decorator
@@ -878,7 +874,7 @@ class Trimesh(Geometry3D):
         edges_face : (n, ) int
           Index of self.faces
         """
-        _ = self.edges
+        populate = self.edges
         return self._cache['edges_face']
 
     @caching.cache_decorator
@@ -927,7 +923,7 @@ class Trimesh(Geometry3D):
         inverse : (len(self.edges), ) int
           Indexes of self.edges_unique
         """
-        _ = self.edges_unique
+        populate = self.edges_unique
         return self._cache['edges_unique_inverse']
 
     @caching.cache_decorator
@@ -1021,7 +1017,7 @@ class Trimesh(Geometry3D):
                 [ 6946, 24225]]])
         """
         # make sure we have populated unique edges
-        _ = self.edges_unique
+        populate = self.edges_unique
         # we are relying on the fact that edges are stacked in triplets
         result = self._cache['edges_unique_inverse'].reshape((-1, 3))
         return result
@@ -1092,15 +1088,9 @@ class Trimesh(Geometry3D):
         units._convert_units(self, desired, guess)
         return self
 
-    def merge_vertices(
-            self,
-            merge_tex=None,
-            merge_norm=None,
-            digits_vertex=None,
-            digits_norm=None,
-            digits_uv=None):
+    def merge_vertices(self, **kwargs):
         """
-        Removes duplicate vertices grouped by position and
+        Removes duplicate vertices, grouped by position and
         optionally texture coordinate and normal.
 
         Parameters
@@ -1120,13 +1110,11 @@ class Trimesh(Geometry3D):
         digits_uv : int
           Number of digits to consider for UV coordinates
         """
-        grouping.merge_vertices(
-            mesh=self,
-            merge_tex=merge_tex,
-            merge_norm=merge_norm,
-            digits_vertex=digits_vertex,
-            digits_norm=digits_norm,
-            digits_uv=digits_uv)
+        if 'textured' in kwargs:
+            kwargs['merge_tex'] = not kwargs.pop('textured')
+            log.warning(
+                'merge_vertices depreciation: `not textured`->`merge_tex`')
+        grouping.merge_vertices(self, **kwargs)
 
     def update_vertices(self, mask, inverse=None):
         """
@@ -1303,7 +1291,7 @@ class Trimesh(Geometry3D):
     @caching.cache_decorator
     def face_adjacency(self):
         """
-        Find faces that share an edge i.e. 'adjacent' faces.
+        Find faces that share an edge, which we call here 'adjacent'.
 
         Returns
         ----------
@@ -1352,7 +1340,7 @@ class Trimesh(Geometry3D):
            Vertex indices which correspond to face_adjacency
         """
         # this value is calculated as a byproduct of the face adjacency
-        _ = self.face_adjacency
+        populate = self.face_adjacency
         return self._cache['face_adjacency_edges']
 
     @caching.cache_decorator
@@ -1458,7 +1446,7 @@ class Trimesh(Geometry3D):
         span : (len(self.face_adjacency), ) float
           Approximate span between the non-shared vertices
         """
-        _ = self.face_adjacency_radius
+        populate = self.face_adjacency_radius
         return self._cache['face_adjacency_span']
 
     @caching.cache_decorator
@@ -1541,7 +1529,7 @@ class Trimesh(Geometry3D):
         if self.is_empty:
             return False
         # consistent winding check is populated into the cache by is_watertight
-        _ = self.is_watertight
+        populate = self.is_watertight
         return self._cache['is_winding_consistent']
 
     @caching.cache_decorator
@@ -1727,7 +1715,7 @@ class Trimesh(Geometry3D):
         origins : (len(self.facets), 3) float
           A point on each facet plane
         """
-        _ = self.facets_normal
+        populate = self.facets_normal
         return self._cache['facets_origin']
 
     @caching.cache_decorator
@@ -2510,6 +2498,7 @@ class Trimesh(Geometry3D):
         """
         from .path import Path3D
         from .path.exchange.misc import faces_to_path
+        from .exchange.load import load_kwargs
         return Path3D(**faces_to_path(
             self, face_ids, **kwargs))
 
diff --git a/trimesh/exchange/export.py b/trimesh/exchange/export.py
index 1b0559a6..657f2615 100644
--- a/trimesh/exchange/export.py
+++ b/trimesh/exchange/export.py
@@ -1,5 +1,4 @@
 import os
-import sys
 import json
 import numpy as np
 
@@ -7,7 +6,6 @@ from ..constants import log
 from .. import util
 from .. import resolvers
 
-from .threemf import export_3MF
 from .urdf import export_urdf  # NOQA
 from .gltf import export_glb, export_gltf
 from .obj import export_obj
@@ -253,8 +251,6 @@ def export_scene(scene,
         data = export_ply(scene.dump(concatenate=True))
     elif file_type == 'stl':
         data = export_stl(scene.dump(concatenate=True))
-    elif file_type == '3mf':
-        data = export_3MF(scene, **kwargs)
     else:
         raise ValueError(
             'unsupported export format: {}'.format(file_type))
@@ -314,12 +310,8 @@ _mesh_exporters = {
     'gltf': export_gltf,
     'dict64': export_dict64,
     'msgpack': export_msgpack,
-    'stl_ascii': export_stl_ascii}
-
-# requires a newer `zipfile` module
-if sys.version_info >= (3, 6):
-    _mesh_exporters['3mf'] = export_3MF
-
+    'stl_ascii': export_stl_ascii
+}
 _mesh_exporters.update(_ply_exporters)
 _mesh_exporters.update(_off_exporters)
 _mesh_exporters.update(_collada_exporters)
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 058acf4a..a3129868 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -1170,46 +1170,6 @@ def _parse_materials(header, views, resolver=None):
     return materials
 
 
-def unique_name(start, contains):
-    """
-    Deterministically generate a unique name not
-    contained in a dict. Will create names of the
-    form "start_10" and increment accordingly.
-
-    Parameters
-    -----------
-    start : str
-      Initial guess for name
-    contains : dict, set, or list
-      Bundle of existing names we cannot use.
-
-    Returns
-    ---------
-    unique : str
-      A name that is not contained in `contains`
-    """
-    # exit early if name is not in bundle
-    if len(start) > 0 and start not in contains:
-        return start
-    increment = 0
-    if len(start) == 0:
-        formatter = '{}'
-    else:
-        # split by our delimiter once
-        split = start.rsplit('_', 1)
-        if len(split) == 2 and split[1].isnumeric():
-            # start incrementing from the passed value
-            increment = int(split[1])
-        # keep the original name and add an integer to it
-        formatter = split[0] + '_{}'
-    # if contains is empty we will only need to check once
-    for i in range(increment + 1, 1 + increment + len(contains)):
-        check = formatter.format(i)
-        if check not in contains:
-            return check
-    raise ValueError('unable to establish unique name!')
-
-
 def _read_buffers(header,
                   buffers,
                   mesh_kwargs,
@@ -1326,7 +1286,9 @@ def _read_buffers(header,
                 name = m.get('name', 'GLTF')
                 names_original[index].append(name)
                 # make name unique across multiple meshes
-                name = unique_name(name, meshes)
+                if name in meshes:
+                    name += "_" + util.unique_id(length=5)
+                    assert name not in meshes
                 if mode == _GL_LINES:
                     # load GL_LINES into a Path object
                     from ..path.entities import Line
@@ -1470,16 +1432,16 @@ def _read_buffers(header,
     nodes = header["nodes"]
     # nodes are referenced by index
     # save their string names if they have one
-    # we have to accumulate in a for loop opposed
-    # to a dict comprehension as it will be checking
-    # the mutated dict in every loop
-    name_index = {}
-    for i, n in enumerate(nodes):
-        name_index[unique_name(
-            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()}
+    names = {}
+    for i, n in enumerate(nodes):
+        if "name" in n:
+            if n["name"] in names.values():
+                names[i] = n["name"] + "_{}".format(util.unique_id())
+            else:
+                names[i] = n["name"]
+        else:
+            names[i] = str(i)
 
     # make sure we have a unique base frame name
     base_frame = "world"
diff --git a/trimesh/exchange/threemf.py b/trimesh/exchange/threemf.py
index cdb36067..2009e590 100644
--- a/trimesh/exchange/threemf.py
+++ b/trimesh/exchange/threemf.py
@@ -1,8 +1,3 @@
-import io
-import sys
-import uuid
-import zipfile
-
 import collections
 import numpy as np
 
@@ -214,223 +209,6 @@ def load_3MF(file_obj,
     return kwargs
 
 
-def export_3MF(mesh,
-               batch_size=4096,
-               compression=zipfile.ZIP_DEFLATED,
-               compresslevel=5):
-    """
-    Converts a Trimesh object into a 3MF file.
-
-    Parameters
-    ---------
-    mesh trimesh.trimesh
-      Mesh or Scene to export.
-    batch_size : int
-      Number of nodes to write per batch.
-    compression : zipfile.ZIP_*
-      Type of zip compression to use in this export.
-    compresslevel : int
-      For Python > 3.7 specify the 0-9 compression level.
-
-    Returns
-    ---------
-    export : bytes
-      Represents geometry as a 3MF file.
-    """
-
-    if sys.version_info < (3, 6):
-        # Python only added 'w' mode to `zipfile` in Python 3.6
-        # and it is not worth the effort to work around
-        raise NotImplementedError(
-            "3MF export requires Python >= 3.6")
-    from ..scene.scene import Scene
-
-    if not isinstance(mesh, Scene):
-        mesh = Scene(mesh)
-
-    geometry = mesh.geometry
-    graph = mesh.graph.to_networkx()
-    base_frame = mesh.graph.base_frame
-
-    # xml namespaces
-    model_nsmap = {
-        None: "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
-        "m": "http://schemas.microsoft.com/3dmanufacturing/material/2015/02",
-        "p": "http://schemas.microsoft.com/3dmanufacturing/production/2015/06",
-        "b": "http://schemas.microsoft.com/3dmanufacturing/beamlattice/2017/02",
-        "s": "http://schemas.microsoft.com/3dmanufacturing/slice/2015/07",
-        "sc": "http://schemas.microsoft.com/3dmanufacturing/securecontent/2019/04",
-    }
-
-    rels_nsmap = {
-        None: "http://schemas.openxmlformats.org/package/2006/relationships"
-    }
-
-    # model ids
-    def model_id(x, models=[]):
-        if x not in models:
-            models.append(x)
-        return str(models.index(x) + 1)
-
-    # 3mf archive dict {path: BytesIO}
-    file_obj = io.BytesIO()
-
-    # specify the parameters for the zip container
-    zip_kwargs = {'compression': compression}
-    # compresslevel was added in Python 3.7
-    if sys.version_info >= (3, 7):
-        zip_kwargs['compresslevel'] = compresslevel
-
-    with zipfile.ZipFile(file_obj, mode='w', **zip_kwargs) as z:
-        # 3dmodel.model
-        with z.open("3D/3dmodel.model", mode='w') as f, etree.xmlfile(
-                f, encoding="utf-8"
-        ) as xf:
-            xf.write_declaration()
-
-            # stream elements
-            with xf.element("model", {"unit": "millimeter"}, nsmap=model_nsmap):
-                # objects with mesh data and/or references to other objects
-                with xf.element("resources"):
-                    # stream objects with actual mesh data
-                    for i, (name, m) in enumerate(geometry.items()):
-                        # attributes for object
-                        attribs = {
-                            "id": model_id(name),
-                            "name": name,
-                            "type": "model",
-                            "p:UUID": str(uuid.uuid4())
-                        }
-                        with xf.element("object", **attribs):
-                            with xf.element("mesh"):
-                                with xf.element("vertices"):
-                                    # vertex nodes are writed directly to the file
-                                    # so make sure lxml's buffer is flushed
-                                    xf.flush()
-                                    for i in range(0, len(m.vertices), batch_size):
-                                        batch = m.vertices[i: i + batch_size]
-                                        fragment = (
-                                            '<vertex x="{}" y="{}" z="{}" />'
-                                            * len(batch)
-                                        )
-                                        f.write(
-                                            fragment.format(*batch.flatten()).encode(
-                                                "utf-8"
-                                            )
-                                        )
-                                with xf.element("triangles"):
-                                    xf.flush()
-                                    for i in range(0, len(m.faces), batch_size):
-                                        batch = m.faces[i: i + batch_size]
-                                        fragment = (
-                                            '<triangle v1="{}" v2="{}" v3="{}" />'
-                                            * len(batch)
-                                        )
-                                        f.write(
-                                            fragment.format(*batch.flatten()).encode(
-                                                "utf-8"
-                                            )
-                                        )
-
-                    # stream components
-                    for node in graph.nodes:
-                        if node == base_frame or node.startswith("camera"):
-                            continue
-                        if len(graph[node]) == 0:
-                            continue
-
-                        attribs = {
-                            "id": model_id(node),
-                            "name": node,
-                            "type": "model",
-                            "p:UUID": str(uuid.uuid4())
-                        }
-                        with xf.element("object", **attribs):
-                            with xf.element("components"):
-                                for next, data in graph[node].items():
-                                    transform = " ".join(
-                                        str(i)
-                                        for i in np.array(data["matrix"])[
-                                            :3, :4
-                                        ].T.flatten()
-                                    )
-                                    xf.write(
-                                        etree.Element(
-                                            "component",
-                                            {
-                                                "objectid": model_id(data["geometry"])
-                                                if "geometry" in data
-                                                else model_id(next),
-                                                "transform": transform,
-                                            },
-                                        )
-                                    )
-
-                # stream build (objects on base_frame)
-                with xf.element("build", {"p:UUID": str(uuid.uuid4())}):
-                    for node, data in graph[base_frame].items():
-                        if node.startswith("camera"):
-                            continue
-                        transform = " ".join(
-                            str(i) for i in np.array(data["matrix"])[:3, :4].T.flatten()
-                        )
-                        uuid_tag = "{{{}}}UUID".format(model_nsmap['p'])
-                        xf.write(
-                            etree.Element(
-                                "item",
-                                {
-                                    "objectid": model_id(node),
-                                    "transform": transform,
-                                    uuid_tag: str(uuid.uuid4())
-                                },
-                                nsmap=model_nsmap
-                            )
-                        )
-
-        # .rels
-        with z.open("_rels/.rels", "w") as f, etree.xmlfile(f, encoding="utf-8") as xf:
-            xf.write_declaration()
-            # stream elements
-            with xf.element("Relationships", nsmap=rels_nsmap):
-                rt = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
-                xf.write(
-                    etree.Element(
-                        "Relationship",
-                        Type=rt,
-                        Target="/3D/3dmodel.model",
-                        Id="rel0",
-                    )
-                )
-
-        # [Content_Types].xml
-        with z.open("[Content_Types].xml", "w") as f, etree.xmlfile(
-            f, encoding="utf-8"
-        ) as xf:
-            xf.write_declaration()
-            # xml namespaces
-            nsmap = {
-                None: "http://schemas.openxmlformats.org/package/2006/content-types"
-            }
-
-            # stream elements
-            types = [
-                ("jpeg", "image/jpeg"),
-                ("jpg", "image/jpeg"),
-                ("model", "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"),
-                ("png", "image/png"),
-                ("rels", "application/vnd.openxmlformats-package.relationships+xml"),
-                (
-                    "texture",
-                    "application/vnd.ms-package.3dmanufacturing-3dmodeltexture",
-                ),
-            ]
-            with xf.element("Types", nsmap=nsmap):
-                for ext, ctype in types:
-                    xf.write(etree.Element("Default", Extension=ext, ContentType=ctype))
-
-    return file_obj.getvalue()
-
-
 def _attrib_to_transform(attrib):
     """
     Extract a homogeneous transform from a dictionary.
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index a94e7ee1..19152350 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -21,11 +21,12 @@ except BaseException as E:
 
 
 def merge_vertices(mesh,
-                   merge_tex=None,
-                   merge_norm=None,
+                   merge_tex=False,
+                   merge_norm=False,
                    digits_vertex=None,
-                   digits_norm=None,
-                   digits_uv=None):
+                   digits_norm=2,
+                   digits_uv=4,
+                   **kwargs):
     """
     Removes duplicate vertices, grouped by position and
     optionally texture coordinate and normal.
@@ -50,18 +51,10 @@ def merge_vertices(mesh,
     # no vertices so exit early
     if len(mesh.vertices) == 0:
         return
-    if merge_tex is None:
-        merge_tex = False
-    if merge_norm is None:
-        merge_norm = False
-    if digits_norm is None:
-        digits_norm = 2
-    if digits_uv is None:
-        digits_uv = 4
-    if digits_vertex is None:
-        # use tol.merge if digit precision not passed
-        digits_vertex = util.decimal_to_digits(
-            tol.merge)
+
+    # use tol.merge if digit precision not passed
+    if not isinstance(digits_vertex, int):
+        digits_vertex = util.decimal_to_digits(tol.merge)
 
     # if we have a ton of unreferenced vertices it will
     # make the unique_rows call super slow so cull first
@@ -95,7 +88,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], keep_order=True)
+    u, i = unique_rows(stacked[referenced])
 
     # construct an inverse using the subset
     inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
@@ -259,7 +252,7 @@ def float_to_int(data, digits=None, dtype=np.int32):
     return as_int
 
 
-def unique_ordered(data, return_index=False, return_inverse=False):
+def unique_ordered(data):
     """
     Returns the same as np.unique, but ordered as per the
     first occurrence of the unique value in data.
@@ -274,31 +267,9 @@ def unique_ordered(data, return_index=False, return_inverse=False):
     In [3]: trimesh.grouping.unique_ordered(a)
     Out[3]: array([0, 3, 4, 1, 2])
     """
-    # uniques are the values, sorted
-    # index is the value in the original `data`
-    # i.e. `data[index] == unique`
-    # inverse is how to re-construct `data` from `unique`
-    # i.e. `unique[inverse] == data`
-    unique, index, inverse = np.unique(
-        data, return_index=True, return_inverse=True)
-
-    # we want to maintain the original index order
-    order = index.argsort()
-
-    if not return_index and not return_inverse:
-        return unique[order]
-
-    # collect return values
-    # start with the unique values in original order
-    result = [unique[order]]
-    # the new index values
-    if return_index:
-        # re-order the index in the original array
-        result.append(index[order])
-    if return_inverse:
-        # create the new inverse from the order of the order
-        result.append(order.argsort()[inverse])
-
+    data = np.asanyarray(data)
+    order = np.sort(np.unique(data, return_index=True)[1])
+    result = data[order]
     return result
 
 
@@ -431,7 +402,7 @@ def unique_float(data,
     return tuple(result)
 
 
-def unique_rows(data, digits=None, keep_order=False):
+def unique_rows(data, digits=None):
     """
     Returns indices of unique rows. It will return the
     first occurrence of a row that is duplicated:
@@ -452,18 +423,13 @@ def unique_rows(data, digits=None, keep_order=False):
       Array to reconstruct original
       Example: data[unique][inverse] == data
     """
-    # get rows hashable so we can run unique function on it
     rows = hashable_rows(data, digits=digits)
+    _, unique, inverse = np.unique(
+        rows,
+        return_index=True,
+        return_inverse=True)
 
-    # we are throwing away the first value which is the
-    # garbage row-hash and only returning index and inverse
-    if keep_order:
-        # keeps order of original occurance
-        return unique_ordered(
-            rows, return_index=True, return_inverse=True)[1:]
-    # returns values sorted by row-hash but since our row-hash
-    # were pretty much garbage the sort order isn't meaningful
-    return np.unique(rows, return_index=True, return_inverse=True)[1:]
+    return unique, inverse
 
 
 def unique_value_in_row(data, unique=None):
diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py
index 10c9b95d..0ddb42db 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -348,7 +348,6 @@ def _svg_path_convert(paths, force=None):
 def _entities_to_str(entities,
                      vertices,
                      name=None,
-                     digits=5,
                      only_layers=None):
     """
     Convert the entities of a path to path strings.
@@ -410,7 +409,7 @@ def _entities_to_str(entities,
         move_str = 'M ' + x_ex + ',' + y_ex
         return move_str
 
-    def svg_discrete(entity, digits=5, reverse=False):
+    def svg_discrete(entity, reverse=False):
         """
         Use an entities discrete representation to export a
         curve as a polyline
@@ -423,10 +422,9 @@ def _entities_to_str(entities,
         if reverse:
             discrete = discrete[::-1]
         # the format string for the SVG path
-        result = ('M{:0.{digits}} {:0.{digits}}' + (
-            'L{:0.{digits}} {:0.{digits}}' * (
-                len(discrete) - 1))).format(
-                    *discrete.reshape(-1), digits=digits)
+        result = ('M {:0.5f},{:0.5f} ' + (
+            ' L {:0.5f},{:0.5f}' * (len(discrete) - 1))).format(
+                *discrete.reshape(-1))
         return result
 
     # tuples of (metadata, path string)
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 00b1c18c..42edf930 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -672,7 +672,6 @@ def repair_invalid(polygon, scale=None, rtol=.5):
 def projected(mesh,
               normal,
               origin=None,
-              ignore_sign=True,
               rpad=1e-5,
               apad=None,
               tol_dot=0.01,
@@ -700,12 +699,6 @@ def projected(mesh,
       Normal to extract flat pattern along
     origin : None or (3,) float
       Origin of plane to project mesh onto
-    ignore_sign : bool
-      Allow a projection from the normal vector in
-      either direction: this provides a substantial speedup
-      on watertight meshes where the direction is irrelevant
-      but if you have a triangle soup and want to discard
-      backfaces you should set this to False.
     rpad : float
       Proportion to pad polygons by before unioning
       and then de-padding result by to avoid zero-width gaps.
@@ -735,8 +728,8 @@ def projected(mesh,
 
     # the projection of each face normal onto facet normal
     dot_face = np.dot(normal, mesh.face_normals.T)
-    if ignore_sign:
-        # for watertight mesh speed up projection by handling side with less faces
+    if mesh.is_watertight:
+        # for watertight mesh, speed up projection by handling side with less faces
         # check if face lies on front or back of normal
         front = dot_face > tol_dot
         back = dot_face < -tol_dot
@@ -755,8 +748,7 @@ def projected(mesh,
         # use the picked side
         side = [front, back][pick]
     else:
-        # if explicitly asked to care about the sign
-        # only handle the front side of normal
+        # for non-watertight mesh, only handle the front side of normal
         side = dot_face > tol_dot
 
     # subset the adjacency pairs to ones which have both faces included
@@ -810,21 +802,13 @@ def projected(mesh,
 
     # if there is only one region we don't need to run a union
     elif len(polygons) == 1:
-        return polygons[0]
+        polygon = polygons[0].buffer(padding).buffer(-padding)
     elif len(polygons) == 0:
         return None
     else:
         # inflate each polygon before unioning to remove zero-size
         # gaps then deflate the result after unioning by the same amount
-        # note the following provides a 25% speedup but needs
-        # more testing to see if it deflates to a decent looking
-        # result:
-        # polygon = ops.unary_union(
-        #    [p.buffer(padding,
-        #              join_style=2,
-        #              mitre_limit=1.5)
-        #     for p in polygons]).buffer(-padding)
         polygon = ops.unary_union(
-            [p.buffer(padding)
-             for p in polygons]).buffer(-padding)
+            [p.buffer(padding) for p in polygons]).buffer(-padding)
+
     return polygon
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index 01ddbf67..60ef7528 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -55,10 +55,6 @@ def segments_to_parameters(segments):
 
     # parametric start and end of line segment
     parameters = np.column_stack((offset, offset + vectors_norm))
-    # make sure signs are consistant
-    vectors, signs = util.vector_hemisphere(
-        vectors, return_sign=True)
-    parameters *= signs.reshape((-1, 1))
 
     return origins, vectors, parameters
 
@@ -113,7 +109,8 @@ def colinear_pairs(segments,
       differ and still be considered colinear
     length : None or float
       If specified, will additionally require
-      that pairs have a *vertex* within this distance.
+      that pairs have a mean vertex distance less
+      than this value from each other to qualify.
 
     Returns
     ------------
@@ -146,9 +143,13 @@ def colinear_pairs(segments,
 
     # if length is specified check endpoint proximity
     if length is not None:
-        a, b = param[colinear.T]
-        distance = np.abs(np.column_stack(
-            [a[:, :1] - b, a[:, 1:] - b])).min(axis=1)
+        # make sure parameter pairs are ordered
+        param.sort(axis=1)
+        # calculate the mean parameter distance for each colinear pair
+        distance = param[colinear].ptp(axis=1).mean(axis=1)
+        # if the MEAN distance is less than specified length consider
+        # the segment to be identical: worst case single- vertex
+        # distance is 2*length
         identical = distance < length
         # remove non- identical pairs
         colinear = colinear[identical]
@@ -544,7 +545,7 @@ def to_svg(segments, digits=4, matrix=None, merge=True):
         segments = unique(segments, digits=digits)
 
     # create the format string for a single line segment
-    base = 'M_ _L_ _'.replace(
+    base = ' M _ _ L _ _'.replace(
         '_', '{:0.' + str(int(digits)) + 'f}')
     # create one large format string then apply points
     result = (base * len(segments)).format(*segments.ravel())
diff --git a/trimesh/path/simplify.py b/trimesh/path/simplify.py
index 329be371..a6a58567 100644
--- a/trimesh/path/simplify.py
+++ b/trimesh/path/simplify.py
@@ -202,6 +202,7 @@ def merge_colinear(points, scale):
     # and direction vectors A-B, B-C, etc
     # these will be perpendicular to the vectors A-C, B-D, etc
     perp = (points[2:] - points[:-2]).T[::-1].T
+    perp[:, 0] *= -1
     perp_norm = util.row_norm(perp)
     perp_nonzero = perp_norm > tol.merge
     perp[perp_nonzero] /= perp_norm[perp_nonzero].reshape((-1, 1))
diff --git a/trimesh/ray/ray_pyembree.py b/trimesh/ray/ray_pyembree.py
index 5b9603c5..470cc337 100644
--- a/trimesh/ray/ray_pyembree.py
+++ b/trimesh/ray/ray_pyembree.py
@@ -144,13 +144,11 @@ class RayMeshIntersector(object):
           Intersection points, only returned if return_locations
         """
         # make sure input is _dtype for embree
-        ray_origins = np.array(
+        ray_origins = np.asanyarray(
             deepcopy(ray_origins),
             dtype=np.float64)
         ray_directions = np.asanyarray(ray_directions,
                                        dtype=np.float64)
-        if ray_origins.shape != ray_directions.shape:
-            raise ValueError('Ray origin and direction don\'t match!')
         ray_directions = util.unitize(ray_directions)
 
         # since we are constructing all hits, save them to a deque then
@@ -180,10 +178,10 @@ class RayMeshIntersector(object):
             # run the pyembree query
             # if you set output=1 it will calculate distance along
             # ray, which is bizzarely slower than our calculation
-
             query = self._scene.run(
                 ray_origins[current],
                 ray_directions[current])
+
             # basically we need to reduce the rays to the ones that hit
             # something
             hit = query != -1
diff --git a/trimesh/remesh.py b/trimesh/remesh.py
index 0d6f9919..1f9d0ba2 100644
--- a/trimesh/remesh.py
+++ b/trimesh/remesh.py
@@ -15,8 +15,7 @@ from .geometry import faces_to_edges
 def subdivide(vertices,
               faces,
               face_index=None,
-              vertex_attributes=None,
-              return_index=False):
+              vertex_attributes=None):
     """
     Subdivide a mesh into smaller triangles.
 
@@ -35,8 +34,6 @@ def subdivide(vertices,
       if (n,) int array of indices: only specified faces
     vertex_attributes : dict
       Contains (n, d) attribute data
-    return_index : bool
-      If True, return index of original face for new faces
 
     Returns
     ----------
@@ -44,9 +41,6 @@ def subdivide(vertices,
       Vertices in space
     new_faces : (p, 3) int
       Remeshed faces
-    index_dict : dict
-      Only returned if `return_index`, {index of
-      original face : index of new faces}.
     """
     if face_index is None:
         face_index = np.arange(len(faces))
@@ -97,11 +91,6 @@ def subdivide(vertices,
                 values, attr_mid))
         return new_vertices, new_faces, new_attributes
 
-    if return_index:
-        index_dict = {f: [f] + [len(faces) + 3 * fidx + i for i in range(3)]
-                      for fidx, f in enumerate(face_index)}
-        return new_vertices, new_faces, index_dict
-
     return new_vertices, new_faces
 
 
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index a94c7b0f..25e6f8e4 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -1,10 +1,9 @@
 import uuid
+import copy
 
 import numpy as np
 import collections
 
-from copy import deepcopy
-
 from .. import util
 from .. import caching
 from .. import transformations
@@ -168,11 +167,7 @@ class SceneGraph(object):
         copied : TransformForest
           Copy of current object.
         """
-        # create a copy without transfering cache
-        copied = SceneGraph()
-        copied.base_frame = deepcopy(self.base_frame)
-        copied.transforms = deepcopy(self.transforms)
-        return copied
+        return copy.deepcopy(self)
 
     def to_flattened(self):
         """
@@ -655,7 +650,7 @@ class EnforcedForest(object):
         nodes : set
           Every node currently stored.
         """
-        return self.node_data.keys()
+        return set(self.node_data.keys())
 
     @property
     def children(self):
diff --git a/trimesh/version.py b/trimesh/version.py
index 669870b1..59279112 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.11.2'
+__version__ = '3.10.8'
diff --git a/trimesh/voxel/creation.py b/trimesh/voxel/creation.py
index e232a4b9..fadcd68b 100644
--- a/trimesh/voxel/creation.py
+++ b/trimesh/voxel/creation.py
@@ -146,7 +146,7 @@ def local_voxelize(mesh,
     # Fill internal regions
     if fill:
         regions, n = ndimage.measurements.label(~voxels)
-        distance = ndimage.distance_transform_cdt(~voxels)
+        distance = ndimage.morphology.distance_transform_cdt(~voxels)
         representatives = [
             np.unravel_index((distance * (regions == i)).argmax(),
                              distance.shape) for i in range(1, n + 1)]
diff --git a/trimesh/voxel/morphology.py b/trimesh/voxel/morphology.py
index ca230ee9..b84afb3a 100644
--- a/trimesh/voxel/morphology.py
+++ b/trimesh/voxel/morphology.py
@@ -9,7 +9,7 @@ from .. import util
 
 
 try:
-    from scipy import ndimage as _m
+    from scipy.ndimage import morphology as _m
 except BaseException as E:
     # scipy is a soft dependency
     from ..exceptions import ExceptionModule


Test patch:
diff --git a/tests/test_3mf.py b/tests/test_3mf.py
index 0ad8bf8e..444c4fc4 100644
--- a/tests/test_3mf.py
+++ b/tests/test_3mf.py
@@ -45,26 +45,6 @@ class MFTest(g.unittest.TestCase):
         s = g.get_mesh('cube_and_sphere_same_name.3mf')
         assert(len(s.geometry) == 2)
 
-    def test_roundtrip(self):
-        if g.sys.version_info < (3, 6):
-            g.log.warning('relies on > Python 3.5')
-            return
-
-        # test a scene round-tripped through the
-        # 3MF exporter and importer
-        s = g.get_mesh('cycloidal.3DXML')
-        assert len(s.geometry) == 13
-
-        # export and reload
-        r = g.trimesh.load(
-            file_obj=g.trimesh.util.wrap_as_stream(
-                s.export(file_type='3mf')),
-            file_type='3mf')
-
-        assert set(s.geometry.keys()) == set(r.geometry.keys())
-        assert g.np.allclose(s.bounds, r.bounds)
-        assert g.np.isclose(s.area, r.area, rtol=1e-3)
-
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
index 2fbb0236..73329b88 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -288,37 +288,6 @@ 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 test_unique_ordered_rows(self):
-        # check the ordering of unique_rows
-        v = g.np.random.random((100000, 3))
-        v = g.np.vstack((v, v, v, v))
-
-        # index, inverse
-        i, iv = g.trimesh.grouping.unique_rows(
-            v, keep_order=True)
-
-        # get the unique values from the index
-        u = v[i]
-
-        # inverse of uniques should equal original array
-        assert g.np.allclose(u[iv], v)
-        # unique_ordered means indexes are in order
-        assert (i == g.np.sort(i)).all()
-
 
 def check_roll_wrap(**kwargs):
     """
diff --git a/tests/test_polygons.py b/tests/test_polygons.py
index 5992ed31..963aa8cc 100644
--- a/tests/test_polygons.py
+++ b/tests/test_polygons.py
@@ -37,8 +37,7 @@ class PolygonTests(g.unittest.TestCase):
         M = g.np.eye(3)
         M[0][2] = 10.0
         P2 = g.trimesh.path.polygons.transform_polygon(polygon[0], M)
-        distance = g.np.array(P2.centroid.coords)[
-            0] - g.np.array(polygon[0].centroid.coords)[0]
+        distance = g.np.array(P2.centroid) - g.np.array(polygon[0].centroid)
         assert g.np.allclose(distance, [10.0, 0])
 
     def test_random_polygon(self):
@@ -103,16 +102,11 @@ class PolygonTests(g.unittest.TestCase):
             vertices=[[0, 0, 0], [0, 1, 0], [1, 0, 0]],
             faces=[[0, 1, 2]])
 
-        # check ignore_sign argument
-        front = m.projected(m.face_normals[0], ignore_sign=False)
-        assert len(front.entities) == 1
-        front = m.projected(m.face_normals[0], ignore_sign=True)
+        front = m.projected(m.face_normals[0])
         assert len(front.entities) == 1
 
-        back = m.projected(-m.face_normals[0], ignore_sign=False)
+        back = m.projected(-m.face_normals[0])
         assert len(back.entities) == 0
-        back = m.projected(-m.face_normals[0], ignore_sign=True)
-        assert len(back.entities) == 1
 
     def test_project_multi(self):
         mesh = (g.trimesh.creation.box() +
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 323cc2e6..9d5f8db5 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -45,7 +45,7 @@ class GraphTests(g.unittest.TestCase):
 
         # world should be root frame
         assert (s.graph.transforms.successors(
-            s.graph.base_frame) == set(s.graph.nodes))
+            s.graph.base_frame) == s.graph.nodes)
 
         for n in s.graph.nodes:
             # successors should always return subset of nodes
@@ -67,8 +67,7 @@ class GraphTests(g.unittest.TestCase):
         # get a scene graph
         graph = g.get_mesh('cycloidal.3DXML').graph
         # get any non-root node
-        node = next(iter((set(graph.nodes).difference(
-            [graph.base_frame]))))
+        node = list(graph.nodes.difference(['world']))[0]
         # remove that node
         graph.transforms.remove_node(node)
         # should have dumped the cache and removed the node
@@ -124,7 +123,8 @@ class GraphTests(g.unittest.TestCase):
         ss = s.subscene('3')
 
         assert ss.graph.base_frame == '3'
-        assert set(ss.graph.nodes) == {'3', '4'}
+        assert len(ss.graph.nodes) == 2
+        assert ss.graph.nodes == {'3', '4'}
         assert len(ss.graph.transforms.node_data) == 2
         assert len(ss.graph.transforms.edge_data) == 1
         assert list(ss.graph.transforms.edge_data.keys()) == [('3', '4')]
diff --git a/tests/test_segments.py b/tests/test_segments.py
index b48fc56f..60c8f69e 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -34,57 +34,14 @@ class SegmentsTest(g.unittest.TestCase):
     def test_colinear(self):
         from trimesh.path import segments
 
-        seg = g.np.array([[[0., 0., 0.],
-                           [0., 1., 0.]],
-
-                          [[0., 1., 0.],
-                           [0., 0., 0.]],
-
-                          [[0., 0., 0.],
-                           [0., 2., 0.]],
-
-                          [[0., 0.5, 0.],
-                           [0., 0.75, 0.]],
-
-                          [[0., 2.1, 0.],
-                           [0., 2.2, 0.]],
-
-                          [[0., 2.0, 0.],
-                           [0., 2.3, 0.]],
-
-                          [[0., 0., 0.],
-                           [1., 1., 0.]]])
-
-        # get the unit direction vector for the segments
-        unit = g.trimesh.unitize(g.np.diff(
-            seg, axis=1).reshape((-1, 3)))
-
-        L = segments.colinear_pairs(seg[:3])
+        seg = g.np.column_stack((
+            g.np.zeros((3, 3)),
+            [[0, 1, 0], [0, 1, 0], [0, 2, 0]])).reshape((-1, 2, 3))
+        L = segments.colinear_pairs(seg)
         assert len(L) == 3
-        # make sure all pairs are really colinear
-        dots = [g.np.dot(*row) for row in unit[L]]
-        assert (g.np.isclose(dots, 1.0) |
-                g.np.isclose(dots, -1)).all()
 
-        L = segments.colinear_pairs(seg)
-        dots = [g.np.dot(*row) for row in unit[L]]
-        assert (g.np.isclose(dots, 1.0) |
-                g.np.isclose(dots, -1)).all()
-
-        epsilon = 1e-6
-        # length should only include vectors with one
-        # vertex closer than epsilon
-        n = segments.colinear_pairs(seg, length=epsilon)
-        dots = [g.np.dot(*row) for row in unit[L]]
-        assert (g.np.isclose(dots, 1.0) |
-                g.np.isclose(dots, -1)).all()
-
-        for pair in n:
-            val = seg[pair]
-            close = g.np.append(
-                (val[0] - val[1]).ptp(axis=1),
-                (val[0] - val[1][::-1]).ptp(axis=1)).min()
-            assert close < epsilon
+        n = segments.colinear_pairs(seg, length=0.01)
+        assert len(n) == 1
 
     def test_extrude(self):
         from trimesh.path.segments import extrude
diff --git a/tests/test_simplify.py b/tests/test_simplify.py
index 01eed496..8880b904 100644
--- a/tests/test_simplify.py
+++ b/tests/test_simplify.py
@@ -24,7 +24,7 @@ class SimplifyTest(g.unittest.TestCase):
             # the simplified version shouldn't have lost area
             assert g.np.allclose(path.area,
                                  simplified.area,
-                                 rtol=1e-3)
+                                 rtol=1e-2)
 
             # see if we fit as many arcs as existed in the original drawing
             new_count = sum(int(type(i).__name__ == 'Arc')
@@ -87,6 +87,17 @@ class SimplifyTest(g.unittest.TestCase):
         simple = path_2D.simplify_spline(smooth=0.01)
         assert g.np.isclose(path_2D.area, simple.area, rtol=.01)
 
+    def test_merge_colinear(self):
+        num = 100
+        dists = g.np.linspace(0, 1000, num=num)
+        direction = g.trimesh.unitize([1, g.np.random.rand()])
+        points = direction * dists.reshape((-1, 1))
+        merged = g.trimesh.path.simplify.merge_colinear(points, scale=1000)
+        print('direction:', direction)
+        print('points:', points.shape)
+        print('merged:', merged.shape)
+        assert merged.shape[0] == 2
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_stl.py b/tests/test_stl.py
index 51f68e01..a0f8c3c0 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -90,32 +90,6 @@ 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()