Repository: mikedh/trimesh

Issue:
ValueError: no field of name rgba - exporting mesh to ASCII encoded .ply
Hello, I have a mesh which I want export to `.ply` file using the ascii encoding. I assign colors to the mesh using

`mesh.visual.vertex_colors = vertex_colors`

then trying to export, I get the following error:

```
mesh.export("mesh.ply", encoding="ascii")
  File "/home/jared/bin/miniconda3/lib/python3.7/site-packages/trimesh/base.py", line 2496, in export
    **kwargs)
  File "/home/jared/bin/miniconda3/lib/python3.7/site-packages/trimesh/exchange/export.py", line 78, in export_mesh
    export = _mesh_exporters[file_type](mesh, **kwargs)
  File "/home/jared/bin/miniconda3/lib/python3.7/site-packages/trimesh/exchange/ply.py", line 161, in export_ply
    vertex['rgba'] = mesh.visual.vertex_colors
ValueError: no field of name rgba
```

Am I doing something wrong?

Discussion:

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d7d2e0d7..5cbf8a7c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -48,7 +48,7 @@ jobs:
         python -m pip install --upgrade pip
         pip install pytest pytest-cov setuptools
     - name: Install Trimesh Minimal
-      run: pip install .
+      run: pip install .[easy]
     - name: Run Pytest
       run: python tests/test_inertia.py
 
diff --git a/Dockerfile b/Dockerfile
index 4d613c2c..8ba58036 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM python:3.8-slim-buster
 LABEL maintainer="mikedh@kerfed.com"
 ARG TRIMESH_PATH=/opt/trimesh
 
-# Required for python to be able to find libembree.
+# Required for Python to be able to find libembree.
 ENV LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"
 
 # Create a local non-root user.
@@ -16,9 +16,6 @@ RUN bash /tmp/apt.bash
 COPY docker/builds/draco.bash /tmp/
 RUN bash /tmp/draco.bash
 
-COPY docker/builds/vhacd.bash /tmp/
-RUN bash /tmp/vhacd.bash
-
 COPY docker/builds/embree.bash /tmp/
 RUN bash /tmp/embree.bash
 
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index f6b0fdda..96b1c4a8 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -284,8 +284,8 @@ def export_ply(mesh,
         header.append(templates['vertex_normal'])
         dtype_vertex.append(dtype_vertex_normal)
 
-    # if mesh has a vertex coloradd it to the header
-    if mesh.visual.kind == 'vertex' and encoding != 'ascii':
+    # if mesh has a vertex color add it to the header
+    if mesh.visual.kind == 'vertex':
         header.append(templates['color'])
         dtype_vertex.append(dtype_color)
 
@@ -338,14 +338,12 @@ def export_ply(mesh,
         if hasattr(mesh, 'faces'):
             export += faces.tobytes()
     elif encoding == 'ascii':
-        export_data = util.structured_array_to_string(vertex,
-                                                      col_delim=' ',
-                                                      row_delim='\n')
+        export_data = util.structured_array_to_string(
+            vertex, col_delim=' ', row_delim='\n')
         if hasattr(mesh, 'faces'):
             export_data += '\n'
-            export_data += util.structured_array_to_string(faces,
-                                                           col_delim=' ',
-                                                           row_delim='\n')
+            export_data += util.structured_array_to_string(
+                faces, col_delim=' ', row_delim='\n')
         export += export_data.encode('utf-8')
     else:
         raise ValueError('encoding must be ascii or binary!')
@@ -570,31 +568,10 @@ def elements_to_kwargs(elements,
     kwargs['vertices'] = vertices
 
     # if both vertex and face color are defined pick the one
-    # with the most "signal," i.e. which one is not all zeros
-    colors = []
-    signal = []
-    if faces is not None:
-        # extract face colors or None
-        f_color, f_signal = element_colors(elements['face'])
-        colors.append({'face_colors': f_color})
-        signal.append(f_signal)
-        # extract vertex colors or None
-        v_color, v_signal = element_colors(elements['vertex'])
-        colors.append({'vertex_colors': v_color})
-        signal.append(v_signal)
-
-        if prefer_color is None:
-            # if we are in "auto-pick" mode take the one with the
-            # largest  standard deviation of colors
-            kwargs.update(colors[np.argmax(signal)])
-        elif 'vert' in prefer_color and v_color is not None:
-            # vertex colors are preferred and defined
-            kwargs['vertex_colors'] = v_color
-        elif 'face' in prefer_color and f_color is not None:
-            # face colors are preferred and defined
-            kwargs['face_colors'] = f_color
-    else:
-        kwargs['colors'] = element_colors(elements['vertex'])[0]
+    if 'face' in elements:
+        kwargs['face_colors'] = element_colors(elements['face'])
+    if 'vertex' in elements:
+        kwargs['vertex_colors'] = element_colors(elements['vertex'])
 
     return kwargs
 
@@ -619,13 +596,9 @@ def element_colors(element):
     keys = ['red', 'green', 'blue', 'alpha']
     candidate_colors = [element['data'][i]
                         for i in keys if i in element['properties']]
-
     if len(candidate_colors) >= 3:
-        colors = np.column_stack(candidate_colors)
-        signal = colors.std(axis=0).sum()
-        return colors, signal
-
-    return None, 0.0
+        return np.column_stack(candidate_colors)
+    return None
 
 
 def load_element_different(properties, data):
diff --git a/trimesh/points.py b/trimesh/points.py
index 06349a45..d5ad7aef 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -404,6 +404,9 @@ class PointCloud(Geometry3D):
         # load vertices
         self.vertices = vertices
 
+        if 'vertex_colors' in kwargs and colors is None:
+            colors = kwargs['vertex_colors']
+
         # save visual data to vertex color object
         self.visual = VertexColor(colors=colors, obj=self)
 
diff --git a/trimesh/proximity.py b/trimesh/proximity.py
index 34bf7bcc..2d9c6393 100644
--- a/trimesh/proximity.py
+++ b/trimesh/proximity.py
@@ -13,6 +13,12 @@ from .constants import tol, log_time
 from .triangles import closest_point as closest_point_corresponding
 from .triangles import points_to_barycentric
 
+try:
+    from scipy.spatial import cKDTree
+except BaseException as E:
+    from .exceptions import closure
+    cKDTree = closure(E)
+
 
 def nearby_faces(mesh, points):
     """
@@ -44,7 +50,7 @@ def nearby_faces(mesh, points):
     # an r-tree containing the axis aligned bounding box for every triangle
     rtree = mesh.triangles_tree
     # a kd-tree containing every vertex of the mesh
-    kdtree = mesh.kdtree
+    kdtree = cKDTree(mesh.vertices[mesh.referenced_vertices])
 
     # query the distance to the nearest vertex to get AABB of a sphere
     distance_vertex = kdtree.query(points)[0].reshape((-1, 1))
@@ -145,9 +151,11 @@ def closest_point(mesh, points):
     # create the corresponding list of triangles
     # and query points to send to the closest_point function
     all_candidates = np.concatenate(candidates)
+
     num_candidates = list(map(len, candidates))
     tile_idxs = np.repeat(np.arange(len(points)), num_candidates)
     query_point = points[tile_idxs, :]
+
     query_tri = triangles[all_candidates]
 
     # do the computation for closest point
diff --git a/trimesh/scene/cameras.py b/trimesh/scene/cameras.py
index cd329ec4..6ce420af 100644
--- a/trimesh/scene/cameras.py
+++ b/trimesh/scene/cameras.py
@@ -292,6 +292,13 @@ def look_at(points, fov, rotation=None, distance=None, center=None, pad=None):
     Generate transform for a camera to keep a list
     of points in the camera's field of view.
 
+    Examples
+    ------------
+    ```python
+    points = np.array([0, 0, 0], [1, 1, 1])
+    scene.camera_transform = scene.camera.look_at(points)
+    ```
+
     Parameters
     -------------
     points : (n, 3) float
diff --git a/trimesh/version.py b/trimesh/version.py
index 6df47eae..f7daba47 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.8.18'
+__version__ = '3.8.19'
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index ea93b80e..6d049c50 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -232,8 +232,14 @@ class SceneViewer(pyglet.window.Window):
         kwargs **
           Passed to rendering.convert_to_vertexlist
         """
-        # convert geometry to constructor args
-        args = rendering.convert_to_vertexlist(geometry, **kwargs)
+        try:
+            # convert geometry to constructor args
+            args = rendering.convert_to_vertexlist(geometry, **kwargs)
+        except BaseException:
+            util.log.warning('failed to add geometry `{}`'.format(name),
+                             exc_info=True)
+            return
+
         # create the indexed vertex list
         self.vertex_list[name] = self.batch.add_indexed(*args)
         # save the hash of the geometry
@@ -702,6 +708,10 @@ class SceneViewer(pyglet.window.Window):
         if self._grid:
             self._grid.draw(mode=gl.GL_LINES)
 
+        # save a reference outside of the loop
+        geometry = self.scene.geometry
+        graph = self.scene.graph
+
         while len(node_names) > 0:
             count += 1
             current_node = node_names.popleft()
@@ -710,10 +720,10 @@ class SceneViewer(pyglet.window.Window):
                 continue
 
             # get the transform from world to geometry and mesh name
-            transform, geometry_name = self.scene.graph.get(current_node)
+            transform, geometry_name = graph.get(current_node)
 
             # if no geometry at this frame continue without rendering
-            if geometry_name is None:
+            if geometry_name is None or geometry_name not in self.vertex_list_mode:
                 continue
 
             # if a geometry is marked as fixed apply the inverse view transform
@@ -725,7 +735,7 @@ class SceneViewer(pyglet.window.Window):
                 transform = np.dot(transform, transform_fix)
 
             # get a reference to the mesh so we can check transparency
-            mesh = self.scene.geometry[geometry_name]
+            mesh = geometry[geometry_name]
             if mesh.is_empty:
                 continue
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1f2234f4..3460695b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -43,12 +43,11 @@ jobs:
     - name: Install APT On Linux
       run: |
         sudo apt-get update -qq -y
-        sudo apt-get install -qq -y libspatialindex-dev libgeos-dev
+        sudo apt-get install -qq -y libspatialindex-dev
     - name: Install Pytest
       run: |
         python -m pip install --upgrade pip
         pip install pytest pytest-cov setuptools
-        pip debug install -vv shapely
     - name: Install Trimesh
       run: pip install .[easy]
     - name: Run Pytest
diff --git a/tests/generic.py b/tests/generic.py
index fd0aa350..73afb55b 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -389,6 +389,29 @@ def check_fuze(fuze):
     fuze.visual.material.copy()
 
 
+def wrapload(exported, file_type, **kwargs):
+    """
+    Reload an exported byte blob into a mesh.
+
+    Parameters
+    -----------
+    exported : bytes or str
+      Exported mesh
+    file_type : str
+      Type of file to load
+    kwargs : dict
+      Passed to trimesh.load
+
+    Returns
+    ---------
+    loaded : trimesh.Trimesh
+      Loaded result
+    """
+    return trimesh.load(file_obj=trimesh.util.wrap_as_stream(exported),
+                        file_type=file_type,
+                        **kwargs)
+
+
 TemporaryDirectory = trimesh.util.TemporaryDirectory
 
 # all the JSON files with truth data
diff --git a/tests/test_export.py b/tests/test_export.py
index 2b3909b9..935c13d8 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -183,8 +183,7 @@ class ExportTest(g.unittest.TestCase):
     def test_obj(self):
         m = g.get_mesh('textured_tetrahedron.obj', process=False)
         export = m.export(file_type='obj')
-        reconstructed = g.trimesh.load(g.trimesh.util.wrap_as_stream(export),
-                                       file_type='obj', process=False)
+        reconstructed = g.wrapload(export, file_type='obj', process=False)
         # test that we get at least the same number of normals and texcoords out;
         # the loader may reorder vertices, so we shouldn't check direct
         # equality
@@ -229,9 +228,7 @@ class ExportTest(g.unittest.TestCase):
         export = source.export(file_type='glb')
 
         # re- load the file as a trimesh.Scene object again
-        loaded = g.trimesh.load(
-            file_obj=g.trimesh.util.wrap_as_stream(export),
-            file_type='glb')
+        loaded = g.wrapload(export, file_type='glb')
 
         # the scene should be identical after export-> import cycle
         assert g.np.allclose(loaded.extents / source.extents,
diff --git a/tests/test_ply.py b/tests/test_ply.py
index e838d657..7c67359a 100644
--- a/tests/test_ply.py
+++ b/tests/test_ply.py
@@ -20,8 +20,7 @@ class PlyTest(g.unittest.TestCase):
         assert m.visual.face_colors.ptp(axis=0).max() > 0
 
         export = m.export(file_type='ply')
-        reconstructed = g.trimesh.load(g.trimesh.util.wrap_as_stream(export),
-                                       file_type='ply')
+        reconstructed = g.wrapload(export, file_type='ply')
 
         assert reconstructed.visual.kind == 'face'
 
@@ -34,17 +33,15 @@ class PlyTest(g.unittest.TestCase):
         assert m.visual.vertex_colors.ptp(axis=0).max() > 0
 
         export = m.export(file_type='ply')
-        reconstructed = g.trimesh.load(g.trimesh.util.wrap_as_stream(export),
-                                       file_type='ply')
+        reconstructed = g.wrapload(export, file_type='ply')
         assert reconstructed.visual.kind == 'vertex'
 
         assert g.np.allclose(reconstructed.visual.vertex_colors,
                              m.visual.vertex_colors)
 
     def test_points(self):
-        """
-        Test reading point clouds from PLY files
-        """
+        # Test reading point clouds from PLY files
+
         m = g.get_mesh('points_ascii.ply')
         assert isinstance(m, g.trimesh.PointCloud)
         assert m.vertices.shape == (5, 3)
@@ -91,8 +88,8 @@ class PlyTest(g.unittest.TestCase):
         m.vertex_attributes['test_nd_attribute'] = test_nd_attribute
 
         export = m.export(file_type='ply')
-        reconstructed = g.trimesh.load(g.trimesh.util.wrap_as_stream(export),
-                                       file_type='ply')
+        reconstructed = g.wrapload(export,
+                                   file_type='ply')
 
         vertex_attributes = reconstructed.metadata['ply_raw']['vertex']['data']
         result_1d = vertex_attributes['test_1d_attribute']
@@ -102,10 +99,8 @@ class PlyTest(g.unittest.TestCase):
         g.np.testing.assert_almost_equal(result_nd, test_nd_attribute)
 
     def test_face_attributes(self):
-        """
-        Test writing face attributes to a ply, by reading them back and asserting the
-        written attributes array matches
-        """
+        # Test writing face attributes to a ply, by reading
+        # them back and asserting the written attributes array matches
 
         m = g.get_mesh('box.STL')
         test_1d_attribute = g.np.copy(m.face_angles[:, 0])
@@ -114,8 +109,7 @@ class PlyTest(g.unittest.TestCase):
         m.face_attributes['test_nd_attribute'] = test_nd_attribute
 
         export = m.export(file_type='ply')
-        reconstructed = g.trimesh.load(g.trimesh.util.wrap_as_stream(export),
-                                       file_type='ply')
+        reconstructed = g.wrapload(export, file_type='ply')
 
         face_attributes = reconstructed.metadata['ply_raw']['face']['data']
         result_1d = face_attributes['test_1d_attribute']
@@ -136,6 +130,26 @@ class PlyTest(g.unittest.TestCase):
         m = g.get_mesh('suzanne.ply')
         assert len(m.faces) > 0
 
+    def test_ascii_color(self):
+        mesh = g.trimesh.creation.box()
+        en = g.wrapload(mesh.export(file_type='ply', encoding="ascii"),
+                        file_type='ply')
+        assert en.visual.kind is None
+
+        color = [255, 0, 0, 255]
+        mesh.visual.vertex_colors = color
+
+        # try exporting and reloading raw
+        eb = g.wrapload(mesh.export(file_type='ply'), file_type='ply')
+
+        assert g.np.allclose(eb.visual.vertex_colors[0], color)
+        assert eb.visual.kind == 'vertex'
+
+        ea = g.wrapload(mesh.export(file_type='ply', encoding='ascii'),
+                        file_type='ply')
+        assert g.np.allclose(ea.visual.vertex_colors, color)
+        assert ea.visual.kind == 'vertex'
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_points.py b/tests/test_points.py
index 9668f2b3..5009c9d5 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -203,12 +203,9 @@ class PointsTest(g.unittest.TestCase):
         assert p.visual.crc() != initial
 
         # test exporting a pointcloud to a PLY file
-        r = g.trimesh.load(g.trimesh.util.wrap_as_stream(
-            p.export(file_type='ply')),
-            file_type='ply')
+        r = g.wrapload(p.export(file_type='ply'), file_type='ply')
         assert r.vertices.shape == p.vertices.shape
         # make sure colors survived the round trip
-
         assert g.np.allclose(r.colors, p.colors)
 
     def test_glb(self):
diff --git a/tests/test_proximity.py b/tests/test_proximity.py
index 11a76336..a9fdd8ac 100644
--- a/tests/test_proximity.py
+++ b/tests/test_proximity.py
@@ -231,6 +231,23 @@ class NearestTest(g.unittest.TestCase):
         g.np.testing.assert_almost_equal(closest[0], [0.0, 0.0, -0.25])
         self.assertEqual(closest_face_index[0], 1)
 
+    def test_unreferenced_vertex(self):
+        # check to see that meshes with unreferenced vertices
+        # return correct values and ignore the unreferenced points
+        query_point = [-1.0, -1.0, -1.0]
+        mesh = g.trimesh.Trimesh(
+            vertices=[[1.0, 0.0, 0.0],
+                      [0.0, 1.0, 0.0],
+                      [0.0, 0.0, 1.0],
+                      [-0.5, -0.5, -0.5]],
+            faces=[[0, 1, 2]],
+            process=False)
+
+        proximity_query = g.trimesh.proximity.ProximityQuery(mesh)
+        q = proximity_query.on_surface([query_point])
+        assert len(q) == 3
+        assert all(len(i) == 1 for i in q)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_render.py b/tests/test_render.py
index b709a651..2a56f752 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -17,34 +17,32 @@ class RenderTest(g.unittest.TestCase):
         # a viewer show() call)
         from trimesh import rendering
 
-        m = g.get_mesh('featuretype.STL')
+        files = ['featuretype.STL',
+                 'fuze.obj',
+                 'points_bin.ply']
+        for file_name in files:
+            m = g.get_mesh(file_name)
+            args = rendering.convert_to_vertexlist(m)
 
-        args = rendering.mesh_to_vertexlist(m)
-        args_auto = rendering.convert_to_vertexlist(m)
-        assert len(args) == 7
-        assert len(args_auto) == len(args)
-
-        # try turning smoothing off and on
-        args_sm = rendering.mesh_to_vertexlist(
-            m, smooth_threshold=0)
-        args_ns = rendering.mesh_to_vertexlist(
-            m, smooth_threshold=g.np.inf)
-        # vertex count should be different
-        # if smooth kwargs got passed through successfully
-        assert args_sm[0] != args_ns[0]
+            if isinstance(m, g.trimesh.Trimesh):
+                # try turning smoothing off and on
+                rendering.mesh_to_vertexlist(
+                    m, smooth_threshold=0)
+                rendering.mesh_to_vertexlist(
+                    m, smooth_threshold=g.np.inf)
 
-        P30 = m.section(plane_normal=[0, 0, 1],
-                        plane_origin=m.centroid)
-        args = rendering.path_to_vertexlist(P30)
-        args_auto = rendering.convert_to_vertexlist(P30)
-        assert len(args) == 6
-        assert len(args_auto) == len(args)
+                P30 = m.section(plane_normal=[0, 0, 1],
+                                plane_origin=m.centroid)
+                args = rendering.path_to_vertexlist(P30)
+                args_auto = rendering.convert_to_vertexlist(P30)
+                assert len(args) == 6
+                assert len(args_auto) == len(args)
 
-        P20, T = P30.to_planar()
-        args = rendering.path_to_vertexlist(P20)
-        args_auto = rendering.convert_to_vertexlist(P20)
-        assert len(args) == 6
-        assert len(args_auto) == len(args)
+                P20, T = P30.to_planar()
+                args = rendering.path_to_vertexlist(P20)
+                args_auto = rendering.convert_to_vertexlist(P20)
+                assert len(args) == 6
+                assert len(args_auto) == len(args)
 
         P21 = g.get_mesh('2D/wrench.dxf')
         args = rendering.path_to_vertexlist(P21)