Repository: mikedh/trimesh
Pull request #1560 (2022-04-15 18:04:53)

Release: Vertex Order + 3MF Exporter
- release #1558 
- release #1546 
- release #1552 
- release #1553 
- Remove some whitespace from SVG exports
- Preserve vertex order by default. 
 


Patch:
diff --git a/trimesh/__init__.py b/trimesh/__init__.py
index e97f2ae3..09a50990 100644
--- a/trimesh/__init__.py
+++ b/trimesh/__init__.py
@@ -33,6 +33,10 @@ 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:
@@ -51,6 +55,10 @@ __all__ = [__version__,
            'Scene',
            'voxel',
            'unitize',
+           'bounds',
+           'nsphere',
+           'collision',
+           'smoothing',
            'tol',
            'path',
            'load',
diff --git a/trimesh/base.py b/trimesh/base.py
index 80a65798..bcea26db 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1,4 +1,3 @@
-# flake8: noqa
 """
 github.com/mikedh/trimesh
 ----------------------------
@@ -15,19 +14,16 @@ 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
+from . import smoothing  # noqa
 from . import comparison
 from . import registration
 from . import decomposition
@@ -59,6 +55,8 @@ class Trimesh(Geometry3D):
                  metadata=None,
                  process=True,
                  validate=False,
+                 merge_tex=None,
+                 merge_norm=None,
                  use_embree=True,
                  initial_cache=None,
                  visual=None,
@@ -189,12 +187,17 @@ 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, **kwargs)
+            self.process(validate=validate,
+                         merge_tex=merge_tex,
+                         merge_norm=merge_norm)
 
         # save reference to kwargs
         self._kwargs = kwargs
 
-    def process(self, validate=False, **kwargs):
+    def process(self,
+                validate=False,
+                merge_tex=None,
+                merge_norm=None):
         """
         Do processing to make a mesh useful.
 
@@ -202,8 +205,9 @@ class Trimesh(Geometry3D):
             1) removing NaN and Inf values
             2) merging duplicate vertices
         If validate:
-            3) Remove triangles which have one edge of their rectangular 2D
-               oriented bounding box shorter than tol.merge
+            3) Remove triangles which have one edge
+               of their 2D oriented bounding box
+               shorter than tol.merge
             4) remove duplicated triangles
             5) ensure triangles are consistently wound
                and normals face outwards
@@ -211,7 +215,7 @@ class Trimesh(Geometry3D):
         Parameters
         ------------
         validate : bool
-          If True, remove degenerate and duplicate faces
+          Remove degenerate and duplicate faces.
 
         Returns
         ------------
@@ -225,7 +229,8 @@ class Trimesh(Geometry3D):
         # avoid clearing the cache during operations
         with self._cache:
             self.remove_infinite_values()
-            self.merge_vertices(**kwargs)
+            self.merge_vertices(merge_tex=merge_tex,
+                                merge_norm=merge_norm)
             # if we're cleaning remove duplicate
             # and degenerate faces
             if validate:
@@ -534,9 +539,8 @@ class Trimesh(Geometry3D):
         if len(in_mesh) == 0:
             return None
         # get mesh bounds with min and max
-        mesh_bounds = np.array([in_mesh.min(axis=0),
-                                in_mesh.max(axis=0)])
-        return mesh_bounds
+        return np.array([in_mesh.min(axis=0),
+                         in_mesh.max(axis=0)])
 
     @caching.cache_decorator
     def extents(self):
@@ -722,7 +726,7 @@ class Trimesh(Geometry3D):
           Three vectors pointing along the
           principal axis of inertia directions
         """
-        populate = self.principal_inertia_components
+        _ = self.principal_inertia_components
         return self._cache['principal_inertia_vectors']
 
     @caching.cache_decorator
@@ -874,7 +878,7 @@ class Trimesh(Geometry3D):
         edges_face : (n, ) int
           Index of self.faces
         """
-        populate = self.edges
+        _ = self.edges
         return self._cache['edges_face']
 
     @caching.cache_decorator
@@ -923,7 +927,7 @@ class Trimesh(Geometry3D):
         inverse : (len(self.edges), ) int
           Indexes of self.edges_unique
         """
-        populate = self.edges_unique
+        _ = self.edges_unique
         return self._cache['edges_unique_inverse']
 
     @caching.cache_decorator
@@ -1017,7 +1021,7 @@ class Trimesh(Geometry3D):
                 [ 6946, 24225]]])
         """
         # make sure we have populated unique edges
-        populate = self.edges_unique
+        _ = 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
@@ -1088,9 +1092,15 @@ class Trimesh(Geometry3D):
         units._convert_units(self, desired, guess)
         return self
 
-    def merge_vertices(self, **kwargs):
+    def merge_vertices(
+            self,
+            merge_tex=None,
+            merge_norm=None,
+            digits_vertex=None,
+            digits_norm=None,
+            digits_uv=None):
         """
-        Removes duplicate vertices, grouped by position and
+        Removes duplicate vertices grouped by position and
         optionally texture coordinate and normal.
 
         Parameters
@@ -1110,11 +1120,13 @@ class Trimesh(Geometry3D):
         digits_uv : int
           Number of digits to consider for UV coordinates
         """
-        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)
+        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)
 
     def update_vertices(self, mask, inverse=None):
         """
@@ -1291,7 +1303,7 @@ class Trimesh(Geometry3D):
     @caching.cache_decorator
     def face_adjacency(self):
         """
-        Find faces that share an edge, which we call here 'adjacent'.
+        Find faces that share an edge i.e. 'adjacent' faces.
 
         Returns
         ----------
@@ -1340,7 +1352,7 @@ class Trimesh(Geometry3D):
            Vertex indices which correspond to face_adjacency
         """
         # this value is calculated as a byproduct of the face adjacency
-        populate = self.face_adjacency
+        _ = self.face_adjacency
         return self._cache['face_adjacency_edges']
 
     @caching.cache_decorator
@@ -1446,7 +1458,7 @@ class Trimesh(Geometry3D):
         span : (len(self.face_adjacency), ) float
           Approximate span between the non-shared vertices
         """
-        populate = self.face_adjacency_radius
+        _ = self.face_adjacency_radius
         return self._cache['face_adjacency_span']
 
     @caching.cache_decorator
@@ -1529,7 +1541,7 @@ class Trimesh(Geometry3D):
         if self.is_empty:
             return False
         # consistent winding check is populated into the cache by is_watertight
-        populate = self.is_watertight
+        _ = self.is_watertight
         return self._cache['is_winding_consistent']
 
     @caching.cache_decorator
@@ -1715,7 +1727,7 @@ class Trimesh(Geometry3D):
         origins : (len(self.facets), 3) float
           A point on each facet plane
         """
-        populate = self.facets_normal
+        _ = self.facets_normal
         return self._cache['facets_origin']
 
     @caching.cache_decorator
@@ -2498,7 +2510,6 @@ 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 657f2615..1b0559a6 100644
--- a/trimesh/exchange/export.py
+++ b/trimesh/exchange/export.py
@@ -1,4 +1,5 @@
 import os
+import sys
 import json
 import numpy as np
 
@@ -6,6 +7,7 @@ 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
@@ -251,6 +253,8 @@ 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))
@@ -310,8 +314,12 @@ _mesh_exporters = {
     'gltf': export_gltf,
     'dict64': export_dict64,
     'msgpack': export_msgpack,
-    'stl_ascii': export_stl_ascii
-}
+    'stl_ascii': export_stl_ascii}
+
+# requires a newer `zipfile` module
+if sys.version_info >= (3, 6):
+    _mesh_exporters['3mf'] = export_3MF
+
 _mesh_exporters.update(_ply_exporters)
 _mesh_exporters.update(_off_exporters)
 _mesh_exporters.update(_collada_exporters)
diff --git a/trimesh/exchange/threemf.py b/trimesh/exchange/threemf.py
index 2009e590..cdb36067 100644
--- a/trimesh/exchange/threemf.py
+++ b/trimesh/exchange/threemf.py
@@ -1,3 +1,8 @@
+import io
+import sys
+import uuid
+import zipfile
+
 import collections
 import numpy as np
 
@@ -209,6 +214,223 @@ 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 19152350..a94e7ee1 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -21,12 +21,11 @@ except BaseException as E:
 
 
 def merge_vertices(mesh,
-                   merge_tex=False,
-                   merge_norm=False,
+                   merge_tex=None,
+                   merge_norm=None,
                    digits_vertex=None,
-                   digits_norm=2,
-                   digits_uv=4,
-                   **kwargs):
+                   digits_norm=None,
+                   digits_uv=None):
     """
     Removes duplicate vertices, grouped by position and
     optionally texture coordinate and normal.
@@ -51,10 +50,18 @@ def merge_vertices(mesh,
     # no vertices so exit early
     if len(mesh.vertices) == 0:
         return
-
-    # use tol.merge if digit precision not passed
-    if not isinstance(digits_vertex, int):
-        digits_vertex = util.decimal_to_digits(tol.merge)
+    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)
 
     # if we have a ton of unreferenced vertices it will
     # make the unique_rows call super slow so cull first
@@ -88,7 +95,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=True)
 
     # construct an inverse using the subset
     inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
@@ -252,7 +259,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.
@@ -267,9 +274,31 @@ def unique_ordered(data):
     In [3]: trimesh.grouping.unique_ordered(a)
     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]
+    # 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])
+
     return result
 
 
@@ -402,7 +431,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:
@@ -423,13 +452,18 @@ def unique_rows(data, digits=None):
       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)
 
-    return unique, inverse
+    # 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:]
 
 
 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 0ddb42db..10c9b95d 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -348,6 +348,7 @@ 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.
@@ -409,7 +410,7 @@ def _entities_to_str(entities,
         move_str = 'M ' + x_ex + ',' + y_ex
         return move_str
 
-    def svg_discrete(entity, reverse=False):
+    def svg_discrete(entity, digits=5, reverse=False):
         """
         Use an entities discrete representation to export a
         curve as a polyline
@@ -422,9 +423,10 @@ def _entities_to_str(entities,
         if reverse:
             discrete = discrete[::-1]
         # the format string for the SVG path
-        result = ('M {:0.5f},{:0.5f} ' + (
-            ' L {:0.5f},{:0.5f}' * (len(discrete) - 1))).format(
-                *discrete.reshape(-1))
+        result = ('M{:0.{digits}} {:0.{digits}}' + (
+            'L{:0.{digits}} {:0.{digits}}' * (
+                len(discrete) - 1))).format(
+                    *discrete.reshape(-1), digits=digits)
         return result
 
     # tuples of (metadata, path string)
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 42edf930..00b1c18c 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -672,6 +672,7 @@ 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,
@@ -699,6 +700,12 @@ 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.
@@ -728,8 +735,8 @@ def projected(mesh,
 
     # the projection of each face normal onto facet normal
     dot_face = np.dot(normal, mesh.face_normals.T)
-    if mesh.is_watertight:
-        # for watertight mesh, speed up projection by handling side with less faces
+    if ignore_sign:
+        # 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
@@ -748,7 +755,8 @@ def projected(mesh,
         # use the picked side
         side = [front, back][pick]
     else:
-        # for non-watertight mesh, only handle the front side of normal
+        # if explicitly asked to care about the sign
+        # only handle the front side of normal
         side = dot_face > tol_dot
 
     # subset the adjacency pairs to ones which have both faces included
@@ -802,13 +810,21 @@ 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)
+        return polygons[0]
     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 60ef7528..01ddbf67 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -55,6 +55,10 @@ 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
 
@@ -109,8 +113,7 @@ def colinear_pairs(segments,
       differ and still be considered colinear
     length : None or float
       If specified, will additionally require
-      that pairs have a mean vertex distance less
-      than this value from each other to qualify.
+      that pairs have a *vertex* within this distance.
 
     Returns
     ------------
@@ -143,13 +146,9 @@ def colinear_pairs(segments,
 
     # if length is specified check endpoint proximity
     if length is not None:
-        # 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
+        a, b = param[colinear.T]
+        distance = np.abs(np.column_stack(
+            [a[:, :1] - b, a[:, 1:] - b])).min(axis=1)
         identical = distance < length
         # remove non- identical pairs
         colinear = colinear[identical]
@@ -545,7 +544,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/remesh.py b/trimesh/remesh.py
index 1f9d0ba2..0d6f9919 100644
--- a/trimesh/remesh.py
+++ b/trimesh/remesh.py
@@ -15,7 +15,8 @@ from .geometry import faces_to_edges
 def subdivide(vertices,
               faces,
               face_index=None,
-              vertex_attributes=None):
+              vertex_attributes=None,
+              return_index=False):
     """
     Subdivide a mesh into smaller triangles.
 
@@ -34,6 +35,8 @@ 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
     ----------
@@ -41,6 +44,9 @@ 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))
@@ -91,6 +97,11 @@ 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 25e6f8e4..a94c7b0f 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -1,9 +1,10 @@
 import uuid
-import copy
 
 import numpy as np
 import collections
 
+from copy import deepcopy
+
 from .. import util
 from .. import caching
 from .. import transformations
@@ -167,7 +168,11 @@ class SceneGraph(object):
         copied : TransformForest
           Copy of current object.
         """
-        return copy.deepcopy(self)
+        # create a copy without transfering cache
+        copied = SceneGraph()
+        copied.base_frame = deepcopy(self.base_frame)
+        copied.transforms = deepcopy(self.transforms)
+        return copied
 
     def to_flattened(self):
         """
@@ -650,7 +655,7 @@ class EnforcedForest(object):
         nodes : set
           Every node currently stored.
         """
-        return set(self.node_data.keys())
+        return self.node_data.keys()
 
     @property
     def children(self):
diff --git a/trimesh/version.py b/trimesh/version.py
index 59279112..b8b2efb8 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.10.8'
+__version__ = '3.11.0'
diff --git a/trimesh/voxel/creation.py b/trimesh/voxel/creation.py
index fadcd68b..e232a4b9 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.morphology.distance_transform_cdt(~voxels)
+        distance = ndimage.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 b84afb3a..ca230ee9 100644
--- a/trimesh/voxel/morphology.py
+++ b/trimesh/voxel/morphology.py
@@ -9,7 +9,7 @@ from .. import util
 
 
 try:
-    from scipy.ndimage import morphology as _m
+    from scipy import ndimage 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 444c4fc4..0ad8bf8e 100644
--- a/tests/test_3mf.py
+++ b/tests/test_3mf.py
@@ -45,6 +45,26 @@ 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 73329b88..2fbb0236 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -288,6 +288,37 @@ 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 963aa8cc..5992ed31 100644
--- a/tests/test_polygons.py
+++ b/tests/test_polygons.py
@@ -37,7 +37,8 @@ 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) - g.np.array(polygon[0].centroid)
+        distance = g.np.array(P2.centroid.coords)[
+            0] - g.np.array(polygon[0].centroid.coords)[0]
         assert g.np.allclose(distance, [10.0, 0])
 
     def test_random_polygon(self):
@@ -102,11 +103,16 @@ class PolygonTests(g.unittest.TestCase):
             vertices=[[0, 0, 0], [0, 1, 0], [1, 0, 0]],
             faces=[[0, 1, 2]])
 
-        front = m.projected(m.face_normals[0])
+        # 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)
         assert len(front.entities) == 1
 
-        back = m.projected(-m.face_normals[0])
+        back = m.projected(-m.face_normals[0], ignore_sign=False)
         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 9d5f8db5..0faaffad 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -67,7 +67,8 @@ class GraphTests(g.unittest.TestCase):
         # get a scene graph
         graph = g.get_mesh('cycloidal.3DXML').graph
         # get any non-root node
-        node = list(graph.nodes.difference(['world']))[0]
+        node = next(iter((set(graph.nodes).difference(
+            [graph.base_frame]))))
         # remove that node
         graph.transforms.remove_node(node)
         # should have dumped the cache and removed the node
diff --git a/tests/test_segments.py b/tests/test_segments.py
index 60c8f69e..b48fc56f 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -34,14 +34,57 @@ class SegmentsTest(g.unittest.TestCase):
     def test_colinear(self):
         from trimesh.path import segments
 
-        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)
+        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])
         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()
 
-        n = segments.colinear_pairs(seg, length=0.01)
-        assert len(n) == 1
+        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
 
     def test_extrude(self):
         from trimesh.path.segments import extrude
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()