Repository: mikedh/trimesh

Issue:
Regression bug : Generating corrupt GLBs
Unfortunately I don't have a simple reproduction of this bug, but there is a problem that was introduced after 3.9.8, up to the time of writing this (3.9.11) where the GLBs produced by trimesh are invalid. We started seeing it today so 3.9.11 is the suspect. Has anyone else encountered this?

Discussion:

Patch:
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 62babf1c..4096b9b5 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -55,6 +55,11 @@ _default_material = {
         "metallicFactor": 0,
         "roughnessFactor": 0}}
 
+# GL geometry modes
+_GL_LINES = 1
+_GL_POINTS = 0
+_GL_TRIANGLES = 4
+
 # specify dtypes with forced little endian
 float32 = np.dtype("<f4")
 uint32 = np.dtype("<u4")
@@ -410,6 +415,32 @@ def _buffer_append(ordered, data):
     return len(ordered) - 1
 
 
+def _data_append(acc, buff, blob, data):
+    """
+    Append a new accessor to an OrderedDict.
+
+    Parameters
+    ------------
+    acc : collections.OrderedDict
+      Collection of accessors, will be mutated in-place
+    buff : collections.OrderedDict
+      Collection of buffer bytes, will be mutated in-place
+    blob : dict
+      Candidate accessor
+    data : numpy.array
+      Data to fill in details to blob
+
+    Returns
+    ----------
+    index : int
+      Index of accessor that was added or reused.
+    """
+    # append to the buffer list if not cached
+    blob['bufferView'] = _buffer_append(buff, data.tobytes())
+    # return the accessor for this data
+    return _acc_append(acc=acc, blob=blob, data=data)
+
+
 def _acc_append(acc, blob, data):
     """
     Append a new accessor to an OrderedDict.
@@ -429,38 +460,47 @@ def _acc_append(acc, blob, data):
       Index of accessor that was added or reused.
     """
 
+    # if we have data include that in the key
+    if hasattr(data, 'fast_hash'):
+        # passed a TrackedArray object
+        key = data.fast_hash()
+    else:
+        # someone passed a vanilla numpy array
+        key = fast_hash(data.tobytes())
     # start by hashing the dict blob
     # note that this will not work if a value is a list
     try:
         # simple keys can be hashed as tuples without JSON
-        key = hash(tuple(blob.items()))
+        key ^= hash(tuple(blob.items()))
     except BaseException:
         # if there are list keys that break the simple hash
-        key = hash(json.dumps(blob, sort_keys=True))
-
-    # if we have data include that in the key
-    if hasattr(data, 'fast_hash'):
-        # passed a TrackedArray object
-        key ^= data.fast_hash()
-    elif data is not None:
-        # someone passed a vanilla numpy array
-        key ^= fast_hash(data.tobytes())
+        key ^= hash(json.dumps(blob, sort_keys=True))
 
-    # if key exists return the index
+    # if key exists return the index in the OrderedDict
     if key in acc:
         return list(acc.keys()).index(key)
 
-    # if data is passed fill in details here
-    if data is not None:
-        kind = blob['componentType']
-        if kind == 5125:
-            blob['count'] = int(np.product(data.shape))
-            blob['max'] = [int(data.max())]
-            blob['min'] = [int(data.min())]
-        elif kind == 5126:
-            blob['count'] = len(data)
-            blob['max'] = data.max(axis=0).tolist()
-            blob['min'] = data.min(axis=0).tolist()
+    # get a numpy dtype for our components
+    dtype = np.dtype(_dtypes[blob['componentType']])
+    # see if we're an array, matrix, etc
+    kind = blob['type']
+
+    if kind == 'SCALAR':
+        # is probably (n, 1)
+        blob['count'] = int(np.product(data.shape))
+        blob['max'] = np.array(
+            [data.max()], dtype=dtype).tolist()
+        blob['min'] = np.array(
+            [data.min()], dtype=dtype).tolist()
+    elif kind.startswith('MAT'):
+        # i.e. (n, 4, 4) matrices
+        blob['count'] = len(data)
+    else:
+        # reshape the data into what we're actually exporting
+        resh = data.reshape((-1, _shapes[kind]))
+        blob['count'] = len(resh)
+        blob['max'] = resh.max(axis=0).astype(dtype).tolist()
+        blob['min'] = resh.min(axis=0).astype(dtype).tolist()
 
     # store the accessor and return the index
     acc[key] = blob
@@ -649,44 +689,38 @@ def _append_mesh(mesh,
 
     # convert mesh data to the correct dtypes
     # faces: 5125 is an unsigned 32 bit integer
-    index = _buffer_append(
-        buffer_items, mesh.faces.astype(uint32).tobytes())
     # accessors refer to data locations
     # mesh faces are stored as flat list of integers
-    acc_face = _acc_append(tree['accessors'],
-                           blob={
-                               "bufferView": index,
-                               "componentType": 5125,
-                               "type": "SCALAR"},
-                           data=mesh.faces)
+    acc_face = _data_append(acc=tree['accessors'],
+                            buff=buffer_items,
+                            blob={"componentType": 5125,
+                                  "type": "SCALAR"},
+                            data=mesh.faces.astype(uint32))
 
     # vertices: 5126 is a float32
-    index = _buffer_append(
-        buffer_items,
-        mesh.vertices.astype(float32).tobytes())
     # create or reuse an accessor for these vertices
-    acc_vertex = _acc_append(tree['accessors'],
-                             blob={"bufferView": index,
-                                   "componentType": 5126,
-                                   "type": "VEC3",
-                                   "byteOffset": 0},
-                             data=mesh.vertices)
+    acc_vertex = _data_append(acc=tree['accessors'],
+                              buff=buffer_items,
+                              blob={"componentType": 5126,
+                                    "type": "VEC3",
+                                    "byteOffset": 0},
+                              data=mesh.vertices.astype(float32))
 
     # meshes reference accessor indexes
     # mode 4 is GL_TRIANGLES
-    tree["meshes"].append({
-        "name": name,
-        "primitives": [{
-            "attributes": {"POSITION": acc_vertex},
-            "indices": acc_face,
-            "mode": 4}]})
+    current = {"name": name,
+               "primitives": [{
+                   "attributes": {"POSITION": acc_vertex},
+                   "indices": acc_face,
+                   "mode": 4}]}
+
     # if units are defined, store them as an extra
     # the GLTF spec says everything is implicit meters
     # we're not doing that as our unit conversions are expensive
     # although that might be better, implicit works for 3DXML
     # https://github.com/KhronosGroup/glTF/tree/master/extensions
     if mesh.units is not None and 'meter' not in mesh.units:
-        tree["meshes"][-1]["extras"] = {"units": str(mesh.units)}
+        current["extras"] = {"units": str(mesh.units)}
 
     # check to see if we have vertex or face colors
     # or if a TextureVisual has colors included as an attribute
@@ -700,23 +734,22 @@ def _append_mesh(mesh,
 
     if vertex_colors is not None:
         # convert color data to bytes and append
-        index = _buffer_append(buffer_items, vertex_colors.astype(uint8).tobytes())
-        acc_color = _acc_append(tree['accessors'],
-                                blob={
-                                    "bufferView": index,
-                                    "componentType": 5121,
-                                    "normalized": True,
-                                    "count": len(vertex_colors),
-                                    "type": "VEC4",
-                                    "byteOffset": 0},
-                                data=None)
+        acc_color = _data_append(
+            acc=tree['accessors'],
+            buff=buffer_items,
+            blob={"componentType": 5121,
+                  "normalized": True,
+                  "type": "VEC4",
+                  "byteOffset": 0},
+            data=vertex_colors.astype(uint8))
+
         # add the reference for vertex color
-        tree["meshes"][-1]["primitives"][0]["attributes"][
+        current["primitives"][0]["attributes"][
             "COLOR_0"] = acc_color
 
     if hasattr(mesh.visual, 'material'):
         # append the material and then set from returned index
-        tree["meshes"][-1]["primitives"][0]["material"] = _append_material(
+        current["primitives"][0]["material"] = _append_material(
             mat=mesh.visual.material,
             tree=tree,
             buffer_items=buffer_items,
@@ -731,57 +764,64 @@ def _append_mesh(mesh,
             uv = mesh.visual.uv.copy()[:, :2]
             # reverse the Y for GLTF
             uv[:, 1] = 1.0 - uv[:, 1]
-            # convert UV coordinate data to bytes and pad
-            index = _buffer_append(buffer_items, uv.astype(float32).tobytes())
             # add an accessor describing the blob of UV's
-            acc_uv = _acc_append(tree['accessors'],
-                                 blob={"bufferView": index,
-                                       "componentType": 5126,
-                                       "count": len(mesh.visual.uv),
-                                       "type": "VEC2",
-                                       "byteOffset": 0},
-                                 data=None)
+            acc_uv = _data_append(acc=tree['accessors'],
+                                  buff=buffer_items,
+                                  blob={"componentType": 5126,
+                                        "type": "VEC2",
+                                        "byteOffset": 0},
+                                  data=uv.astype(float32))
             # add the reference for UV coordinates
-            tree["meshes"][-1]["primitives"][0]["attributes"][
+            current["primitives"][0]["attributes"][
                 "TEXCOORD_0"] = acc_uv
 
     if (include_normals or (include_normals is None and
                             'vertex_normals' in mesh._cache.cache)):
-
-        normals = mesh.vertex_normals.astype(float32)
-        index = _buffer_append(
-            buffer_items,
-            normals.tobytes())
-        acc_norm = _acc_append(tree['accessors'],
-                               blob={
-                                   "bufferView": index,
-                                   "componentType": 5126,
-                                   "count": len(mesh.vertices),
-                                   "type": "VEC3",
-                                   "byteOffset": 0},
-                               data=normals)
+        # store vertex normals if requested
+        acc_norm = _data_append(
+            acc=tree['accessors'],
+            buff=buffer_items,
+            blob={"componentType": 5126,
+                  "count": len(mesh.vertices),
+                  "type": "VEC3",
+                  "byteOffset": 0},
+            data=mesh.vertex_normals.astype(float32))
         # add the reference for vertex color
-        tree["meshes"][-1]["primitives"][0]["attributes"][
+        current["primitives"][0]["attributes"][
             "NORMAL"] = acc_norm
 
     # for each attribute with a leading underscore, assign them to trimesh
     # vertex_attributes
-    for key in mesh.vertex_attributes:
+    for key, attrib in mesh.vertex_attributes.items():
         attribute_name = key
         # Application specific attributes must be prefixed with an underscore
         if not key.startswith("_"):
             attribute_name = "_" + key
-        index = _buffer_append(buffer_items, mesh.vertex_attributes[key].tobytes())
-        accessor = {"bufferView": index,
-                    "count": len(mesh.vertex_attributes[key])}
-        accessor.update(_build_accessor(mesh.vertex_attributes[key]))
-        acc_atr = _acc_append(tree['accessors'],
-                              blob=accessor,
-                              data=None)
-        tree["meshes"][-1]["primitives"][0]["attributes"][attribute_name] = acc_atr
+        # store custom vertex attributes
+        acc_atr = _data_append(acc=tree['accessors'],
+                               buff=buffer_items,
+                               blob=_build_accessor(attrib),
+                               data=attrib)
+        current["primitives"][0]["attributes"][attribute_name] = acc_atr
+
+    tree["meshes"].append(current)
 
 
 def _build_views(buffer_items):
+    """
+    Create views for buffers that are simply
+    based on how many bytes they are long.
+
+    Parameters
+    --------------
+    buffer_items : collections.OrderedDict
+      Buffers to build views for
+
+    Returns
+    ----------
+    views : (n,) list of dict
+      GLTF views
+    """
     views = []
     # create the buffer views
     current_pos = 0
@@ -795,6 +835,19 @@ def _build_views(buffer_items):
 
 
 def _build_accessor(array):
+    """
+    Build an accessor for an arbitrary array.
+
+    Parameters
+    -----------
+    array : numpy array
+      The array to build an accessor for
+
+    Returns
+    ----------
+    accessor : dict
+      The accessor for array.
+    """
     shape = array.shape
     data_type = "SCALAR"
     if len(shape) == 2:
@@ -846,7 +899,6 @@ def _byte_pad(data, bound=4):
     padded : bytes
       Result where: (len(padded) % bound) == 0
     """
-    bound = int(bound)
     if len(data) % bound != 0:
         # extra bytes to pad with
         count = bound - (len(data) % bound)
@@ -855,7 +907,7 @@ def _byte_pad(data, bound=4):
         # combine the padding and data
         result = bytes().join([data, pad])
         # we should always divide evenly
-        if (len(result) % bound) != 0:
+        if tol.strict and (len(result) % bound) != 0:
             raise ValueError(
                 'byte_pad failed! ori:{} res:{} pad:{} req:{}'.format(
                     len(data), len(result), count, bound))
@@ -898,46 +950,44 @@ def _append_path(path, name, tree, buffer_items):
 
     # data is the second value of the fifth field
     # which is a (data type, data) tuple
-    index = _buffer_append(
-        buffer_items, vxlist[4][1].astype(float32).tobytes())
-
-    acc_vertex = _acc_append(tree['accessors'],
-                             blob={"bufferView": index,
-                                   "componentType": 5126,
-                                   "type": "VEC3",
-                                   "byteOffset": 0},
-                             data=path.vertices)
-    tree["meshes"].append({
+    acc_vertex = _data_append(
+        acc=tree['accessors'],
+        buff=buffer_items,
+        blob={"componentType": 5126,
+              "type": "VEC3",
+              "byteOffset": 0},
+        data=vxlist[4][1].astype(float32))
+
+    current = {
         "name": name,
         "primitives": [{
             "attributes": {"POSITION": acc_vertex},
-            "mode": 1,  # mode 1 is GL_LINES
-            "material": material_idx}]})
+            "mode": _GL_LINES,  # i.e. 1
+            "material": material_idx}]}
 
     # if units are defined, store them as an extra:
     # https://github.com/KhronosGroup/glTF/tree/master/extensions
     if path.units is not None and 'meter' not in path.units:
-        tree["meshes"][-1]["extras"] = {"units": str(path.units)}
-
-    index = _buffer_append(
-        buffer_items,
-        np.array(vxlist[5][1]).astype(uint8).tobytes())
-    acc_color = _acc_append(tree['accessors'],
-                            blob={"bufferView": index,
-                                  "componentType": 5121,
-                                  "count": vxlist[0],
-                                  "normalized": True,
-                                  "type": "VEC4",
-                                  "byteOffset": 0},
-                            data=None)
-    # add color to attributes
-    tree["meshes"][-1]["primitives"][0]["attributes"]["COLOR_0"] = acc_color
+        current["extras"] = {"units": str(path.units)}
+
+    if path.colors is not None:
+        acc_color = _data_append(acc=tree['accessors'],
+                                 buff=buffer_items,
+                                 blob={"componentType": 5121,
+                                       "normalized": True,
+                                       "type": "VEC4",
+                                       "byteOffset": 0},
+                                 data=np.array(vxlist[5][1]).astype(uint8))
+        # add color to attributes
+        current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
+
+    tree["meshes"].append(current)
 
 
 def _append_point(points, name, tree, buffer_items):
     """
-    Append a 2D or 3D pointCloud to the scene structure and put the
-    data into buffer_items.
+    Append a 2D or 3D pointCloud to the scene structure and
+    put the data into buffer_items.
 
     Parameters
     -------------
@@ -958,21 +1008,17 @@ def _append_point(points, name, tree, buffer_items):
 
     # data is the second value of the fifth field
     # which is a (data type, data) tuple
-    index = _buffer_append(
-        buffer_items,
-        vxlist[4][1].astype(float32).tobytes())
-    acc_vertex = _acc_append(tree['accessors'],
-                             blob={"bufferView": index,
-                                   "componentType": 5126,
-                                   "type": "VEC3",
-                                   "byteOffset": 0},
-                             data=points.vertices)
-    tree["meshes"].append({
-        "name": name,
-        "primitives": [{
-            "attributes": {"POSITION": acc_vertex},
-            "mode": 0,  # mode 0 is GL_POINTS
-            "material": len(tree["materials"])}]})
+    acc_vertex = _data_append(acc=tree['accessors'],
+                              buff=buffer_items,
+                              blob={"componentType": 5126,
+                                    "type": "VEC3",
+                                    "byteOffset": 0},
+                              data=vxlist[4][1].astype(float32))
+    current = {"name": name,
+               "primitives": [{
+                   "attributes": {"POSITION": acc_vertex},
+                   "mode": _GL_POINTS,
+                   "material": len(tree["materials"])}]}
 
     # TODO add color support to Points object
     # this is just exporting everying as black
@@ -986,20 +1032,18 @@ def _append_point(points, name, tree, buffer_items):
         elif '4' in color_type:
             kind = 'VEC4'
         else:
-            return
-        index = _buffer_append(
-            buffer_items,
-            np.array(color_data).astype(uint8).tobytes())
-        acc_color = _acc_append(tree['accessors'],
-                                blob={"bufferView": index,
-                                      "componentType": 5121,
-                                      "count": vxlist[0],
-                                      "normalized": True,
-                                      "type": kind,
-                                      "byteOffset": 0},
-                                data=None)
+            raise ValueError('unknown color: %s', color_type)
+        acc_color = _data_append(acc=tree['accessors'],
+                                 buff=buffer_items,
+                                 blob={"componentType": 5121,
+                                       "count": vxlist[0],
+                                       "normalized": True,
+                                       "type": kind,
+                                       "byteOffset": 0},
+                                 data=np.array(color_data).astype(uint8))
         # add color to attributes
-        tree["meshes"][-1]["primitives"][0]["attributes"]["COLOR_0"] = acc_color
+        current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
+    tree["meshes"].append(current)
 
 
 def _parse_materials(header, views, resolver=None):
@@ -1140,6 +1184,7 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
 
                 access[index] = np.frombuffer(
                     data[start:start + length], dtype=dtype).reshape(shape)
+
             else:
                 # a "sparse" accessor should be initialized as zeros
                 access[index] = np.zeros(
@@ -1153,39 +1198,55 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
     # load data from accessors into Trimesh objects
     meshes = collections.OrderedDict()
 
-    if "meshes" in header:
-        for index, m in enumerate(header["meshes"]):
-
-            metadata = {}
-            unique = util.unique_id()
-            try:
-                # try loading units from the GLTF extra
-                metadata['units'] = str(m["extras"]["units"])
-            except BaseException:
-                # GLTF spec indicates the default units are meters
-                metadata['units'] = 'meters'
-
-            try:
-                # load extras metadata if available
-                metadata['extras'] = str(m["extras"])
-            except BaseException:
-                pass
+    for index, m in enumerate(header.get("meshes", [])):
+        metadata = {}
+        unique = util.unique_id()
+        try:
+            # try loading units from the GLTF extra
+            metadata['units'] = str(m["extras"]["units"])
+        except BaseException:
+            # GLTF spec indicates the default units are meter
+            metadata['units'] = 'meters'
+        try:
+            # load extras metadata if available
+            metadata['extras'] = str(m["extras"])
+        except BaseException:
+            pass
+
+        for j, p in enumerate(m["primitives"]):
+            # if we don't have a triangular mesh continue
+            # if not specified assume it is a mesh
+            kwargs = {"metadata": {}, "process": False}
+            kwargs.update(mesh_kwargs)
+            kwargs["metadata"].update(metadata)
+
+            # i.e. GL_LINES, GL_TRIANGLES, etc
+            mode = p.get('mode')
+
+            # create a unique mesh name per- primitive
+            name = m.get('name', 'GLTF')
+            # make name unique across multiple meshes
+            if name in meshes:
+                name += "_{}".format(unique)
+
+            if mode == _GL_LINES:
+                # load GL_LINES into a Path object
+                from ..path.entities import Line
+                kwargs["vertices"] = access[p["attributes"]["POSITION"]]
+                kwargs['entities'] = [Line(
+                    points=np.arange(len(kwargs['vertices'])))]
+            elif mode == _GL_POINTS:
+                kwargs["vertices"] = access[p["attributes"]["POSITION"]]
 
-            for j, p in enumerate(m["primitives"]):
-                # if we don't have a triangular mesh continue
-                # if not specified assume it is a mesh
-                if "mode" in p and p["mode"] != 4:
-                    log.warning('skipping primitive with mode {}!'.format(p['mode']))
-                    continue
+            elif mode is None or mode == _GL_TRIANGLES:
 
-                # store those units
-                kwargs = {"metadata": {}}
-                kwargs.update(mesh_kwargs)
-                kwargs["metadata"].update(metadata)
+                if mode is None:
+                    # some people skip mode since GL_TRIANGLES
+                    # is apparently the de-facto default
+                    log.warning('primitive has no mode! trying GL_TRIANGLES?')
 
                 # get vertices from accessors
                 kwargs["vertices"] = access[p["attributes"]["POSITION"]]
-
                 # get faces from accessors
                 if 'indices' in p:
                     kwargs["faces"] = access[p["indices"]].reshape((-1, 3))
@@ -1228,23 +1289,14 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
                 if visuals is not None:
                     kwargs['visual'] = visuals
 
-                # create a unique mesh name per- primitive
-                if "name" in m:
-                    name = m["name"]
-                else:
-                    name = "GLTF_geometry"
-
                 # By default the created mesh is not from primitive,
                 # in case it is the value will be updated
-                kwargs['metadata']['from_gltf_primitive'] = False
                 # each primitive gets it's own Trimesh object
                 if len(m["primitives"]) > 1:
                     kwargs['metadata']['from_gltf_primitive'] = True
                     name += "_{}".format(j)
-
-                # make name unique across multiple meshes
-                if name in meshes:
-                    name += "_{}".format(unique)
+                else:
+                    kwargs['metadata']['from_gltf_primitive'] = False
 
                 custom_attrs = [attr for attr in p["attributes"]
                                 if attr.startswith("_")]
@@ -1253,9 +1305,11 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
                     for attr in custom_attrs:
                         vertex_attributes[attr] = access[p["attributes"][attr]]
                     kwargs["vertex_attributes"] = vertex_attributes
-                kwargs["process"] = False
-                meshes[name] = kwargs
-                mesh_prim[index].append(name)
+            else:
+                log.warning('skipping primitive with mode %s!', mode)
+                continue
+            meshes[name] = kwargs
+            mesh_prim[index].append(name)
 
     # sometimes GLTF "meshes" come with multiple "primitives"
     # by default we return one Trimesh object per "primitive"
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index e85c9dea..f9d3bb1a 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -432,21 +432,20 @@ def load_kwargs(*args, **kwargs):
         data, file_type = kwargs['data'], kwargs['file_type']
         if not isinstance(data, dict):
             data = util.wrap_as_stream(data)
-        k = mesh_loaders[file_type](data,
-                                    file_type=file_type)
+        k = mesh_loaders[file_type](
+            data, file_type=file_type)
         return Trimesh(**k)
 
     def handle_path():
         from ..path import Path2D, Path3D
-        e, v = kwargs['entities'], kwargs['vertices']
-        if 'metadata' in kwargs:
-            m = kwargs['metadata']
+        shape = kwargs['vertices'].shape
+
+        if shape[1] == 2:
+            return Path2D(**kwargs)
+        elif shape[1] == 3:
+            return Path3D(**kwargs)
         else:
-            m = None
-        if v.shape[1] == 2:
-            return Path2D(entities=e, vertices=v, metadata=m)
-        elif v.shape[1] == 3:
-            return Path3D(entities=e, vertices=v, metadata=m)
+            raise ValueError('Vertices must be 2D or 3D!')
 
     def handle_pointcloud():
         return PointCloud(**kwargs)
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index d9d3a400..fc4d1f7b 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -625,7 +625,7 @@ def load_element_different(properties, data):
     data : array
       Data rows for this element.
     """
-    element_data = {k: [] for k in properties.keys()}
+    edata = {k: [] for k in properties.keys()}
     for row in data:
         start = 0
         for name, dt in properties.items():
@@ -637,12 +637,18 @@ def load_element_different(properties, data):
                 # skip the first entry (the length), when reading the data
                 start += 1
             end = start + length
-            element_data[name].append(row[start:end].astype(dt))
+            edata[name].append(row[start:end].astype(dt))
             # start next property at the end of this one
             start = end
-    # try converting to numpy arrays
-    squeeze = {k: np.array(v).squeeze() for k, v in
-               element_data.items()}
+
+    # if the shape of any array is (n, 1) we want to
+    # squeeze/concatenate it into (n,)
+    squeeze = {k: np.array(v, dtype='object')
+               for k, v in edata.items()}
+    # squeeze and convert any clean 2D arrays
+    squeeze.update({k: v.squeeze().astype(edata[k][0].dtype)
+                    for k, v in squeeze.items() if len(v.shape) == 2})
+
     return squeeze
 
 
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 6f018143..03cddb53 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -224,6 +224,8 @@ def float_to_int(data, digits=None, dtype=np.int32):
     # if the data is empty we are also done
     if data.dtype.kind in 'ib' or data.size == 0:
         return data.astype(dtype)
+    elif data.dtype.kind != 'f':
+        data = data.astype(np.float64)
 
     # populate digits from kwargs
     if digits is None:
diff --git a/trimesh/path/entities.py b/trimesh/path/entities.py
index ed6b8623..4abcbf43 100644
--- a/trimesh/path/entities.py
+++ b/trimesh/path/entities.py
@@ -5,18 +5,17 @@ entities.py
 Basic geometric primitives which only store references to
 vertex indices rather than vertices themselves.
 """
-
-import numpy as np
-
 import copy
+import numpy as np
 
 from .arc import discretize_arc, arc_center
 from .curve import discretize_bezier, discretize_bspline
 
 from .. import util
+from ..util import ABC
 
 
-class Entity(object):
+class Entity(ABC):
 
     def __init__(self,
                  points,
@@ -252,7 +251,8 @@ class Text(Entity):
                  vector=None,
                  normal=None,
                  align=None,
-                 layer=None):
+                 layer=None,
+                 color=None):
         """
         An entity for text labels.
 
@@ -284,6 +284,8 @@ class Text(Entity):
         self.height = height
         # what layer is the entity on
         self.layer = layer
+        # what color is the entity
+        self.color = color
 
         # None or (2,) str
         if align is None:
@@ -680,11 +682,13 @@ class BSpline(Curve):
     def __init__(self, points,
                  knots,
                  layer=None,
+                 color=None,
                  **kwargs):
         self.points = np.asanyarray(points, dtype=np.int64)
         self.knots = np.asanyarray(knots, dtype=np.float64)
         self.layer = layer
         self.kwargs = kwargs
+        self.color = color
 
     def discrete(self, vertices, count=None, scale=1.0):
         """
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index ec3f1c39..64353cce 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -128,12 +128,11 @@ class Path(parent.Geometry):
           RGBA colors for each entity
         """
         # start with default colors
-        colors = np.ones((len(self.entities), 4))
-        colors = (colors * [100, 100, 100, 255]).astype(np.uint8)
-        # collect colors from entities
-        for i, e in enumerate(self.entities):
-            if hasattr(e, 'color') and e.color is not None:
-                colors[i] = to_rgba(e.color)
+        raw = [e.color for e in self.entities]
+        if not any(c is not None for c in raw):
+            return None
+
+        colors = np.array([to_rgba(c) for c in raw])
         # don't allow parts of the color array to be written
         colors.flags['WRITEABLE'] = False
         return colors
diff --git a/trimesh/rendering.py b/trimesh/rendering.py
index 895778ec..341d58a8 100644
--- a/trimesh/rendering.py
+++ b/trimesh/rendering.py
@@ -35,8 +35,8 @@ def convert_to_vertexlist(geometry, **kwargs):
     elif util.is_instance_named(geometry, 'Path'):
         # works for Path3D and Path2D
         # both of which inherit from Path
-        return path_to_vertexlist(geometry,
-                                  **kwargs)
+        return path_to_vertexlist(
+            geometry, **kwargs)
     elif util.is_instance_named(geometry, 'PointCloud'):
         # pointcloud objects contain colors
         return points_to_vertexlist(geometry.vertices,
@@ -181,11 +181,13 @@ def path_to_vertexlist(path, group=None, **kwargs):
     index = np.arange(count).tolist()
     # convert from entity color to the color of
     # each vertex in the line segments
-    vcolor = np.vstack(
-        [(np.ones((len(s), 4)) * c).astype(np.uint8)
-         for s, c in zip(stacked, path.colors)])
+    colors = path.colors
+    if colors is not None:
+        colors = np.vstack(
+            [(np.ones((len(s), 4)) * c).astype(np.uint8)
+             for s, c in zip(stacked, path.colors)])
     # convert to gl-friendly colors
-    gl_colors = colors_to_gl(vcolor, count=count)
+    gl_colors = colors_to_gl(colors, count=count)
 
     # collect args for vertexlist constructor
     args = (count,    # number of lines
diff --git a/trimesh/version.py b/trimesh/version.py
index fdae339d..b1eb5f0e 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.9.12'
+__version__ = '3.9.13'


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index dc0658e5..ba5574d7 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -323,7 +323,8 @@ def check_path2D(path):
     assert all(path.polygons_closed[i] is not None
                for i in path.enclosure_directed.nodes())
 
-    assert path.colors.shape == (len(path.entities), 4)
+    if any(e.color is not None for e in path.entities):
+        assert path.colors.shape == (len(path.entities), 4)
 
 
 def scene_equal(a, b):
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 98dd5ccd..4b0e7821 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -540,6 +540,17 @@ class GLTFTest(g.unittest.TestCase):
         # Assert that geometries that are not primitives are not marked as such
         assert not s.geometry['Wheels'].metadata['from_gltf_primitive']
 
+    def test_points(self):
+        # test a simple pointcloud export-import cycle
+        points = g.np.arange(30).reshape((-1, 3))
+        export = g.trimesh.Scene(
+            g.trimesh.PointCloud(points)).export(file_type='glb')
+        reloaded = g.trimesh.load(
+            g.trimesh.util.wrap_as_stream(export),
+            file_type='glb')
+        # make sure points survived export and reload
+        assert g.np.allclose(next(iter(reloaded.geometry.values())).vertices, points)
+
     def test_bulk(self):
         # Try exporting every loadable model to GLTF and checking
         # the generated header against the schema.
diff --git a/tests/test_paths.py b/tests/test_paths.py
index cc4aac3e..fbe4f950 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -196,12 +196,15 @@ class VectorTests(g.unittest.TestCase):
         # make sure we have entities
         assert len(p.entities) > 0
         # make sure shape of colors is correct
-        assert p.colors.shape == (len(p.entities), 4)
+        assert all(e.color is None for e in p.entities)
+        assert p.colors is None
+
         color = [255, 0, 0, 255]
         # assign a color to the entity
         p.entities[0].color = color
         # make sure this is reflected in the path color
         assert g.np.allclose(p.colors[0], color)
+        assert p.colors.shape == (len(p.entities), 4)
 
 
 class SplitTest(g.unittest.TestCase):