Repository: mikedh/trimesh
Pull request #678 (2019-12-26 19:13:41)

SVG Segments, Python 3.8, Vertex Normal Merging
Changes:
- adds `trimesh.path.segments.to_svg`
- don't merge vertices with explicitly set normals that are different (supersedes / closes #677)
- adds python 3.8 to travis with `shapely.vectorized` suppressed until https://github.com/Toblerity/Shapely/issues/810




Patch:
diff --git a/.travis.yml b/.travis.yml
index 9e8f3ff1..bd8ee237 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,6 +10,7 @@ matrix:
     - env: PYTHON_VERSION=3.5
     - env: PYTHON_VERSION=3.6
     - env: PYTHON_VERSION=3.7
+    - env: PYTHON_VERSION=3.8
 
 before_deploy:
 - pip install -q -r docs/requirements.txt
diff --git a/trimesh/base.py b/trimesh/base.py
index d309124d..1bbf8a11 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -384,7 +384,7 @@ class Trimesh(Geometry):
         # if nothing passed exit
         if values is None:
             return
-        # make sure candidate face normals are C- contiguous float
+        # make sure candidate face normals are C-contiguous float
         values = np.asanyarray(
             values, order='C', dtype=np.float64)
         # face normals need to correspond to faces
@@ -570,7 +570,7 @@ class Trimesh(Geometry):
         The point in space which is the average of the triangle centroids
         weighted by the area of each triangle.
 
-        This will be valid even for non- watertight meshes,
+        This will be valid even for non-watertight meshes,
         unlike self.center_mass
 
         Returns
@@ -1077,23 +1077,34 @@ class Trimesh(Geometry):
         units._convert_units(self, desired, guess)
         return self
 
-    def merge_vertices(self, digits=None, textured=True):
+    def merge_vertices(self, **kwargs):
         """
         If a mesh has vertices that are closer than
         trimesh.constants.tol.merge reindex faces to reference
         the same index for both vertices.
 
         Parameters
-        --------------
-        digits : int
-          If specified overrides tol.merge
-        textured : bool
-          If True avoids merging vertices with different UV
-          coordinates. No effect on untextured meshes.
-        """
-        grouping.merge_vertices(self,
-                                digits=digits,
-                                textured=textured)
+        -------------
+        mesh : Trimesh object
+          Mesh to merge vertices on
+        use_tex : bool
+          If True for textured meshes merge vertices
+          with identical positions AND UV coordinates.
+        use_norm : bool
+          If True meshes with vertex normals defined will
+          only have vertices merged with identical normal
+        digits_vertex : None or int
+          Number of digits to consider for vertex position
+        digits_norm : int
+          Number of digits to consider for unit normals
+        digits_uv : int
+          Number of digits to consider for UV coordinates
+        """
+        if 'textured' in kwargs:
+            kwargs['use_tex'] = kwargs.pop('textured')
+            log.warning(
+                'merge_vertices depreciation: `textured`->`use_tex`')
+        grouping.merge_vertices(self, **kwargs)
 
     def update_vertices(self, mask, inverse=None):
         """
@@ -1129,7 +1140,7 @@ class Trimesh(Geometry):
             else:
                 inverse = None
 
-        # re- index faces from inverse
+        # re-index faces from inverse
         if inverse is not None and util.is_shape(self.faces, (-1, 3)):
             self.faces = inverse[self.faces.reshape(-1)].reshape((-1, 3))
 
@@ -1146,7 +1157,6 @@ class Trimesh(Geometry):
                 if len(value) != count:
                     raise TypeError()
             except TypeError:
-
                 continue
             # apply the mask to the attribute
             self.vertex_attributes[key] = value[mask]
@@ -1367,7 +1377,7 @@ class Trimesh(Geometry):
     @caching.cache_decorator
     def face_adjacency_projections(self):
         """
-        The projection of the non- shared vertex of a triangle onto
+        The projection of the non-shared vertex of a triangle onto
         its adjacent face
 
         Returns
@@ -1427,14 +1437,14 @@ class Trimesh(Geometry):
     @caching.cache_decorator
     def face_adjacency_span(self):
         """
-        The approximate perpendicular projection of the non- shared
+        The approximate perpendicular projection of the non-shared
         vertices in a pair of adjacent faces onto the shared edge of
         the two faces.
 
         Returns
         ------------
         span : (len(self.face_adjacency), ) float
-          Approximate span between the non- shared vertices
+          Approximate span between the non-shared vertices
         """
         populate = self.face_adjacency_radius
         return self._cache['face_adjacency_span']
@@ -2356,10 +2366,10 @@ class Trimesh(Geometry):
                                          center_mass=self._center_mass,
                                          skip_inertia=False)
 
-        # if magical clean- up mode is enabled
+        # if magical clean-up mode is enabled
         # and mesh is watertight/wound correctly but with negative
         # volume it means that every triangle is probably facing
-        # inwards, so we invert it in- place without dumping cache
+        # inwards, so we invert it in-place without dumping cache
         if (self._validate and
             self.is_watertight and
             self.is_winding_consistent and
@@ -2377,7 +2387,7 @@ class Trimesh(Geometry):
 
     def invert(self):
         """
-        Invert the mesh in- place by reversing the winding of every
+        Invert the mesh in-place by reversing the winding of every
         face and negating normals without dumping the cache.
 
         Alters
@@ -2617,7 +2627,7 @@ class Trimesh(Geometry):
         """
         Given an array of points determine whether or not they
         are inside the mesh. This raises an error if called on a
-        non- watertight mesh.
+        non-watertight mesh.
 
         Parameters
         ------------
@@ -2630,7 +2640,7 @@ class Trimesh(Geometry):
           Whether or not each point is inside the mesh
         """
         if not self.is_watertight:
-            log.warning('Mesh is non- watertight for contained point query!')
+            log.warning('Mesh is non-watertight for contained point query!')
         contains = self.ray.contains_points(points)
         return contains
 
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index b2ce304b..dff28ae4 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -21,9 +21,11 @@ except BaseException as E:
 
 
 def merge_vertices(mesh,
-                   digits=None,
-                   textured=True,
-                   uv_digits=4):
+                   use_tex=True,
+                   use_norm=True,
+                   digits_vertex=None,
+                   digits_norm=2,
+                   digits_uv=4):
     """
     Removes duplicate vertices based on integer hashes of
     each row.
@@ -32,59 +34,63 @@ def merge_vertices(mesh,
     -------------
     mesh : Trimesh object
       Mesh to merge vertices on
-    digits : int
-      How many digits to consider for vertices
-      If not specified uses tol.merge
-    textured : bool
-      If True, for textured meshes only merge vertices
+    use_tex : bool
+      If True for textured meshes merge vertices
       with identical positions AND UV coordinates.
-      No effect on untextured meshes
-    uv_digits : int
-      Number of digits to consider for UV coordinates.
-    """
+    use_norm : bool
+      If True meshes with vertex normals defined will
+      only have vertices merged with identical normal
+    digits_vertex : None or int
+      Number of digits to consider for vertex position
+    digits_norm : int
+      Number of digits to consider for unit normals
+    digits_uv : int
+      Number of digits to consider for UV coordinates
+    """
+    # 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
+    if hasattr(mesh, 'faces') and len(mesh.faces) > 0:
+        referenced = np.zeros(len(mesh.vertices), dtype=np.bool)
+        referenced[mesh.faces] = True
+    else:
+        # this is used for geometry without faces
+        referenced = np.ones(len(mesh.vertices), dtype=np.bool)
 
-    if not isinstance(digits, int):
-        digits = util.decimal_to_digits(tol.merge)
+    # collect vertex attributes into sequence
+    stacked = [mesh.vertices * (10 ** digits_vertex)]
 
     # UV texture visuals require us to update the
     # vertices and normals differently
-    if (textured and
+    if (use_tex and
         mesh.visual.defined and
         mesh.visual.kind == 'texture' and
             mesh.visual.uv is not None):
         # get an array with vertices and UV coordinates
         # converted to integers at requested precision
-        stacked = np.column_stack((
-            mesh.vertices * (10 ** digits),
-            mesh.visual.uv * (10 ** uv_digits))).round().astype(np.int64)
-        # Merge vertices with identical positions and UVs
-        # we don't merge vertices just based on position
-        # because that can corrupt textures at seams.
-        unique, inverse = unique_rows(stacked)
-        mesh.update_vertices(unique, inverse)
-
-    # In normal usage, just merge vertices that are close.
-    else:
-        # if we have a ton of unreferenced vertices it will
-        # make the unique_rows call super slow so cull first
-        if hasattr(mesh, 'faces') and len(mesh.faces) > 0:
-            referenced = np.zeros(len(mesh.vertices), dtype=np.bool)
-            referenced[mesh.faces] = True
-        else:
-            # this is used for PointCloud objects
-            referenced = np.ones(len(mesh.vertices), dtype=np.bool)
+        stacked.append(mesh.visual.uv * (10 ** digits_uv))
 
-        # check unique rows of referenced vertices
-        u, i = unique_rows(mesh.vertices[referenced],
-                           digits=digits)
+    # check to see if we have vertex normals
+    normals = mesh._cache['vertex_normals']
+    if use_norm and np.shape(normals) == np.shape(mesh.vertices):
+        stacked.append(normals * (10 ** digits_norm))
 
-        # construct an inverse using the subset
-        inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
-        inverse[referenced] = i
-        # get the vertex mask
-        mask = np.nonzero(referenced)[0][u]
-        # run the update
-        mesh.update_vertices(mask=mask, inverse=inverse)
+    # stack collected vertex properties and round to int
+    stacked = np.column_stack(stacked).round().astype(np.int64)
+
+    # check unique rows of referenced vertices
+    u, i = unique_rows(stacked[referenced])
+
+    # construct an inverse using the subset
+    inverse = np.zeros(len(mesh.vertices), dtype=np.int64)
+    inverse[referenced] = i
+    # get the vertex mask
+    mask = np.nonzero(referenced)[0][u]
+    # run the update including normals and UV coordinates
+    mesh.update_vertices(mask=mask, inverse=inverse)
 
 
 def group(values, min_len=0, max_len=np.inf):
@@ -93,16 +99,20 @@ def group(values, min_len=0, max_len=np.inf):
 
     Parameters
     ----------
-    values:     1D array
-    min_len:    int, the shortest group allowed
-                All groups will have len >= min_length
-    max_len:    int, the longest group allowed
-                All groups will have len <= max_length
+    values : (n,) int
+      Values to group
+    min_len : int
+      The shortest group allowed
+      All groups will have len >= min_length
+    max_len : int
+      The longest group allowed
+      All groups will have len <= max_length
 
     Returns
     ----------
-    groups: sequence of indices to form groups
-            IE [0,1,0,1] returns [[0,2], [1,3]]
+    groups : sequence
+      Contains indices to form groups
+      IE [0,1,0,1] returns [[0,2], [1,3]]
     """
     original = np.asanyarray(values)
 
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 4b7cc18f..d77222cc 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -1,7 +1,5 @@
 import numpy as np
 
-
-from shapely import vectorized
 from shapely.geometry import Polygon
 
 from rtree import Rtree
@@ -331,9 +329,11 @@ def medial_axis(polygon,
       Vertex positions in space
     """
     from scipy.spatial import Voronoi
+    from shapely import vectorized
 
     if resolution is None:
-        resolution = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).max() / 100
+        resolution = np.reshape(
+            polygon.bounds, (2, 2)).ptp(axis=0).max() / 100
 
     # get evenly spaced points on the polygons boundaries
     samples = resample_boundaries(polygon=polygon,
@@ -468,23 +468,25 @@ def sample(polygon, count, factor=1.5, max_iter=10):
     Parameters
     -----------
     polygon : shapely.geometry.Polygon
-                Polygon that will contain points
-    count   : int
-                Number of points to return
-    factor  : float
-                How many points to test per loop
-                IE, count * factor
-    max_iter : int,
-                Maximum number of intersection loops
-                to run, total points sampled is
-                count * factor * max_iter
+      Polygon that will contain points
+    count : int
+      Number of points to return
+    factor : float
+      How many points to test per loop
+    max_iter : int
+      Maximum number of intersection checks is:
+      > count * factor * max_iter
 
     Returns
     -----------
     hit : (n, 2) float
-           Random points inside polygon
-           where n <= count
+      Random points inside polygon
+      where n <= count
     """
+    # do batch point-in-polygon queries
+    from shapely import vectorized
+
+    # get size of bounding box
     bounds = np.reshape(polygon.bounds, (2, 2))
     extents = bounds.ptp(axis=0)
 
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index b1ef6109..d7c36e73 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -225,7 +225,7 @@ def split(segments, points, atol=1e-5):
 
 def unique(segments, digits=5):
     """
-    Find unique line segments.
+    Find unique non-zero line segments.
 
     Parameters
     ------------
@@ -245,13 +245,16 @@ def unique(segments, digits=5):
     inverse = grouping.unique_rows(
         segments.reshape((-1, segments.shape[2])),
         digits=digits)[1].reshape((-1, 2))
-
     # make sure rows are sorted
     inverse.sort(axis=1)
-    # find rows that occur once
-    index = grouping.unique_rows(inverse)
+    # remove segments where both indexes are the same
+    mask = np.zeros(len(segments), dtype=np.bool)
+    # only include the first occurance of a segment
+    mask[grouping.unique_rows(inverse)[0]] = True
+    # remove segments that are zero-length
+    mask[inverse[:, 0] == inverse[:, 1]] = False
     # apply the unique mask
-    unique = segments[index[0]]
+    unique = segments[mask]
 
     return unique
 
@@ -504,3 +507,44 @@ def resample(segments,
     if len(result) == 1:
         return result[0]
     return result
+
+
+def to_svg(segments, digits=4, scale=None, origin=None):
+    """
+    Convert (n, 2, 2) line segments to an SVG path string.
+
+    Parameters
+    ------------
+    segments : (n, 2, 2) float
+      Line segments to convert
+    digits : int
+      Number of digits to include in SVG string
+    scale : None or float
+      Scale to apply before exporting
+    origin : None or (2,) float
+      Origin to translate before scaling
+
+    Returns
+    -----------
+    path : str
+      SVG path string with one line per segment
+      IE: 'M 0.1 0.2 L 10 12'
+    """
+    segments = np.asanyarray(segments)
+    if not util.is_shape(segments, (-1, 2, 2)):
+        raise ValueError('only for (n, 2, 2) segments!')
+
+    # create the array to export
+    flat = segments.copy().reshape((-1, 2))
+    # apply origin if passed
+    if origin is not None:
+        flat -= np.array(origin).reshape(2)
+    # then apply scale if passed
+    if scale is not None:
+        flat *= float(scale)
+    # create the base format for a single line segment
+    base = ' M _ _ L _ _'.replace(
+        '_', '{:0.' + str(int(digits)) + 'f}')
+    # create one large format string then apply points
+    result = (base * len(segments))[1:].format(*flat.ravel())
+    return result
diff --git a/trimesh/util.py b/trimesh/util.py
index 6e837461..e57fa124 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -2147,18 +2147,19 @@ class TemporaryDirectory(object):
         shutil.rmtree(self.path)
 
 
-def decode_text(text):
+def decode_text(text, initial='utf-8'):
     """
     Try to decode byte input as a string.
 
-    Initially guesses that text is UTF-8, and then if
-    that fails it tries to detect the encoding and
-    try once more.
+    Tries initial guess (UTF-8) then if that fails it
+    uses chardet to try another guess before failing.
 
     Parameters
     ------------
     text : bytes
       Data that might be a string
+    initial : str
+      Initial guess for text encoding.
 
     Returns
     ------------
@@ -2171,15 +2172,18 @@ def decode_text(text):
 
     try:
         # initially guess file is UTF-8
-        text = text.decode('utf-8')
+        text = text.decode(initial)
     except UnicodeDecodeError:
         # detect different file encodings
         import chardet
         # try to detect the encoding of the file
         detect = chardet.detect(text)
         # warn on files that aren't UTF-8
-        log.warning('data not UTF-8, possibly: {} confidence {}'.format(
-            detect['encoding'], detect['confidence']))
+        log.warning(
+            'Data not {}! Trying {} (confidence {})'.format(
+                initial,
+                detect['encoding'],
+                detect['confidence']))
         # try to decode again, unwrapped in try
         text = text.decode(detect['encoding'])
     return text
diff --git a/trimesh/version.py b/trimesh/version.py
index f59ba749..aa89269e 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.5.6'
+__version__ = '3.5.7'


Test patch:
diff --git a/tests/test_normals.py b/tests/test_normals.py
index 212e9a4a..8d70fbbe 100644
--- a/tests/test_normals.py
+++ b/tests/test_normals.py
@@ -35,7 +35,9 @@ class NormalsTest(g.unittest.TestCase):
 
     def test_weighted_vertex_normals(self):
 
-        def compare_trimesh_to_groundtruth(mesh, truth, atol=g.trimesh.tol.merge):
+        def compare_trimesh_to_groundtruth(
+                mesh,
+                truth, atol=g.trimesh.tol.merge):
             # force fallback to loop normal summing by passing None
             # as the sparse matrix
             normals = g.trimesh.geometry.weighted_vertex_normals(
@@ -136,6 +138,32 @@ class NormalsTest(g.unittest.TestCase):
         assert g.np.allclose(
             g.np.linalg.norm(mesh.face_normals, axis=1), 1.0)
 
+    def test_merge(self):
+        """
+        Check merging with vertex normals
+        """
+        # no vertex merging
+        m = g.get_mesh(
+            'cube_compressed.obj',
+            process=False)
+        assert m.vertices.shape == (24, 3)
+        assert m.faces.shape == (12, 3)
+        assert g.np.isclose(m.volume, 8.0, atol=1e-4)
+
+        # with normal-aware vertex merging
+        m = g.get_mesh(
+            'cube_compressed.obj',
+            process=True)
+        assert m.vertices.shape == (24, 3)
+        assert m.faces.shape == (12, 3)
+        assert g.np.isclose(m.volume, 8.0, atol=1e-4)
+
+        # without considering normals should just be cube
+        m.merge_vertices(use_norm=False)
+        assert m.vertices.shape == (8, 3)
+        assert m.faces.shape == (12, 3)
+        assert g.np.isclose(m.volume, 8.0, atol=1e-4)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_paths.py b/tests/test_paths.py
index afd30013..7b33cdf0 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -7,6 +7,14 @@ except BaseException:
 class VectorTests(g.unittest.TestCase):
 
     def test_discrete(self):
+        try:
+            # TODO : REMOVE THIS WHEN SHAPELY WORKS IN 3.8
+            from shapely import vectorized  # noqa
+            vec_ok = True
+        except BaseException:
+            vec_ok = False
+            g.log.error(
+                'no shapely.vectorized!', exc_info=True)
 
         for d in g.get_2D():
             # store md5 before requesting passive functions
@@ -100,7 +108,7 @@ class VectorTests(g.unittest.TestCase):
             # Y should not have moved
             assert g.np.allclose(d.bounds[:, 1], ori[:, 1])
 
-            if len(d.polygons_full) > 0 and len(d.vertices) < 150:
+            if vec_ok and len(d.polygons_full) > 0 and len(d.vertices) < 150:
                 g.log.info('Checking medial axis on %s',
                            d.metadata['file_name'])
                 m = d.medial_axis()
@@ -231,6 +239,14 @@ class VectorTests(g.unittest.TestCase):
         """
         Test random sampling of polygons
         """
+        try:
+            # TODO : REMOVE THIS
+            from shapely import vectorized  # noqa
+        except BaseException:
+            g.log.error(
+                'no shapely.vectorized!', exc_info=True)
+            return
+
         p = g.Point([0, 0]).buffer(1.0)
         count = 100
 
diff --git a/tests/test_segments.py b/tests/test_segments.py
index a2520d5c..c8b75d1a 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -82,6 +82,22 @@ class SegmentsTest(g.unittest.TestCase):
         # make sure overall length hasn't changed
         assert g.np.isclose(length(res), length(seg))
 
+    def test_svg(self):
+        from trimesh.path.segments import to_svg
+        # create some 2D segments
+        seg = g.random((1000, 2, 2))
+        # create an SVG path string
+        svg = to_svg(seg)
+        # should be one move and one line per segment
+        assert svg.count('M') == len(seg)
+        assert svg.count('L') == len(seg)
+
+        try:
+            to_svg(g.random((100, 2, 3)))
+        except ValueError:
+            return
+        raise ValueError('to_svg accepted wrong input!')
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()