Repository: mikedh/trimesh

Issue:
point cloud export problem
Hi  there,

Is there an easy way to export point cloud? I found there existed some support in the point cloud class. However, when I wanted to export my point cloud object as .ply, it showed " 'PointCloud' object has no attribute 'visual'".

My code is like:
```
mesh = trimesh.load('models/XX.obj')
cloud=trimesh.PointCloud(mesh.sample(10000))
cloud.export('demo.ply')
```

Do I miss anything here? Thanks a lot.

Discussion:

Patch:
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 7602b406..4589b78b 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -1170,7 +1170,7 @@ def truncated_prisms(tris, origin=None, normal=None):
     f_seq = np.tile(f, (len(transformed), 1)).reshape((-1, len(f), 3))
     # if the normal of the triangle was positive flip the winding
     f_seq[cross > 0] = np.fliplr(f)
-    # offet stacked faces to create correct indices
+    # offset stacked faces to create correct indices
     faces = (f_seq + (np.arange(len(f_seq)) * 6).reshape((-1, 1, 1))).reshape((-1, 3))
 
     # create a mesh from the data
diff --git a/trimesh/exchange/assimp.py b/trimesh/exchange/assimp.py
index b77a3383..22fa0e92 100644
--- a/trimesh/exchange/assimp.py
+++ b/trimesh/exchange/assimp.py
@@ -46,8 +46,9 @@ def load_pyassimp(file_obj,
         # add data from the LP_Mesh
         mesh_kwargs.update({'vertices': lp.vertices,
                             'vertex_normals': lp.normals,
-                            'faces': lp.faces,
-                            'vertex_colors': colors})
+                            'vertex_colors': colors,
+                            'faces': lp.faces})
+
         return mesh_kwargs
 
     # did we open the file inside this function
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index 7c0537b2..2a217e9c 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -52,7 +52,6 @@ def load_collada(file_obj, resolver=None, **kwargs):
     meshes = {}
     # list of dict
     graph = []
-
     for node in c.scene.nodes:
         _parse_node(node=node,
                     parent_matrix=np.eye(4),
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index 833c3532..39576cff 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -160,34 +160,37 @@ def export_ply(mesh,
     if mesh.visual.kind == 'vertex':
         vertex['rgba'] = mesh.visual.vertex_colors
 
-    header += templates['face']
-    if mesh.visual.kind == 'face' and encoding != 'ascii':
-        header += templates['color']
-        dtype_face.append(dtype_color)
-
-    # put mesh face data into custom dtype to export
-    faces = np.zeros(len(mesh.faces), dtype=dtype_face)
-    faces['count'] = 3
-    faces['index'] = mesh.faces
-    if mesh.visual.kind == 'face' and encoding != 'ascii':
-        faces['rgba'] = mesh.visual.face_colors
-
-    header += templates['outro']
-
     header_params = {'vertex_count': len(mesh.vertices),
-                     'face_count': len(mesh.faces),
                      'encoding': encoding}
 
+    if hasattr(mesh, 'faces'):
+        header += templates['face']
+        if mesh.visual.kind == 'face' and encoding != 'ascii':
+            header += templates['color']
+            dtype_face.append(dtype_color)
+        # put mesh face data into custom dtype to export
+        faces = np.zeros(len(mesh.faces), dtype=dtype_face)
+        faces['count'] = 3
+        faces['index'] = mesh.faces
+        if mesh.visual.kind == 'face' and encoding != 'ascii':
+            faces['rgba'] = mesh.visual.face_colors
+        header_params['face_count'] = len(mesh.faces)
+
+    header += templates['outro']
     export = Template(header).substitute(header_params).encode('utf-8')
 
     if encoding == 'binary_little_endian':
         export += vertex.tostring()
-        export += faces.tostring()
+        if hasattr(mesh, 'faces'):
+            export += faces.tostring()
     elif encoding == 'ascii':
-        # ply format is: (face count, v0, v1, v2)
-        fstack = np.column_stack((np.ones(len(mesh.faces),
-                                          dtype=np.int64) * 3,
-                                  mesh.faces))
+        if hasattr(mesh, 'faces'):
+            # ply format is: (face count, v0, v1, v2)
+            fstack = np.column_stack((np.ones(len(mesh.faces),
+                                              dtype=np.int64) * 3,
+                                      mesh.faces))
+        else:
+            fstack = []
 
         # if we're exporting vertex normals they get stacked
         if vertex_normal:
@@ -446,7 +449,7 @@ def elements_to_kwargs(elements,
             # face colors are preferred and defined
             kwargs['face_colors'] = f_color
     else:
-        kwargs['colors'] = element_colors(elements['vertex'])
+        kwargs['colors'] = element_colors(elements['vertex'])[0]
 
     return kwargs
 
diff --git a/trimesh/points.py b/trimesh/points.py
index 7405c1fa..07bcdb9b 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -11,6 +11,7 @@ import numpy as np
 from .parent import Geometry
 from .geometry import plane_transform
 from .constants import tol
+from .visual.color import VertexColor
 
 from . import util
 from . import caching
@@ -26,13 +27,17 @@ def point_plane_distance(points,
 
     Parameters
     -----------
-    points:       (n, 3) float, points in space
-    plane_normal: (3,) float, normal vector
-    plane_origin: (3,) float, plane origin in space
+    points : (n, 3) float
+      Points in space
+    plane_normal : (3,) float
+      Unit normal vector
+    plane_origin : (3,) float
+      Plane origin in space
 
     Returns
     ------------
-    distances:     (n,) float, distance from point to plane
+    distances : (n,) float
+      Distance from point to plane
     """
     points = np.asanyarray(points, dtype=np.float64)
     w = points - plane_origin
@@ -42,15 +47,18 @@ def point_plane_distance(points,
 
 def major_axis(points):
     """
-    Returns an approximate vector representing the major axis of points
+    Returns an approximate vector representing the major
+    axis of the passed points.
 
     Parameters
     -------------
-    points: (n, dimension) float, points in space
+    points : (n, dimension) float
+      Points in space
 
     Returns
     -------------
-    axis: (dimension,) float, vector along approximate major axis
+    axis : (dimension,) float
+      Vector along approximate major axis
     """
     U, S, V = np.linalg.svd(points)
     axis = util.unitize(np.dot(S, V))
@@ -59,19 +67,19 @@ def major_axis(points):
 
 def plane_fit(points):
     """
-    Given a set of points, find an origin and normal using SVD.
+    Fit a plane to points using SVD.
 
     Parameters
     ---------
-    points : (n,3) float
-        Points in 3D space
+    points : (n, 3) float
+      3D points in space
 
     Returns
     ---------
     C : (3,) float
-        Point on the plane
+      Point on the plane
     N : (3,) float
-        Normal vector of plane
+      Unit normal vector of plane
     """
     # make sure input is numpy array
     points = np.asanyarray(points, dtype=np.float64)
@@ -92,17 +100,21 @@ def radial_sort(points,
                 normal):
     """
     Sorts a set of points radially (by angle) around an
-    origin/normal.
+    an axis specified by origin and normal vector.
 
     Parameters
     --------------
-    points: (n,3) float, points in space
-    origin: (3,)  float, origin to sort around
-    normal: (3,)  float, vector to sort around
+    points : (n, 3) float
+      Points in space
+    origin : (3,)  float
+      Origin to sort around
+    normal : (3,)  float
+      Vector to sort around
 
     Returns
     --------------
-    ordered: (n,3) flot, re- ordered points in space
+    ordered : (n, 3) float
+      Same as input points but reordered
     """
 
     # create two axis perpendicular to each other and the normal,
@@ -127,18 +139,22 @@ def project_to_plane(points,
                      return_transform=False,
                      return_planar=True):
     """
-    Projects a set of (n,3) points onto a plane.
+    Project (n, 3) points onto a plane.
 
     Parameters
-    ---------
-    points:           (n,3) array of points
-    plane_normal:     (3) normal vector of plane
-    plane_origin:     (3) point on plane
-    transform:        None or (4,4) matrix. If specified, normal/origin are ignored
-    return_transform: bool, if true returns the (4,4) matrix used to project points
-                      onto a plane
-    return_planar:    bool, if True, returns (n,2) points. If False, returns
-                      (n,3), where the Z column consists of zeros
+    -----------
+    points : (n, 3) float
+      Points in space.
+    plane_normal : (3,) float
+      Unit normal vector of plane
+    plane_origin : (3,)
+      Origin point of plane
+    transform : None or (4, 4) float
+       Homogeneous transform, if specified, normal+origin are overridden
+    return_transform : bool
+      Returns the (4, 4) matrix used or not
+    return_planar : bool
+      Return (n, 2) points rather than (n, 3) points
     """
 
     if np.all(np.abs(plane_normal) < tol.zero):
@@ -215,18 +231,18 @@ def k_means(points, k, **kwargs):
     Parameters
     ----------
     points:  (n, d) float
-        Points in a space
+      Points in space
     k : int
-         Number of centroids to compute
+      Number of centroids to compute
     **kwargs : dict
-        Passed directly to scipy.cluster.vq.kmeans
+      Passed directly to scipy.cluster.vq.kmeans
 
     Returns
     ----------
     centroids : (k, d) float
-         Points in some space
+      Points in some space
     labels: (n) int
-        Indexes for which points belong to which centroid
+      Indexes for which points belong to which centroid
     """
     from scipy.cluster.vq import kmeans
     from scipy.spatial import cKDTree
@@ -255,7 +271,8 @@ def tsp(points, start=0):
     i.e. the travelling salesman problem on a fully connected
     graph. It is not a MINIMUM traversal; rather it is a
     "not totally goofy traversal, quickly." On random points
-    this traversal is often ~20x shorter than random ordering.
+    this traversal is often ~20x shorter than random ordering,
+    and executes on 1000 points in around 29ms on a 2014 i7.
 
     Parameters
     ---------------
@@ -327,7 +344,7 @@ def tsp(points, start=0):
 
 def plot_points(points, show=True):
     """
-    Plot an (n,3) list of points using matplotlib
+    Plot an (n, 3) list of points using matplotlib
 
     Parameters
     -------------
@@ -387,8 +404,8 @@ class PointCloud(Geometry):
         # load vertices
         self.vertices = vertices
 
-        if colors is not None:
-            self.colors = colors
+        # save visual data to vertex color object
+        self.visual = VertexColor(colors=colors, obj=self)
 
     def __setitem__(self, *args, **kwargs):
         return self.vertices.__setitem__(*args, **kwargs)
@@ -556,17 +573,11 @@ class PointCloud(Geometry):
         colors : (len(self.vertices), 4) np.uint8
           Per- point RGBA color
         """
-        return self._data.get('colors', None)
+        return self.visual.vertex_colors
 
     @colors.setter
     def colors(self, data):
-        if data is None:
-            if 'colors' in self._data:
-                del self._data['colors']
-        data = np.asanyarray(data)
-        if data.shape in [(3,), (4,)]:
-            data = np.tile(data, (len(self.vertices), 1))
-        self._data['colors'] = data
+        self.visual.vertex_colors = data
 
     @caching.cache_decorator
     def convex_hull(self):
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 928b75ad..a12cfc5c 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -552,7 +552,7 @@ class Scene(Geometry):
         # if no geometry nothing to set camera to
         if len(self.geometry) == 0:
             self._camera = cameras.Camera(fov=fov)
-            self.graph[self._camera.name] = None
+            self.graph[self._camera.name] = np.eye(4)
             return self._camera
         # set with no rotation by default
         if angles is None:
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 39c9c62f..e9e85dd9 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -555,7 +555,7 @@ def kwargs_to_matrix(**kwargs):
         matrix = transformations.rotation_matrix(kwargs['angle'],
                                                  kwargs['axis'])
     else:
-        raise ValueError('Couldn\'t update transform!')
+        matrix = np.eye(4)
 
     if 'translation' in kwargs:
         # translation can be used in conjunction with any of the methods of
diff --git a/trimesh/version.py b/trimesh/version.py
index e478d70a..c91eaea4 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.6.20'
+__version__ = '3.6.21'
diff --git a/trimesh/visual/base.py b/trimesh/visual/base.py
new file mode 100644
index 00000000..20f34ffd
--- /dev/null
+++ b/trimesh/visual/base.py
@@ -0,0 +1,52 @@
+"""
+base.py
+-------------
+
+The base class for `Visual` objects
+"""
+import abc
+from ..util import ABC
+
+
+class Visuals(ABC):
+
+    """
+    Parent of Visual classes.
+    """
+
+    @abc.abstractproperty
+    def kind(self):
+        pass
+
+    @abc.abstractmethod
+    def update_vertices(self):
+        pass
+
+    @abc.abstractmethod
+    def update_faces(self):
+        pass
+
+    @abc.abstractmethod
+    def concatenate(self, other):
+        pass
+
+    @abc.abstractmethod
+    def copy(self):
+        pass
+
+    def __add__(self, other):
+        """
+        Concatenate two ColorVisuals objects into a single object.
+
+        Parameters
+        -----------
+        other : Visuals
+          Other visual to concatenate
+
+        Returns
+        -----------
+        result : Visuals
+          Object containing information from current
+          object and other in the order (self, other)
+        """
+        return self.concatenate(other)
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index fca7afc1..1b6fb966 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -27,12 +27,14 @@ import numpy as np
 import copy
 import colorsys
 
+from .base import Visuals
+
 from .. import util
 from .. import caching
 from .. import grouping
 
 
-class ColorVisuals(object):
+class ColorVisuals(Visuals):
     """
     Store color information about a mesh.
     """
@@ -98,7 +100,8 @@ class ColorVisuals(object):
 
         Returns
         ---------
-        defined: bool, are colors defined or not.
+        defined : bool
+          Are colors defined or not.
         """
         return self.kind is not None
 
@@ -109,7 +112,8 @@ class ColorVisuals(object):
 
         Returns
         ----------
-        mode: 'face', 'vertex', or None
+        mode : str or None
+          One of ('face', 'vertex', None)
         """
         self._verify_crc()
         if 'vertex_colors' in self._data:
@@ -127,7 +131,8 @@ class ColorVisuals(object):
 
         Returns
         ----------
-        crc: int, checksum of data in visual object and its parent mesh
+        crc : int
+          Checksum of data in visual object and its parent mesh
         """
         # will make sure everything has been transferred
         # to datastore that needs to be before returning crc
@@ -453,21 +458,6 @@ class ColorVisuals(object):
         result = objects.concatenate(self, other, *args)
         return result
 
-    def __add__(self, other):
-        """
-        Concatenate two ColorVisuals objects into a single object.
-
-        Parameters
-        -----------
-        other: ColorVisuals object
-
-        Returns
-        -----------
-        result: ColorVisuals object containing information from current
-                object and other in the order (self, other)
-        """
-        return self.concatenate(other)
-
     def _update_key(self, mask, key):
         """
         Mask the value contained in the DataStore at a specified key.
@@ -483,6 +473,70 @@ class ColorVisuals(object):
             self._data[key] = self._data[key][mask]
 
 
+class VertexColor(Visuals):
+    """
+    Create a simple visual object to hold just vertex colors
+    for objects such as PointClouds.
+    """
+
+    def __init__(self, colors=None, obj=None):
+        """
+        Create a vertex color visual
+        """
+        self.obj = obj
+        self.vertex_colors = colors
+
+    @property
+    def kind(self):
+        return 'vertex'
+
+    def update_vertices(self, mask):
+        if self._colors is not None:
+            self._colors = self._colors[mask]
+
+    def update_faces(self, mask):
+        pass
+
+    @property
+    def vertex_colors(self):
+        return self._colors
+
+    @vertex_colors.setter
+    def vertex_colors(self, data):
+        if data is None:
+            self._colors = None
+        else:
+            print(data)
+            data = np.asanyarray(data)
+            if data.shape in [(3,), (4,)]:
+                data = np.tile(data, (len(self.obj.vertices), 1))
+            self._colors = data
+
+    def copy(self):
+        """
+        Return a copy of the current visuals
+        """
+        return copy.deepcopy(self)
+
+    def concatenate(self, other):
+        """
+        Concatenate this visual object with another VertexVisuals.
+
+        Parameters
+        -----------
+        other : VertexColors or ColorVisuals
+          Other object to concatenate
+
+        Returns
+        ------------
+        concate : VertexColor
+          Object with both colors
+        """
+        return VertexColor(colors=np.vstack(
+            self.vertex_colors,
+            other.vertex_colors))
+
+
 def to_rgba(colors, dtype=np.uint8):
     """
     Convert a single or multiple RGB colors to RGBA colors.
diff --git a/trimesh/visual/texture.py b/trimesh/visual/texture.py
index 15c83ea0..e3f9dc1d 100644
--- a/trimesh/visual/texture.py
+++ b/trimesh/visual/texture.py
@@ -2,15 +2,15 @@ import numpy as np
 
 import copy
 
+from .base import Visuals
 from . import color
-
 from .. import caching
 from .. import grouping
 
 from .material import SimpleMaterial, PBRMaterial  # NOQA
 
 
-class TextureVisuals(object):
+class TextureVisuals(Visuals):
     def __init__(self,
                  uv=None,
                  material=None,
@@ -172,6 +172,9 @@ class TextureVisuals(object):
         """
         pass
 
+    def concatenate(self, *args, **kwargs):
+        raise NotImplementedError('concatentate not implemented yet for texture!')
+
 
 def unmerge_faces(faces, *args):
     """


Test patch:
diff --git a/tests/test_points.py b/tests/test_points.py
index d010efd6..6ca16bbd 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -197,6 +197,20 @@ class PointsTest(g.unittest.TestCase):
         assert matrix.shape == (4, 4)
         assert not g.np.allclose(p.bounds, original)
 
+    def test_ply(self):
+        p = g.get_mesh('points_agisoft.xyz')
+        assert isinstance(p, g.trimesh.PointCloud)
+        assert len(p.vertices) > 0
+        p.visual.colors = (g.np.random.random(
+            (len(p.vertices), 4)) * 255).astype(g.np.uint8)
+        # 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')
+        assert r.vertices.shape == p.vertices.shape
+        # make sure colors survived the round trip
+        assert g.np.allclose(r.colors, p.colors)
+
     def test_remove_close(self):
         # create 100 unique points
         p = g.np.arange(300).reshape((100, 3))