Repository: mikedh/trimesh
Pull request #756 (2020-03-25 20:44:52)

Packing And OFF Fixes
- Release #753 
- Remove untested graphtool support
- Pass kwargs to packing


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2dc95ee3..a125d55c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -124,7 +124,6 @@ jobs:
   containers:
     name: Build Docker Images
     runs-on: ubuntu-latest
-    needs: tests
     steps:
     - name: Configure AWS Credentials
       uses: aws-actions/configure-aws-credentials@v1
diff --git a/models/comments.off b/models/comments.off
index 79f64edf..19ab408b 100644
--- a/models/comments.off
+++ b/models/comments.off
@@ -7,7 +7,7 @@ OFF
 
 8 6 12
   1.632993   0.000000   1.154701
-  0.000000   1.632993   1.154701
+  0.000000   1.632993   1.154701 # see this vertex be weird
  -1.632993   0.000000   1.154701
   0.000000  -1.632993   1.154701
   1.632993   0.000000  -1.154701
diff --git a/models/whitespace.off b/models/whitespace.off
new file mode 100644
index 00000000..89d5e134
--- /dev/null
+++ b/models/whitespace.off
@@ -0,0 +1,15 @@
+	                                                                       OFF8 6 12 # or nah
+  1.632993   0.000000   1.154701
+  0.000000   1.632993   1.154701
+ -1.632993   0.000000   1.154701
+  0.000000  -1.632993   1.154701
+  1.632993   0.000000  -1.154701
+  0.000000   1.632993  -1.154701
+ -1.632993   0.000000  -1.154701
+  0.000000  -1.632993  -1.154701
+  4  0 1 2 3  1.000 0.000 0.000 0.75
+  4  7 4 0 3  0.300 0.400 0.000 0.75
+  4  4 5 1 0  0.200 0.500 0.100 0.75
+  4  5 6 2 1  0.100 0.600 0.200 0.75
+  4  3 2 6 7  0.000 0.700 0.300 0.75
+  4  6 5 4 7  0.000 1.000 0.000 0.75
diff --git a/trimesh/exchange/off.py b/trimesh/exchange/off.py
index 7c00fbcf..6787adbd 100644
--- a/trimesh/exchange/off.py
+++ b/trimesh/exchange/off.py
@@ -1,3 +1,4 @@
+import re
 import numpy as np
 
 from .. import util
@@ -20,18 +21,20 @@ def load_off(file_obj, **kwargs):
     """
     text = file_obj.read()
     # will magically survive weird encoding sometimes
-    text = util.decode_text(text).lstrip()
-    # split the first key
-    header, raw = text.split(None, 1)
+    # comment strip will handle all cases of commenting
+    text = util.comment_strip(
+        util.decode_text(text)).strip()
 
+    # split the first key
+    _, header, raw = re.split('(COFF|OFF)', text, 1)
     if header.upper() not in ['OFF', 'COFF']:
         raise NameError(
             'Not an OFF file! Header was: `{}`'.format(header))
 
     # split into lines and remove whitespace
     splits = [i.strip() for i in str.splitlines(str(raw))]
-    # remove empty lines and comments
-    splits = [i for i in splits if len(i) > 0 and i[0] != '#']
+    # remove empty lines
+    splits = [i for i in splits if len(i) > 0]
 
     # the first non-comment line should be the counts
     header = np.array(splits[0].split(), dtype=np.int64)
diff --git a/trimesh/graph.py b/trimesh/graph.py
index 9755fa46..f30f3770 100644
--- a/trimesh/graph.py
+++ b/trimesh/graph.py
@@ -2,11 +2,10 @@
 graph.py
 -------------
 
-Deal with graph operations. Primarily deal with graphs in (n,2)
+Deal with graph operations. Primarily deal with graphs in (n, 2)
 edge list form, and abstract the backend graph library being used.
 
-Currently uses networkx, scipy.sparse.csgraph, or graph_tool
-backends.
+Currently uses networkx or scipy.sparse.csgraph backend.
 """
 
 import numpy as np
@@ -19,16 +18,11 @@ from . import exceptions
 from .constants import log, tol
 from .geometry import faces_to_edges
 
-try:
-    from graph_tool import Graph as GTGraph
-    from graph_tool.topology import label_components
-except ImportError:
-    pass
-
 try:
     from scipy.sparse import csgraph, coo_matrix
-except ImportError as E:
-    csgraph = exceptions.closure(E)
+except BaseException as E:
+    # re-raise exception when used
+    csgraph = exceptions.ExceptionModule(E)
     coo_matrix = exceptions.closure(E)
 
 try:
@@ -43,7 +37,7 @@ def face_adjacency(faces=None,
                    mesh=None,
                    return_edges=False):
     """
-    Returns an (n,2) list of face indices.
+    Returns an (n, 2) list of face indices.
     Each pair of faces in the list shares an edge, making them adjacent.
 
 
@@ -234,7 +228,7 @@ def vertex_adjacency_graph(mesh):
     potentially for some simple smoothing techniques.
     >>> graph = mesh.vertex_adjacency_graph
     >>> graph.neighbors(0)
-    > [1,3,4]
+    > [1, 3, 4]
     """
     g = nx.Graph()
     g.add_edges_from(mesh.edges_unique)
@@ -273,7 +267,7 @@ def facets(mesh, engine=None):
     mesh :  trimesh.Trimesh
     engine : str
        Which graph engine to use:
-       ('scipy', 'networkx', 'graphtool')
+       ('scipy', 'networkx')
 
     Returns
     ---------
@@ -375,7 +369,7 @@ def connected_components(edges,
       Minimum length of a component group to return
     engine :  str or None
       Which graph engine to use (None for automatic):
-      (None, 'networkx', 'scipy', 'graphtool')
+      (None, 'networkx', 'scipy')
 
 
     Returns
@@ -399,30 +393,6 @@ def connected_components(edges,
              for i in iterable if len(i) >= min_len])
         return components
 
-    def components_graphtool():
-        """
-        Find connected components using graphtool
-        """
-        g = GTGraph()
-        # make sure all the nodes are in the graph
-        g.add_vertex(node_count)
-        # add the edge list
-        g.add_edge_list(edges)
-
-        labels = np.array(label_components(g, directed=False)[0].a,
-                          dtype=np.int64)[:node_count]
-
-        # we have to remove results that contain nodes outside
-        # of the specified node set and reindex
-        contained = np.zeros(node_count, dtype=np.bool)
-        contained[nodes] = True
-        index = np.arange(node_count, dtype=np.int64)[contained]
-
-        components = grouping.group(labels[contained], min_len=min_len)
-        components = np.array([index[c] for c in components])
-
-        return components
-
     def components_csgraph():
         """
         Find connected components using scipy.sparse.csgraph
@@ -458,7 +428,7 @@ def connected_components(edges,
             return np.array([])
 
     if not util.is_shape(edges, (-1, 2)):
-        raise ValueError('edges must be (n,2)!')
+        raise ValueError('edges must be (n, 2)!')
 
     # find the maximum index referenced in either nodes or edges
     counts = [0]
@@ -474,11 +444,8 @@ def connected_components(edges,
     edges_ok = mask[edges].all(axis=1)
     edges = edges[edges_ok]
 
-    # graphtool is usually faster then scipy by ~10%, however on very
-    # large or very small graphs graphtool outperforms scipy substantially
-    # networkx is pure python and is usually 5-10x slower
+    # networkx is pure python and is usually 5-10x slower than scipy
     engines = collections.OrderedDict((
-        ('graphtool', components_graphtool),
         ('scipy', components_csgraph),
         ('networkx', components_networkx)))
 
@@ -552,7 +519,7 @@ def split_traversal(traversal,
         edges_hash = grouping.hashable_rows(
             np.sort(edges, axis=1))
 
-    # turn the (n,) traversal into (n-1,2) edges
+    # turn the (n,) traversal into (n-1, 2) edges
     trav_edge = np.column_stack((traversal[:-1],
                                  traversal[1:]))
     # hash each edge so we can compare to edge set
@@ -638,7 +605,7 @@ def fill_traversals(traversals, edges, edges_hash=None):
             traversal=nodes,
             edges=edges,
             edges_hash=edges_hash))
-    # turn the split traversals back into (n,2) edges
+    # turn the split traversals back into (n, 2) edges
     included = util.vstack_empty([np.column_stack((i[:-1], i[1:]))
                                   for i in splits])
     if len(included) > 0:
@@ -679,7 +646,7 @@ def traversals(edges, mode='bfs'):
     if len(edges) == 0:
         return []
     elif not util.is_shape(edges, (-1, 2)):
-        raise ValueError('edges are not (n,2)!')
+        raise ValueError('edges are not (n, 2)!')
 
     # pick the traversal method
     mode = str(mode).lower().strip()
@@ -724,7 +691,7 @@ def edges_to_coo(edges, count=None, data=None):
 
     Parameters
     ------------
-    edges : (n,2) int
+    edges : (n, 2) int
       Edges of a graph
     count : int
       The total number of nodes in the graph
@@ -741,7 +708,7 @@ def edges_to_coo(edges, count=None, data=None):
     edges = np.asanyarray(edges, dtype=np.int64)
     if not (len(edges) == 0 or
             util.is_shape(edges, (-1, 2))):
-        raise ValueError('edges must be (n,2)!')
+        raise ValueError('edges must be (n, 2)!')
 
     # if count isn't specified just set it to largest
     # value referenced in edges
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index 0c7ef785..3372b513 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -175,7 +175,7 @@ def pack_rectangles(rectangles, sheet_size, shuffle=False):
     return density, offset[inserted], inserted, consumed_box
 
 
-def pack_paths(paths, sheet_size=None):
+def pack_paths(paths, **kwargs):
     """
     Pack a list of Path2D objects into a rectangle.
 
@@ -193,9 +193,6 @@ def pack_paths(paths, sheet_size=None):
     """
     from .util import concatenate
 
-    if sheet_size is not None:
-        sheet_size = np.sort(sheet_size)[::-1]
-
     quantity = []
     for path in paths:
         if 'quantity' in path.metadata:
@@ -209,13 +206,12 @@ def pack_paths(paths, sheet_size=None):
     # pack the polygons using rectangular bin packing
     inserted, transforms = multipack(polygons=polygons,
                                      quantity=quantity,
-                                     sheet_size=sheet_size)
+                                     **kwargs)
 
     multi = []
     for i, T in zip(inserted, transforms):
         multi.append(paths[i].copy())
         multi[-1].apply_transform(T)
-
     # append all packed paths into a single Path object
     packed = concatenate(multi)
 
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index f8c04984..221bec30 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -24,7 +24,7 @@ from .. import bounds
 from .. import caching
 from .. import grouping
 from .. import exceptions
-from .. import transformations
+from .. import transformations as tf
 
 from . import raster
 from . import repair
@@ -475,7 +475,7 @@ class Path(object):
         Parameters
         -----------
         transform : (d+1, d+1) float
-          Homogeneous transformation for vertices
+          Homogeneous transformations for vertices
         """
         dimension = self.vertices.shape[1]
         transform = np.asanyarray(transform, dtype=np.float64)
@@ -493,7 +493,7 @@ class Path(object):
         # apply transform to discretized paths
         if 'discrete' in self._cache.cache:
             cache['discrete'] = np.array([
-                transformations.transform_points(
+                tf.transform_points(
                     d, matrix=transform)
                 for d in self.discrete])
 
@@ -512,7 +512,7 @@ class Path(object):
                 cache[key] = self._cache.cache[key]
 
         # transform vertices in place
-        self.vertices = transformations.transform_points(
+        self.vertices = tf.transform_points(
             self.vertices,
             matrix=transform)
         # explicitly clear the cache
@@ -521,6 +521,7 @@ class Path(object):
 
         # populate the things we wangled
         self._cache.cache.update(cache)
+        return self
 
     def apply_scale(self, scale):
         """
@@ -534,7 +535,7 @@ class Path(object):
         dimension = self.vertices.shape[1]
         matrix = np.eye(dimension + 1)
         matrix[:dimension, :dimension] *= scale
-        self.apply_transform(matrix)
+        return self.apply_transform(matrix)
 
     def apply_translation(self, offset):
         """
@@ -555,7 +556,7 @@ class Path(object):
         # apply the offset
         matrix[:dimension, dimension] = offset
 
-        self.apply_transform(matrix)
+        return self.apply_transform(matrix)
 
     def apply_layer(self, name):
         """
@@ -577,28 +578,25 @@ class Path(object):
         Returns
         -----------
         matrix : (dimension + 1, dimension + 1) float
-          Homogeneous transformation that was applied
+          Homogeneous transformations that was applied
           to the current Path object.
         """
-        dimension = self.vertices.shape[1]
-        matrix = np.eye(dimension + 1)
-        matrix[:dimension, dimension] = -self.vertices.min(axis=0)
+        # transform to the lower left corner
+        matrix = tf.translation_matrix(-self.bounds[0])
+        # cleanly apply trransformation matrix
         self.apply_transform(matrix)
+
         return matrix
 
     def merge_vertices(self, digits=None):
         """
-        Merges vertices which are identical and replace references.
+        Merges vertices which are identical and replace references
+        by altering `self.entities` and `self.vertices`
 
         Parameters
         --------------
         digits : None, or int
           How many digits to consider when merging vertices
-
-        Alters
-        -----------
-        self.entities : entity.points re- referenced
-        self.vertices : duplicates removed
         """
         if len(self.vertices) == 0:
             return
@@ -904,7 +902,7 @@ class Path3D(Path):
         planar : trimesh.path.Path2D
                    Current path transformed onto plane
         to_3D :  (4,4) float
-                   Homeogenous transformation to move planar
+                   Homeogenous transformations to move planar
                    back into 3D space
         """
         # which vertices are actually referenced
@@ -937,8 +935,8 @@ class Path3D(Path):
             raise ValueError('unable to create transform!')
 
         # transform all vertices to 2D plane
-        flat = transformations.transform_points(self.vertices,
-                                                to_2D)
+        flat = tf.transform_points(self.vertices,
+                                   to_2D)
 
         # Z values of vertices which are referenced
         heights = flat[referenced][:, 2]
@@ -959,7 +957,7 @@ class Path3D(Path):
         # exactly Z=0 adjust it so the returned transform does
         if np.abs(height) > tol.planar:
             # adjust to_3D transform by height
-            adjust = transformations.translation_matrix(
+            adjust = tf.translation_matrix(
                 [0, 0, height])
             # apply the height adjustment to_3D
             to_3D = np.dot(to_3D, adjust)
@@ -1166,8 +1164,8 @@ class Path2D(Path):
         vertices = np.column_stack((copy.deepcopy(self.vertices),
                                     np.zeros(len(self.vertices))))
         if transform is not None:
-            vertices = transformations.transform_points(vertices,
-                                                        transform)
+            vertices = tf.transform_points(vertices,
+                                           transform)
         # make sure everything is deep copied
         path_3D = Path3D(entities=copy.deepcopy(self.entities),
                          vertices=vertices,
@@ -1395,7 +1393,7 @@ class Path2D(Path):
                                         smooth=smooth,
                                         verbose=verbose)
 
-    def split(self):
+    def split(self, **kwargs):
         """
         If the current Path2D consists of n 'root' curves,
         split them into a list of n Path2D objects
@@ -1434,7 +1432,9 @@ class Path2D(Path):
         Plot the entities of the path, with no notion of topology
         """
         import matplotlib.pyplot as plt
+        # keep plot axis scaled the same
         plt.axes().set_aspect('equal', 'datalim')
+        # hardcode a format for each entity type
         eformat = {'Line0': {'color': 'g', 'linewidth': 1},
                    'Line1': {'color': 'y', 'linewidth': 1},
                    'Arc0': {'color': 'r', 'linewidth': 1},
@@ -1444,12 +1444,16 @@ class Path2D(Path):
                    'BSpline0': {'color': 'm', 'linewidth': 1},
                    'BSpline1': {'color': 'm', 'linewidth': 1}}
         for entity in self.entities:
+            # if the entity has it's own plot method use it
             if annotations and hasattr(entity, 'plot'):
                 entity.plot(self.vertices)
                 continue
+            # otherwise plot the discrete curve
             discrete = entity.discrete(self.vertices)
+            # a unique key for entities
             e_key = entity.__class__.__name__ + str(int(entity.closed))
-            fmt = eformat[e_key]
+
+            fmt = eformat[e_key].copy()
             if color is not None:
                 # passed color will override other optons
                 fmt['color'] = color
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 65b51c5e..20055a5c 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -1108,7 +1108,7 @@ class Scene(Geometry):
         return result
 
 
-def split_scene(geometry):
+def split_scene(geometry, only_watertight=True):
     """
     Given a geometry, list of geometries, or a Scene
     return them as a single Scene object.
@@ -1140,7 +1140,7 @@ def split_scene(geometry):
     split = []
     metadata = {}
     for g in util.make_sequence(geometry):
-        split.extend(g.split())
+        split.extend(g.split(only_watertight=only_watertight))
         metadata.update(g.metadata)
 
     # if there is only one geometry in the mesh
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 43ac9135..af86e8e7 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -224,15 +224,20 @@ def identity_matrix():
 
 
 def translation_matrix(direction):
-    """Return matrix to translate by direction vector.
+    """
+    Return matrix to translate by direction vector.
 
     >>> v = np.random.random(3) - 0.5
     >>> np.allclose(v, translation_matrix(v)[:3, 3])
     True
 
     """
-    M = np.identity(4)
-    M[:3, 3] = direction[:3]
+    # are we 2D or 3D
+    dim = len(direction)
+    # start with identity matrix
+    M = np.identity(dim + 1)
+    # apply the offset
+    M[:dim, dim] = direction[:dim]
     return M
 
 
diff --git a/trimesh/util.py b/trimesh/util.py
index 46920f75..042e936c 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1256,7 +1256,7 @@ def comment_strip(text, starts_with='#', new_line='\n'):
     # take each comment up until the newline
     removed = [i.split(new_line, 1) for i in split]
     # add the leading string back on
-    result = lead + new_line.join(
+    result = lead + new_line + new_line.join(
         i[1] for i in removed
         if len(i) > 1 and len(i[1]) > 0)
     # strip leading and trailing whitespace
diff --git a/trimesh/version.py b/trimesh/version.py
index 8dde8f61..a530d0b7 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.6.15'
+__version__ = '3.6.16'


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4807232c..52fab889 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,7 +29,6 @@ jobs:
 
   tests:
     name: Run Unit Tests
-    needs: formatting
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
diff --git a/tests/test_off.py b/tests/test_off.py
index c3c07ab8..4a967502 100644
--- a/tests/test_off.py
+++ b/tests/test_off.py
@@ -10,9 +10,17 @@ except BaseException:
 class OFFTests(g.unittest.TestCase):
 
     def test_comment(self):
-        m = g.get_mesh('comments.off')
+        # see if we survive comments
+        m = g.get_mesh('comments.off', process=False)
+        assert m.is_volume
+        assert m.vertices.shape == (8, 3)
+        assert m.faces.shape == (12, 3)
 
+    def test_whitespace(self):
+        m = g.get_mesh('whitespace.off', process=False)
         assert m.is_volume
+        assert m.vertices.shape == (8, 3)
+        assert m.faces.shape == (12, 3)
 
 
 if __name__ == '__main__':
diff --git a/tests/test_util.py b/tests/test_util.py
index 9723743a..76efa2c2 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -333,6 +333,9 @@ class CommentTests(unittest.TestCase):
         text = '#naahah\nhey whats up\n hi#'
         assert f(text) == 'hey whats up\n hi'
 
+        text = 'hey whats up# see here\n hi#'
+        assert f(text) == 'hey whats up\n hi'
+
 
 if __name__ == '__main__':
     trimesh.util.attach_to_log()