Repository: mikedh/trimesh
Pull request #878 (2020-06-17 17:49:19)

Minor fix for stack_3D on empty arrays
Also adds a `has_module` helper to avoid importing collada always.


Patch:
diff --git a/setup.py b/setup.py
index 05c86980..1af982ce 100644
--- a/setup.py
+++ b/setup.py
@@ -50,6 +50,7 @@ requirements_easy = set([
 # through some mechanism on Linux with Python 3.5+
 # and are allowed to compile code
 requirements_all = requirements_easy.union([
+    'mapbox-earcut',  # faster 2D triangulations of polygons
     'triangle',      # 2D triangulations of polygons
     'python-fcl',    # do fast 3D collision queries
     'psutil',        # figure out how much memory we have
diff --git a/trimesh/base.py b/trimesh/base.py
index 395cc5ca..956354a6 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -2054,12 +2054,14 @@ class Trimesh(Geometry):
 
         # turn the line segments into Path2D objects
         paths = [None] * len(lines)
-        for index, L, T in zip(range(len(lines)),
-                               lines,
-                               transforms):
-            if len(L) > 0:
-                paths[index] = load_path(
-                    L, metadata={'to_3D': T})
+        for i, faces, segments, T in zip(range(len(lines)),
+                                         faces,
+                                         lines,
+                                         transforms):
+            if len(segments) > 0:
+                paths[i] = load_path(
+                    segments,
+                    metadata={'to_3D': T, 'face_index': faces})
         return paths
 
     def slice_plane(self,
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 9e1f0272..16c05d0a 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -413,6 +413,7 @@ def extrude_triangulation(vertices,
 
 def triangulate_polygon(polygon,
                         triangle_args=None,
+                        engine=None,
                         **kwargs):
     """
     Given a shapely polygon create a triangulation using a
@@ -433,6 +434,25 @@ def triangulate_polygon(polygon,
     faces : (n, 3) int
        Index of vertices that make up triangles
     """
+    if engine is None and util.has_module('mapbox_earcut'):
+        engine = 'earcut'
+
+    if engine == 'earcut':
+        from mapbox_earcut import triangulate_float64
+
+        # get vertices as sequence where exterior is the first value
+        vertices = [np.array(polygon.exterior)]
+        vertices.extend(np.array(i) for i in polygon.interiors)
+        # record the index from the length of each vertex array
+        rings = np.cumsum([len(v) for v in vertices])
+        # stack vertices into (n, 2) float array
+        vertices = np.vstack(vertices)
+        # run triangulation
+        faces = triangulate_float64(vertices, rings).reshape(
+            (-1, 3)).astype(np.int64).reshape((-1, 3))
+
+        return vertices, faces
+
     # do the import here for soft requirement
     from triangle import triangulate
     # set default triangulation arguments if not specified
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index 60c1b43b..1f48f67b 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -34,6 +34,8 @@ def load_collada(file_obj, resolver=None, **kwargs):
     loaded : list of dict
       kwargs for Trimesh constructor
     """
+    import collada
+
     # load scene using pycollada
     c = collada.Collada(file_obj)
 
@@ -76,6 +78,8 @@ def export_collada(mesh, **kwargs):
     -----------
     export: str, string of COLLADA format output
     """
+    import collada
+
     meshes = mesh
     if not isinstance(mesh, (list, tuple, set, np.ndarray)):
         meshes = [mesh]
@@ -152,6 +156,7 @@ def _parse_node(node,
     """
     Recursively parse COLLADA scene nodes.
     """
+    import collada
 
     # Parse mesh node
     if isinstance(node, collada.scene.GeometryNode):
@@ -263,6 +268,7 @@ def _parse_material(effect, resolver):
     """
     Turn a COLLADA effect into a trimesh material.
     """
+    import collada
 
     # Compute base color
     baseColorFactor = np.ones(4)
@@ -329,6 +335,8 @@ def _unparse_material(material):
     """
     Turn a trimesh material into a COLLADA material.
     """
+    import collada
+
     # TODO EXPORT TEXTURES
     if isinstance(material, visual.material.PBRMaterial):
         diffuse = material.baseColorFactor
@@ -402,7 +410,7 @@ def load_zae(file_obj, resolver=None, **kwargs):
 # only provide loaders if `pycollada` is installed
 _collada_loaders = {}
 _collada_exporters = {}
-if collada is not None:
+if util.has_module('collada'):
     _collada_loaders['dae'] = load_collada
     _collada_loaders['zae'] = load_zae
     _collada_exporters['dae'] = export_collada
diff --git a/trimesh/intersections.py b/trimesh/intersections.py
index 6d197368..b4afcfe7 100644
--- a/trimesh/intersections.py
+++ b/trimesh/intersections.py
@@ -273,7 +273,7 @@ def mesh_multiplane(mesh,
         # store (n, 2, 2) float lines
         segments.append(lines_2D)
         # store (n,) int indexes of mesh.faces
-        face_index.append(face_index)
+        face_index.append(index)
 
     # (n, 4, 4) transforms from 2D to 3D
     transforms = np.array(transforms, dtype=np.float64)
@@ -690,7 +690,7 @@ def slice_mesh_plane(mesh,
             v, f = [], []
             for polygon in on_plane.polygons_full:
                 t = triangulate_polygon(
-                    polygon, triangle_args='p', allow_boundary_steiner=False)
+                    polygon, triangle_args='p', engine='triangle')
                 v.append(t[0])
                 f.append(t[1])
 
diff --git a/trimesh/util.py b/trimesh/util.py
index 58fcbc83..f8dedd5b 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -69,6 +69,26 @@ TOL_MERGE = 1e-8
 _STRICT = False
 
 
+def has_module(name):
+    """
+    Check to see if a module is installed by name without
+    actually importing the module.
+
+    Parameters
+    ------------
+    name : str
+      The name of the module to check
+
+    Returns
+    ------------
+    installed : bool
+      True if module is installed
+    """
+    # this should work on Python 2.7 and 3.4+
+    import pkgutil
+    return pkgutil.find_loader(name) is not None
+
+
 def unitize(vectors,
             check_valid=False,
             threshold=None):
@@ -666,12 +686,13 @@ def stack_3D(points, return_2D=False):
     points = np.asanyarray(points, dtype=np.float64)
     shape = points.shape
 
-    if len(shape) != 2:
+    if shape == (0,):
+        is_2D = False
+    elif len(shape) != 2:
         raise ValueError('Points must be 2D array!')
-
-    if shape[1] == 2:
-        points = np.column_stack((points,
-                                  np.zeros(len(points))))
+    elif shape[1] == 2:
+        points = np.column_stack((
+            points, np.zeros(len(points))))
         is_2D = True
     elif shape[1] == 3:
         is_2D = False
diff --git a/trimesh/version.py b/trimesh/version.py
index 8c3336cc..368b1f26 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.7.0'
+__version__ = '3.7.2'


Test patch:
diff --git a/tests/test_creation.py b/tests/test_creation.py
index a00e3035..9e6b7a6c 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -8,11 +8,10 @@ class CreationTest(g.unittest.TestCase):
 
     def setUp(self):
         engines = []
-        try:
-            from triangle import triangulate  # NOQA
+        if g.trimesh.util.has_module('triangle'):
             engines.append('triangle')
-        except ImportError:
-            g.log.error('no triangle: skipping')
+        if g.trimesh.util.has_module('mapbox_earcut'):
+            engines.append('earcut')
         self.engines = engines
 
     def test_cone(self):
@@ -171,7 +170,7 @@ class CreationTest(g.unittest.TestCase):
 
     def test_triangulate(self):
         """
-        test triangulate using meshpy and triangle
+        Test triangulate using meshpy and triangle
         """
         # circles
         bigger = g.Point([10, 0]).buffer(1.0)
@@ -197,7 +196,6 @@ class CreationTest(g.unittest.TestCase):
             for poly in [bigger, smaller, donut, bench]:
                 v, f = g.trimesh.creation.triangulate_polygon(
                     poly, engine=engine)
-
                 # run asserts
                 check_triangulation(v, f, poly.area)
             try:
diff --git a/tests/test_section.py b/tests/test_section.py
index 646016da..db820fc8 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -111,6 +111,23 @@ class SectionTest(g.unittest.TestCase):
                 assert g.np.isclose(back_3D.vertices[:, 2].mean(),
                                     z_levels[index])
 
+    def test_multi_index(self):
+        # make sure returned face indexes on a section are correct
+        mesh = g.trimesh.creation.box()
+        normal = g.np.array([0, 0, 1])
+        # point for plane origin
+        origin = mesh.center_mass
+        # pick some offsets from the plane
+        heights = g.np.linspace(0, 0.1, 2)
+        # compute the multiple crosss sections with `section_multiplane`
+        multi = mesh.section_multiplane(origin, normal, heights)
+        # get the face indexes this should have hit by checking the normal
+        idx = set(g.np.nonzero(g.np.isclose(
+            g.np.dot(mesh.face_normals, normal), 0, atol=1e-4))[0])
+        # make sure all indexes are set correctly
+        assert all(set(m.metadata['face_index'] == idx)
+                   for m in multi)
+
 
 class PlaneLine(g.unittest.TestCase):
 
@@ -269,8 +286,6 @@ class SliceTest(g.unittest.TestCase):
         sliced_capped = mesh.slice_plane(plane_origin=plane_origins,
                                          plane_normal=plane_normals,
                                          cap=True)
-
-        assert len(sliced_capped.faces) == 20
         assert g.np.isclose(
             sliced_capped.bounds[0], mesh.bounds[0] + g.np.array([0, 0.95, 0.95])).all()
         assert g.np.isclose(sliced_capped.bounds[1], mesh.bounds[1]).all()
diff --git a/tests/test_util.py b/tests/test_util.py
index 76efa2c2..3b42d859 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -53,6 +53,33 @@ class UtilTests(unittest.TestCase):
                 tree = g.trimesh.util.bounds_tree(bounds)
                 self.assertTrue(0 in tree.intersection(bounds[0]))
 
+    def test_stack(self):
+        # shortcut to the function
+        f = g.trimesh.util.stack_3D
+        # start with some random points
+        p = g.np.random.random((100, 2))
+        stack = f(p)
+        # shape should be 3D
+        assert stack.shape == (100, 3)
+        # points should be equal
+        assert g.np.allclose(p, stack[:, :2])
+
+        # check an empty array
+        assert f([]).shape == (0,)
+
+        try:
+            # try with 4D points
+            f(g.np.ones((100, 4)))
+            assert False
+        except ValueError:
+            # this is what should happen
+            pass
+
+    def test_has_module(self):
+
+        assert g.trimesh.util.has_module('collections')
+        assert not g.trimesh.util.has_module('foobarrionananan')
+
     def test_strips(self):
         """
         Test our conversion of triangle strips to face indexes.