Repository: mikedh/trimesh
Pull request #1941 (2023-06-10 12:15:32)

fix crash on tex-coord pointcloud
Hey there,

when trying to import a PLY with per-vertex UV coords, but no faces (i.e. a point cloud), the importer crashes, as it doesn't check if faces exist in the first place:

```
Traceback (most recent call last):
...
  File "/usr/local/lib/python3.10/site-packages/trimesh/exchange/load.py", line 132, in load
    loaded = load_mesh(file_obj,
  File "/usr/local/lib/python3.10/site-packages/trimesh/exchange/load.py", line 215, in load_mesh
    results = loader(file_obj,
  File "/usr/local/lib/python3.10/site-packages/trimesh/exchange/ply.py", line 122, in load_ply
    kwargs = _elements_to_kwargs(
  File "/usr/local/lib/python3.10/site-packages/trimesh/exchange/ply.py", line 554, in _elements_to_kwargs
    t_u[faces.reshape(-1)],
AttributeError: 'NoneType' object has no attribute 'reshape'
```

[halfcone_mc1.ply.zip](https://github.com/mikedh/trimesh/files/11712409/halfcone_mc1.ply.zip)



Patch:
diff --git a/setup.py b/setup.py
index 794e67e9..3cf6c048 100644
--- a/setup.py
+++ b/setup.py
@@ -69,8 +69,7 @@ requirements_test = set(['pytest',       # run all unit tests
                          'coveralls',    # report coverage stats
                          'autopep8',     # check and autoformat
                          'ruff',         # static code analysis
-                         'pymeshlab',    # used as a validator for exports
-                         'ezdxf'])       # used as a validator for exports
+                         'ezdxf'])       # use as a validator for exports
 
 # Python 2.7 and 3.4 support has been dropped from packages
 # version lock those packages here so install succeeds
@@ -84,7 +83,6 @@ lock = [((3, 4), 'lxml', '4.3.5'),
         ((3, 6), 'pyglet<2', None),
         ((3, 6), 'autopep8', None),
         ((3, 6), 'ruff', None),
-        ((3, 7), 'pymeshlab', None),
         ((3, 5), 'embreex', None),
         ((3, 6), 'svg.path', '4.1')]
 for max_python, name, version in lock:
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 71201eb3..0eea0924 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -338,7 +338,7 @@ def oriented_bounds(obj,
     return to_origin, min_extents
 
 
-def minimum_cylinder(obj, sample_count=6, angle_tol=0.001):
+def minimum_cylinder(obj, sample_count=6, angle_tol=.001):
     """
     Find the approximate minimum volume cylinder which contains
     a mesh or a a list of points.
diff --git a/trimesh/creation.py b/trimesh/creation.py
index d9120c41..4e320acd 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -153,8 +153,7 @@ def revolve(linestring,
     faces = (stacked + offset) % len(vertices)
 
     # create the mesh from our vertices and faces
-    mesh = Trimesh(vertices=vertices,
-                   faces=faces,
+    mesh = Trimesh(vertices=vertices, faces=faces,
                    **kwargs)
 
     # strict checks run only in unit tests
@@ -164,6 +163,11 @@ def revolve(linestring,
         # if revolved curve starts and ends with zero radius
         # it should really be a valid volume, unless the sign
         # reversed on the input linestring
+
+        if not mesh.is_volume:
+            from IPython import embed
+            embed()
+
         assert mesh.is_volume
         assert mesh.body_count == 1
 
@@ -645,14 +649,9 @@ def box(extents=None, transform=None, bounds=None, **kwargs):
     return box
 
 
-def icosahedron(**kwargs):
+def icosahedron():
     """
-    Create an icosahedron, one of the platonic solids which is has 20 faces.
-
-    Parameters
-    ------------
-    kwargs : dict
-      Passed through to `Trimesh` constructor.
+    Create an icosahedron, a 20 faced polyhedron.
 
     Returns
     -------------
@@ -669,14 +668,13 @@ def icosahedron(**kwargs):
     # scale vertices so each vertex radius is 1.0
     vertices = np.reshape(vertices, (-1, 3)) / np.sqrt(2.0 + t)
     faces = np.reshape(faces, (-1, 3))
-
-    return Trimesh(vertices=vertices,
+    mesh = Trimesh(vertices=vertices,
                    faces=faces,
-                   process=kwargs.pop('process', False),
-                   **kwargs)
+                   process=False)
+    return mesh
 
 
-def icosphere(subdivisions=3, radius=1.0, **kwargs):
+def icosphere(subdivisions=3, radius=1.0, color=None):
     """
     Create an isophere centered at the origin.
 
@@ -688,8 +686,8 @@ def icosphere(subdivisions=3, radius=1.0, **kwargs):
       4 ** subdivisions, so you probably want to keep this under ~5
     radius : float
       Desired radius of sphere
-    kwargs : dict
-      Passed through to `Trimesh` constructor.
+    color: (3,) float or uint8
+      Desired color of sphere
 
     Returns
     ---------
@@ -705,16 +703,15 @@ def icosphere(subdivisions=3, radius=1.0, **kwargs):
         scalar = np.sqrt(np.dot(vectors ** 2, [1, 1, 1]))
         unit = vectors / scalar.reshape((-1, 1))
         ico.vertices += unit * (radius - scalar).reshape((-1, 1))
-
-    return Trimesh(vertices=ico.vertices,
-                   faces=ico.faces,
-                   metadata={'shape': 'sphere',
-                             'radius': radius},
-                   process=kwargs.pop('process', False),
-                   **kwargs)
+    ico._validate = True
+    if color is not None:
+        ico.visual.face_colors = color
+    ico.metadata.update({'shape': 'sphere',
+                         'radius': radius})
+    return ico
 
 
-def uv_sphere(radius=1.0, count=None, transform=None, **kwargs):
+def uv_sphere(radius=1.0, count=None, transform=None):
     """
     Create a UV sphere (latitude + longitude) centered at the
     origin. Roughly one order of magnitude faster than an
@@ -726,8 +723,7 @@ def uv_sphere(radius=1.0, count=None, transform=None, **kwargs):
       Radius of sphere
     count : (2,) int
       Number of latitude and longitude lines
-    kwargs : dict
-      Passed thgrough
+
     Returns
     ----------
     mesh : trimesh.Trimesh
@@ -751,8 +747,7 @@ def uv_sphere(radius=1.0, count=None, transform=None, **kwargs):
                    sections=count[1],
                    transform=transform,
                    metadata={'shape': 'sphere',
-                             'radius': radius},
-                   **kwargs)
+                             'radius': radius})
 
 
 def capsule(height=1.0,
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index b1e36660..f44c5db8 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -541,26 +541,25 @@ def _elements_to_kwargs(elements,
             # we may have mixed quads and triangles handle them with function
             faces = triangulate_quads(faces)
 
-    if texcoord is None:
-        # ply has no clear definition of how texture coordinates are stored,
-        # unfortunately there are many common names that we need to try
-        texcoord_names = [('texture_u', 'texture_v'), ('u', 'v'), ('s', 't')]
-        for names in texcoord_names:
-            # If texture coordinates are defined with vertices
-            try:
-                t_u = elements['vertex']['data'][names[0]]
-                t_v = elements['vertex']['data'][names[1]]
-                texcoord = np.stack((
-                    t_u[faces.reshape(-1)],
-                    t_v[faces.reshape(-1)]), axis=-1).reshape(
-                        (faces.shape[0], -1))
-                # stop trying once succeeded
-                break
-            except (ValueError, KeyError):
-                # if the fields didn't exist
-                pass
+        if texcoord is None:
+            # ply has no clear definition of how texture coordinates are stored,
+            # unfortunately there are many common names that we need to try
+            texcoord_names = [('texture_u', 'texture_v'), ('u', 'v'), ('s', 't')]
+            for names in texcoord_names:
+                # If texture coordinates are defined with vertices
+                try:
+                    t_u = elements['vertex']['data'][names[0]]
+                    t_v = elements['vertex']['data'][names[1]]
+                    texcoord = np.stack((
+                        t_u[faces.reshape(-1)],
+                        t_v[faces.reshape(-1)]), axis=-1).reshape(
+                            (faces.shape[0], -1))
+                    # stop trying once succeeded
+                    break
+                except (ValueError, KeyError):
+                    # if the fields didn't exist
+                    pass
 
-    if faces is not None:
         shape = np.shape(faces)
 
         # PLY stores texture coordinates per-face which is
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index 60577166..22fe3f65 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -36,14 +36,10 @@ class _Primitive(Trimesh):
     __copy__ = None
     __deepcopy__ = None
 
-    def __init__(self):
-        # run the Trimesh constructor with no arguments
-        super(_Primitive, self).__init__()
-
-        # remove any data
+    def __init__(self, *args, **kwargs):
+        super(_Primitive, self).__init__(*args, **kwargs)
         self._data.clear()
         self._validate = False
-        
         # make sure any cached numpy arrays have
         # set `array.flags.writable = False`
         self._cache.force_immutable = True
@@ -133,15 +129,10 @@ class _Primitive(Trimesh):
         # create a new object with kwargs
         return type(self)(**kwargs)
 
-    def to_mesh(self, **kwargs):
+    def to_mesh(self):
         """
         Return a copy of the Primitive object as a Trimesh.
 
-        Parameters
-        -----------
-        kwargs : dict
-          Passed to the Trimesh object constructor.
-
         Returns
         ------------
         mesh : trimesh.Trimesh
@@ -151,8 +142,7 @@ class _Primitive(Trimesh):
             vertices=self.vertices.copy(),
             faces=self.faces.copy(),
             face_normals=self.face_normals.copy(),
-            process=kwargs.pop('process', False),
-            **kwargs)
+            process=False)
         return result
 
     def apply_transform(self, matrix):
@@ -319,11 +309,8 @@ class _PrimitiveAttributes(object):
 
 class Cylinder(_Primitive):
 
-    def __init__(self,
-                 radius=1.0,
-                 height=1.0,
-                 transform=None,
-                 sections=32):
+    def __init__(self, radius=1.0, height=1.0,
+                 transform=None, sections=32, **kwargs):
         """
         Create a Cylinder Primitive, a subclass of Trimesh.
 
@@ -338,7 +325,7 @@ class Cylinder(_Primitive):
         sections : int
           Number of facets in circle
         """
-        super(Cylinder, self).__init__()
+        super(Cylinder, self).__init__(**kwargs)
 
         defaults = {'height': 10.0,
                     'radius': 1.0,
@@ -473,7 +460,8 @@ class Capsule(_Primitive):
                  radius=1.0,
                  height=10.0,
                  transform=None,
-                 sections=32):
+                 sections=32,
+                 **kwargs):
         """
         Create a Capsule Primitive, a subclass of Trimesh.
 
@@ -488,7 +476,7 @@ class Capsule(_Primitive):
         sections : int
           Number of facets in circle
         """
-        super(Capsule, self).__init__()
+        super(Capsule, self).__init__(**kwargs)
 
         defaults = {'height': 1.0,
                     'radius': 1.0,
@@ -554,7 +542,8 @@ class Sphere(_Primitive):
                  radius=1.0,
                  center=None,
                  transform=None,
-                 subdivisions=3):
+                 subdivisions=3,
+                 **kwargs):
         """
         Create a Sphere Primitive, a subclass of Trimesh.
 
@@ -570,7 +559,7 @@ class Sphere(_Primitive):
           Number of subdivisions for icosphere.
         """
 
-        super(Sphere, self).__init__()
+        super(Sphere, self).__init__(**kwargs)
 
         defaults = {'radius': 1.0,
                     'transform': np.eye(4),
@@ -675,12 +664,11 @@ class Sphere(_Primitive):
 
     def _create_mesh(self):
         log.debug('creating mesh for Sphere primitive')
-        unit = creation.icosphere(
-            subdivisions=self.primitive.subdivisions,
-            radius=self.primitive.radius)
+        unit = creation.icosphere(subdivisions=self.primitive.subdivisions)
+        unit.vertices *= self.primitive.radius
+        unit.vertices += self.primitive.center
 
-        # apply the center offset here
-        self._cache['vertices'] = unit.vertices + self.primitive.center
+        self._cache['vertices'] = unit.vertices
         self._cache['faces'] = unit.faces
         self._cache['face_normals'] = unit.face_normals
 
@@ -689,7 +677,8 @@ class Box(_Primitive):
 
     def __init__(self,
                  extents=None,
-                 transform=None):
+                 transform=None,
+                 **kwargs):
         """
         Create a Box Primitive as a subclass of Trimesh
 
@@ -700,7 +689,7 @@ class Box(_Primitive):
         transform : (4, 4) float
           Homogeneous transformation matrix for box center
         """
-        super(Box, self).__init__()
+        super(Box, self).__init__(**kwargs)
         defaults = {'transform': np.eye(4),
                     'extents': np.ones(3)}
         self.primitive = _PrimitiveAttributes(
@@ -840,7 +829,8 @@ class Extrusion(_Primitive):
     def __init__(self,
                  polygon=None,
                  transform=None,
-                 height=1.0):
+                 height=1.0,
+                 **kwargs):
         """
         Create an Extrusion primitive, which
         is a subclass of Trimesh.
@@ -856,14 +846,11 @@ class Extrusion(_Primitive):
         """
         # do the import here, fail early if Shapely isn't installed
         from shapely.geometry import Point
-
-        # run the Trimesh init
-        super(Extrusion, self).__init__()
+        super(Extrusion, self).__init__(**kwargs)
         # set default values
         defaults = {'polygon': Point([0, 0]).buffer(1.0),
                     'transform': np.eye(4),
                     'height': 1.0}
-
         self.primitive = _PrimitiveAttributes(
             self, defaults, {'transform': transform,
                              'polygon': polygon,


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index b2775e83..f298bb15 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -485,8 +485,7 @@ def scene_equal(a, b):
         # and have the same volume
         assert np.isclose(
             m.volume, b.geometry[k].volume, rtol=0.001)
-    # the axis aligned bounding box should be the same
-    assert np.allclose(a.bounds, b.bounds)
+
 
 def texture_equal(a, b):
     """
@@ -588,10 +587,10 @@ data = _load_data()
 
 # find executables to run with subprocess
 # formats supported by meshlab for export tests
-try:
-    import pymeshlab
+if any(trimesh.util.which(i) is None
+       for i in ['xfvb-run', 'meshlabserver']):
+    meshlab_formats = []
+else:
     meshlab_formats = ['3ds', 'ply', 'stl', 'obj', 'qobj', 'off', 'ptx', 'vmi',
-                       'bre', 'dae', 'ctm', 'pts', 'apts', 'gts', 'pdb',
+                       'bre', 'dae', 'ctm', 'pts', 'apts', 'xyz', 'gts', 'pdb',
                        'tri', 'asc', 'x3d', 'x3dv', 'wrl']
-except BaseException:
-    meshlab_formats = []
diff --git a/tests/test_export.py b/tests/test_export.py
index 14ea1b09..386e2a6d 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -158,10 +158,22 @@ class ExportTest(g.unittest.TestCase):
                     option['file_obj'] = temp.name
                     mesh.export(**option)
 
-                    # -_-
-                    ms = g.pymeshlab.MeshSet()
-                    ms.load_new_mesh(temp.name)
-                    ms.save_current_mesh(temp_off.name)
+                    # will raise CalledProcessError if meshlab
+                    # can't successfully import the file
+                    try:
+                        # have meshlab take the export and convert it into
+                        # an OFF file, which is basically the simplest format
+                        # that uses by- reference vertices
+                        # meshlabserver requires X so wrap it with XVFB
+                        cmd = 'xvfb-run -a -s "-screen 0 800x600x24" meshlabserver '
+                        cmd += '-i {} -o {}'.format(temp.name, temp_off.name)
+                        g.subprocess.check_call(cmd, shell=True)
+                    except g.subprocess.CalledProcessError as E:
+                        # log the options that produced the failure
+                        g.log.error('failed to export {}'.format(
+                            option))
+                        # raise the error again
+                        raise E
 
                     # load meshlabs export back into trimesh
                     r = g.trimesh.load(temp_off.name)
diff --git a/tests/test_primitives.py b/tests/test_primitives.py
index 98cf44a7..50c30b8c 100644
--- a/tests/test_primitives.py
+++ b/tests/test_primitives.py
@@ -45,17 +45,10 @@ class PrimitiveTest(g.unittest.TestCase):
                                                            radius=10.0,
                                                            subdivisions=5))
         self.primitives.append(g.trimesh.primitives.Box())
-
-        try:
-            self.primitives.append(
-                g.trimesh.primitives.Box(
-                    center=[102.20, 0, 102.0],
-                    extents=[29, 100, 1000]))
-            raise ValueError('Box shouldnt have accepted `center`!')
-        except TypeError:
-            # this should have raised a TypeError as `center` is not a kwarg
-            pass
-
+        self.primitives.append(
+            g.trimesh.primitives.Box(
+                center=[102.20, 0, 102.0],
+                extents=[29, 100, 1000]))
         self.primitives.append(g.trimesh.primitives.Box(
             extents=[10, 20, 30],
             transform=g.trimesh.transformations.random_rotation_matrix()))