Repository: mikedh/trimesh
Pull request #1271 (2021-06-15 15:52:38)

Release: Materials And Fixes
- release #1266 fixing subdivide with UV coordinates and add simple test
- release #1246 adding multi-material support
- release #1270 adding per-vertex UV exported how meshlab exports


Patch:
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 63ee6cc7..2cb14ee0 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -11,3 +11,7 @@
       overflow: visible !important;
    }
 }
+
+dl.py.property {
+    display: unset;
+}
diff --git a/models/rgb_cube_with_primitives.gltf b/models/rgb_cube_with_primitives.gltf
new file mode 100644
index 00000000..bebad087
--- /dev/null
+++ b/models/rgb_cube_with_primitives.gltf
@@ -0,0 +1,268 @@
+{
+    "asset" : {"version" : "2.0"},
+    "scene" : 0,
+    "scenes" : [
+        {
+            "name" : "Scene",
+            "nodes" : [
+                0
+            ]
+        }
+    ],
+    "nodes" : [
+        {
+            "mesh" : 0,
+            "name" : "Cube"
+        }
+    ],
+    "materials" : [
+        {
+            "doubleSided" : true,
+            "name" : "Green",
+            "pbrMetallicRoughness" : {
+                "baseColorFactor" : [
+                    0.0,
+                    1.0,
+                    0.0,
+                    1.0
+                ],
+                "metallicFactor" : 0,
+                "roughnessFactor" : 0.5
+            }
+        },
+        {
+            "doubleSided" : true,
+            "name" : "Blue",
+            "pbrMetallicRoughness" : {
+                "baseColorFactor" : [
+                    0.0,
+                    0.0,
+                    1.0,
+                    1.0
+                ],
+                "metallicFactor" : 0,
+                "roughnessFactor" : 0.5
+            }
+        },
+        {
+            "doubleSided" : true,
+            "name" : "Red",
+            "pbrMetallicRoughness" : {
+                "baseColorFactor" : [
+                    1.0,
+                    0.0,
+                    0.0,
+                    1.0
+                ],
+                "metallicFactor" : 0,
+                "roughnessFactor" : 0.5
+            }
+        }
+    ],
+    "meshes" : [
+        {
+            "name" : "Cube",
+            "primitives" : [
+                {
+                    "attributes" : {
+                        "POSITION" : 0,
+                        "NORMAL" : 1,
+                        "TEXCOORD_0" : 2
+                    },
+                    "indices" : 3,
+                    "material" : 0
+                },
+                {
+                    "attributes" : {
+                        "POSITION" : 4,
+                        "NORMAL" : 5,
+                        "TEXCOORD_0" : 6
+                    },
+                    "indices" : 7,
+                    "material" : 1
+                },
+                {
+                    "attributes" : {
+                        "POSITION" : 8,
+                        "NORMAL" : 9,
+                        "TEXCOORD_0" : 10
+                    },
+                    "indices" : 11,
+                    "material" : 2
+                }
+            ]
+        }
+    ],
+    "accessors" : [
+        {
+            "bufferView" : 0,
+            "componentType" : 5126,
+            "count" : 8,
+            "max" : [
+                1,
+                1,
+                1
+            ],
+            "min" : [
+                -1,
+                -1,
+                -1
+            ],
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 1,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 2,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC2"
+        },
+        {
+            "bufferView" : 3,
+            "componentType" : 5123,
+            "count" : 12,
+            "type" : "SCALAR"
+        },
+        {
+            "bufferView" : 4,
+            "componentType" : 5126,
+            "count" : 8,
+            "max" : [
+                1,
+                1,
+                1
+            ],
+            "min" : [
+                -1,
+                -1,
+                -1
+            ],
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 5,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 6,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC2"
+        },
+        {
+            "bufferView" : 7,
+            "componentType" : 5123,
+            "count" : 12,
+            "type" : "SCALAR"
+        },
+        {
+            "bufferView" : 8,
+            "componentType" : 5126,
+            "count" : 8,
+            "max" : [
+                1,
+                1,
+                1
+            ],
+            "min" : [
+                -1,
+                -1,
+                -1
+            ],
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 9,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC3"
+        },
+        {
+            "bufferView" : 10,
+            "componentType" : 5126,
+            "count" : 8,
+            "type" : "VEC2"
+        },
+        {
+            "bufferView" : 11,
+            "componentType" : 5123,
+            "count" : 12,
+            "type" : "SCALAR"
+        }
+    ],
+    "bufferViews" : [
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 0
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 96
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 64,
+            "byteOffset" : 192
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 24,
+            "byteOffset" : 256
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 280
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 376
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 64,
+            "byteOffset" : 472
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 24,
+            "byteOffset" : 536
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 560
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 96,
+            "byteOffset" : 656
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 64,
+            "byteOffset" : 752
+        },
+        {
+            "buffer" : 0,
+            "byteLength" : 24,
+            "byteOffset" : 816
+        }
+    ],
+    "buffers" : [
+        {
+            "byteLength" : 840,
+            "uri" : "data:application/octet-stream;base64,AACAPwAAgD8AAIC/AACAPwAAgL8AAIC/AACAPwAAgD8AAIA/AACAPwAAgL8AAIA/AACAvwAAgD8AAIC/AACAvwAAgL8AAIC/AACAvwAAgD8AAIA/AACAvwAAgL8AAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAgPwAAAD8AAMA+AAAAPwAAID8AAIA+AADAPgAAgD4AACA/AABAPwAAwD4AAEA/AAAgPwAAAAAAAMA+AAAAAAMAAgAGAAMABgAHAAUABAAAAAUAAAABAAAAgD8AAIA/AACAvwAAgD8AAIC/AACAvwAAgD8AAIA/AACAPwAAgD8AAIC/AACAPwAAgL8AAIA/AACAvwAAgL8AAIC/AACAvwAAgL8AAIA/AACAPwAAgL8AAIC/AACAPwAAAAAAAIA/AAAAgAAAAAAAAIC/AAAAgAAAAAAAAIA/AAAAgAAAAAAAAIC/AAAAgAAAAAAAAIA/AAAAgAAAAAAAAIC/AAAAgAAAAAAAAIA/AAAAgAAAAAAAAIC/AAAAgAAAID8AAAA/AADAPgAAAD8AACA/AACAPgAAwD4AAIA+AABgPwAAAD8AAAA+AAAAPwAAYD8AAIA+AAAAPgAAgD4AAAQABgAAAAYAAgAFAAEAAwAFAAMABwAAAIA/AACAPwAAgL8AAIA/AACAvwAAgL8AAIA/AACAPwAAgD8AAIA/AACAvwAAgD8AAIC/AACAPwAAgL8AAIC/AACAvwAAgL8AAIC/AACAPwAAgD8AAIC/AACAvwAAgD8AAIA/AAAAAAAAAIAAAIA/AAAAAAAAAIAAAIA/AAAAAAAAAIAAAIA/AAAAAAAAAIAAAIC/AAAAAAAAAIAAAIC/AAAAAAAAAIAAAIC/AAAAAAAAAIAAAIC/AAAAAAAAAIAAACA/AAAAPwAAwD4AAAA/AAAgPwAAgD4AAMA+AACAPgAAID8AAEA/AADAPgAAQD8AACA/AACAPwAAwD4AAIA/BwAGAAQABwAEAAUAAQAAAAIAAQACAAMA"
+        }
+    ]
+}
diff --git a/trimesh/base.py b/trimesh/base.py
index 4a6a11c4..6c0912a8 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1888,24 +1888,31 @@ class Trimesh(Geometry3D):
         """
         # subdivide vertex attributes
         vertex_attributes = {}
+        visual = None
         if (hasattr(self.visual, 'uv') and
                 np.shape(self.visual.uv) == (len(self.vertices), 2)):
-            # only subdivide if
-            vertex_attributes['uv'] = self.visual.uv
 
-        # perform the subdivision with vertex attributes
-        vertices, faces, attr = remesh.subdivide(
-            vertices=self.vertices,
-            faces=self.faces,
-            face_index=face_index,
-            vertex_attributes=vertex_attributes)
-        # if we had texture reconstruct it here
-        visual = None
-        if 'uv' in attr:
+            # uv coords divided along with vertices
+            vertices, faces, attr = remesh.subdivide(
+                vertices=np.hstack((self.vertices, self.visual.uv)),
+                faces=self.faces,
+                face_index=face_index,
+                vertex_attributes=vertex_attributes)
+
             # get a copy of the current visuals
             visual = self.visual.copy()
-            # assign the subdivided UV's and remove them
-            visual.uv = attr.pop('uv')
+
+            # seperate uv coords and vertices
+            vertices, visual.uv = vertices[:, :3], vertices[:, 3:]
+
+        else:
+            # perform the subdivision with vertex attributes
+            vertices, faces, attr = remesh.subdivide(
+                vertices=self.vertices,
+                faces=self.faces,
+                face_index=face_index,
+                vertex_attributes=vertex_attributes)
+
         # create a new mesh
         result = Trimesh(
             vertices=vertices,
@@ -1915,6 +1922,72 @@ class Trimesh(Geometry3D):
             process=False)
         return result
 
+    def subdivide_to_size(self, max_edge, max_iter=10, return_index=False):
+        """
+        Subdivide a mesh until every edge is shorter than a
+        specified length.
+
+        Will return a triangle soup, not a nicely structured mesh.
+
+        Parameters
+        ------------
+        max_edge : float
+            Maximum length of any edge in the result
+        max_iter : int
+            The maximum number of times to run subdivision
+        return_index : bool
+            If True, return index of original face for new faces
+        """
+        # subdivide vertex attributes
+        visual = None
+        if (hasattr(self.visual, 'uv') and
+                np.shape(self.visual.uv) == (len(self.vertices), 2)):
+
+            # uv coords divided along with vertices
+            vertices_faces = remesh.subdivide_to_size(
+                vertices=np.hstack((self.vertices, self.visual.uv)),
+                faces=self.faces,
+                max_edge=max_edge,
+                max_iter=max_iter,
+                return_index=return_index)
+            # unpack result
+            if return_index:
+                vertices, faces, final_index = vertices_faces
+            else:
+                vertices, faces = vertices_faces
+
+            # get a copy of the current visuals
+            visual = self.visual.copy()
+
+            # seperate uv coords and vertices
+            vertices, visual.uv = vertices[:, :3], vertices[:, 3:]
+
+        else:
+            # uv coords divided along with vertices
+            vertices_faces = remesh.subdivide_to_size(
+                vertices=self.vertices,
+                faces=self.faces,
+                max_edge=max_edge,
+                max_iter=max_iter,
+                return_index=return_index)
+            # unpack result
+            if return_index:
+                vertices, faces, final_index = vertices_faces
+            else:
+                vertices, faces = vertices_faces
+
+        # create a new mesh
+        result = Trimesh(
+            vertices=vertices,
+            faces=faces,
+            visual=visual,
+            process=False)
+
+        if return_index:
+            return result, final_index
+
+        return result
+
     @log_time
     def smoothed(self, **kwargs):
         """
diff --git a/trimesh/curvature.py b/trimesh/curvature.py
index 19493dea..3c8acb2b 100644
--- a/trimesh/curvature.py
+++ b/trimesh/curvature.py
@@ -20,20 +20,20 @@ def face_angles_sparse(mesh):
 
     Returns
     ----------
-    sparse: scipy.sparse.coo_matrix with:
-            dtype: float
-            shape: (len(mesh.vertices), len(mesh.faces))
+    sparse : scipy.sparse.coo_matrix
+      matrix is float shaped (len(vertices), len(faces))
     """
-    matrix = coo_matrix((mesh.face_angles.flatten(),
-                         (mesh.faces_sparse.row, mesh.faces_sparse.col)),
-                        mesh.faces_sparse.shape)
+    matrix = coo_matrix((
+        mesh.face_angles.flatten(),
+        (mesh.faces_sparse.row, mesh.faces_sparse.col)),
+        mesh.faces_sparse.shape)
     return matrix
 
 
 def vertex_defects(mesh):
     """
-    Return the vertex defects, or (2*pi) minus the sum of the angles
-    of every face that includes that vertex.
+    Return the vertex defects, or (2*pi) minus the sum of the
+    angles of every face that includes that vertex.
 
     If a vertex is only included by coplanar triangles, this
     will be zero. For convex regions this is positive, and
@@ -44,25 +44,32 @@ def vertex_defects(mesh):
     vertex_defect : (len(self.vertices), ) float
                      Vertex defect at the every vertex
     """
-    angle_sum = np.asarray(mesh.face_angles_sparse.sum(axis=1)).flatten()
+    angle_sum = np.array(mesh.face_angles_sparse.sum(axis=1)).flatten()
     defect = (2 * np.pi) - angle_sum
     return defect
 
 
 def discrete_gaussian_curvature_measure(mesh, points, radius):
     """
-    Return the discrete gaussian curvature measure of a sphere centered
-    at a point as detailed in 'Restricted Delaunay triangulations and normal
-    cycle', Cohen-Steiner and Morvan.
+    Return the discrete gaussian curvature measure of a sphere
+    centered at a point as detailed in 'Restricted Delaunay
+    triangulations and normal cycle'- Cohen-Steiner and Morvan.
+
+    This is the sum of the vertex defects at all vertices
+    within the radius for each point.
 
     Parameters
     ----------
-    points : (n,3) float, list of points in space
-    radius : float, the sphere radius
+    points : (n, 3) float
+      Points in space
+    radius : float ,
+      The sphere radius, which can be zero if vertices
+      passed are points.
 
     Returns
     --------
-    gaussian_curvature: (n,) float, discrete gaussian curvature measure.
+    gaussian_curvature:  (n,) float
+      Discrete gaussian curvature measure.
     """
 
     points = np.asanyarray(points, dtype=np.float64)
@@ -77,18 +84,24 @@ def discrete_gaussian_curvature_measure(mesh, points, radius):
 
 def discrete_mean_curvature_measure(mesh, points, radius):
     """
-    Return the discrete mean curvature measure of a sphere centered
-    at a point as detailed in 'Restricted Delaunay triangulations and normal
-    cycle', Cohen-Steiner and Morvan.
+    Return the discrete mean curvature measure of a sphere
+    centered at a point as detailed in 'Restricted Delaunay
+    triangulations and normal cycle'- Cohen-Steiner and Morvan.
+
+    This is the sum of the angle at all edges contained in the
+    sphere for each point.
 
     Parameters
     ----------
-    points : (n,3) float, list of points in space
-    radius : float, the sphere radius
+    points : (n, 3) float
+      Points in space
+    radius : float
+      Sphere radius which should typically be greater than zero
 
     Returns
     --------
-    mean_curvature: (n,) float, discrete mean curvature measure.
+    mean_curvature : (n,) float
+      Discrete mean curvature measure.
     """
 
     points = np.asanyarray(points, dtype=np.float64)
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 5c2d8df3..d3215a5a 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -679,33 +679,16 @@ def _append_mesh(mesh,
         log.warning('skipping empty mesh!')
         return
 
-    # convert mesh data to the correct dtypes
-    # faces: 5125 is an unsigned 32 bit integer
-    # accessors refer to data locations
-    # mesh faces are stored as flat list of integers
-    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
-    # create or reuse an accessor for these vertices
-    acc_vertex = _data_append(acc=tree['accessors'],
-                              buff=buffer_items,
-                              blob={"componentType": 5126,
-                                    "type": "VEC3",
-                                    "byteOffset": 0},
-                              data=mesh.vertices.astype(float32))
+    primitive_map = _build_primitive_map(mesh)
+    mesh_primitives = _build_primitives(mesh=mesh,
+                                        tree=tree,
+                                        buffer_items=buffer_items,
+                                        primitive_map=primitive_map)
 
     # meshes reference accessor indexes
     # mode 4 is GL_TRIANGLES
     current = {"name": name,
-               "primitives": [{
-                   "attributes": {"POSITION": acc_vertex},
-                   "indices": acc_face,
-                   "mode": 4}]}
-
+               "primitives": mesh_primitives}
     # 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
@@ -740,12 +723,13 @@ def _append_mesh(mesh,
             "COLOR_0"] = acc_color
 
     if hasattr(mesh.visual, 'material'):
-        # append the material and then set from returned index
-        current["primitives"][0]["material"] = _append_material(
-            mat=mesh.visual.material,
-            tree=tree,
-            buffer_items=buffer_items,
-            mat_hashes=mat_hashes)
+        for primitive_idx, mat in enumerate(primitive_map.keys()):
+            # append the material and then set from returned index
+            current["primitives"][primitive_idx]["material"] = _append_material(
+                mat=mat,
+                tree=tree,
+                buffer_items=buffer_items,
+                mat_hashes=mat_hashes)
 
         # if mesh has UV coordinates defined export them
         has_uv = (hasattr(mesh.visual, 'uv') and
@@ -800,6 +784,95 @@ def _append_mesh(mesh,
     tree["meshes"].append(current)
 
 
+def _build_primitive_map(mesh):
+    """
+    Build a map for a mesh with key the material used and value the faces using it.
+
+    Parameters
+    ----------
+    mesh : trimesh.Trimesh
+        The source geometry mesh.
+
+    Returns
+    -------
+    primitives_map : collections.OrderedDict()
+        Ordered dictionary with materials and corresponding faces.
+    """
+    # If there are no face_materials defined, then all of the faces use only one material.
+    if getattr(mesh.visual, 'face_materials', None) is None:
+        return collections.OrderedDict([
+            (getattr(mesh.visual, 'material', None), mesh.faces)
+        ])
+
+    # Otherwise build an ordered dict with the faces used for each face.
+    primitive_map = collections.OrderedDict()
+    for face_idx, mat_idx in enumerate(mesh.visual.face_materials):
+        mat = mesh.visual.material.get(mat_idx)
+        if mat in primitive_map:
+            primitive_map[mat].append(mesh.faces[face_idx])
+        else:
+            primitive_map[mat] = [mesh.faces[face_idx]]
+
+    return primitive_map
+
+
+def _build_primitives(mesh,
+                      tree,
+                      buffer_items,
+                      primitive_map):
+    """
+    Append a mesh to the scene structure and put the
+    data into buffer_items.
+
+    Parameters
+    -------------
+    mesh : trimesh.Trimesh
+      Source geometry
+    tree : dict
+      Will be updated with data from mesh
+    buffer_items
+      Will have buffer appended with mesh data
+    """
+    mesh_primitives = []
+    for mat, faces in primitive_map.items():
+        vertices = collections.OrderedDict(
+            [(idx, mesh.vertices[idx]) for face in faces for idx in face]
+        )
+        vertex_map = {old_idx: new_idx for new_idx, old_idx in enumerate(vertices.keys())}
+
+        # Update the vertex indices for the faces
+        primitive_faces = np.array([
+            [vertex_map[face[0]], vertex_map[face[1]], vertex_map[face[2]]]
+            for face in faces
+        ])
+
+        # convert mesh data to the correct dtypes
+        # faces: 5125 is an unsigned 32 bit integer
+        # accessors refer to data locations
+        # mesh faces are stored as flat list of integers
+        acc_face = _data_append(acc=tree['accessors'],
+                                buff=buffer_items,
+                                blob={"componentType": 5125,
+                                      "type": "SCALAR"},
+                                data=primitive_faces.astype(uint32))
+
+        # vertices: 5126 is a float32
+        # create or reuse an accessor for these vertices
+        primitive_vertices = np.array(list(vertices.values()))
+        acc_vertex = _data_append(acc=tree['accessors'],
+                                  buff=buffer_items,
+                                  blob={"componentType": 5126,
+                                        "type": "VEC3",
+                                        "byteOffset": 0},
+                                  data=primitive_vertices.astype(float32))
+        mesh_primitives.append({
+            "attributes": {"POSITION": acc_vertex},
+            "indices": acc_face,
+            "mode": 4})
+
+    return mesh_primitives
+
+
 def _build_views(buffer_items):
     """
     Create views for buffers that are simply
@@ -1114,7 +1187,11 @@ def _parse_materials(header, views, resolver=None):
     return materials
 
 
-def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver=None):
+def _read_buffers(header,
+                  buffers,
+                  mesh_kwargs,
+                  merge_primitives=False,
+                  resolver=None):
     """
     Given a list of binary data and a layout, return the
     kwargs to create a scene object.
@@ -1191,6 +1268,8 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
     # load data from accessors into Trimesh objects
     meshes = collections.OrderedDict()
 
+    names_original = collections.defaultdict(list)
+
     for index, m in enumerate(header.get("meshes", [])):
         metadata = {}
         try:
@@ -1218,6 +1297,7 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             attr = p['attributes']
             # create a unique mesh name per- primitive
             name = m.get('name', 'GLTF')
+            names_original[index].append(name)
             # make name unique across multiple meshes
             if name in meshes:
                 name += "_" + util.unique_id(
@@ -1314,16 +1394,24 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             if len(names) <= 1:
                 mesh_prim_replace[mesh_index] = names
                 continue
-            # use the first name
-            name = names[0]
+            # use the first original name
+            name = names_original[mesh_index][0]
             # remove the other meshes after we're done looping
-            mesh_pop.extend(names[1:])
+            mesh_pop.extend(names[:])
             # collect the meshes
             # TODO : use mesh concatenation with texture support
             current = [meshes[n] for n in names]
             v_seq = [p['vertices'] for p in current]
             f_seq = [p['faces'] for p in current]
             v, f = util.append_faces(v_seq, f_seq)
+            materials = [p['visual'].material for p in current]
+            face_materials = []
+            for i, p in enumerate(current):
+                face_materials += [i] * len(p['faces'])
+            visuals = visual.texture.TextureVisuals(
+                material=visual.material.MultiMaterial(materials=materials),
+                face_materials=face_materials
+            )
             if 'metadata' in meshes[names[0]]:
                 metadata = meshes[names[0]]['metadata']
             else:
@@ -1331,6 +1419,7 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             meshes[name] = {
                 'vertices': v,
                 'faces': f,
+                'visual': visuals,
                 'metadata': metadata,
                 'process': False}
             mesh_prim_replace[mesh_index] = [name]
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index eb4f26bd..de7b8499 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -525,6 +525,17 @@ def elements_to_kwargs(elements,
             # incorrectly is a ValueError
             pass
 
+    # If texture coordinates are defined with vertices
+    if (texcoord is None and
+        'texture_u' in elements['vertex']['data'] and
+            'texture_v' in elements['vertex']['data']):
+        texture_u = elements['vertex']['data']['texture_u']
+        texture_v = elements['vertex']['data']['texture_v']
+        texcoord = np.stack((
+            texture_u[faces.reshape(-1)],
+            texture_v[faces.reshape(-1)]), axis=-1).reshape(
+                (faces.shape[0], -1))
+
     if faces is not None:
         shape = np.shape(faces)
         if len(shape) != 2:
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 03cddb53..19152350 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -48,6 +48,10 @@ def merge_vertices(mesh,
     digits_uv : int
       Number of digits to consider for UV coordinates
     """
+    # no vertices so exit early
+    if len(mesh.vertices) == 0:
+        return
+
     # use tol.merge if digit precision not passed
     if not isinstance(digits_vertex, int):
         digits_vertex = util.decimal_to_digits(tol.merge)
diff --git a/trimesh/path/exchange/load.py b/trimesh/path/exchange/load.py
index 7089d642..0b51cf8d 100644
--- a/trimesh/path/exchange/load.py
+++ b/trimesh/path/exchange/load.py
@@ -8,15 +8,15 @@ from . import misc
 from ... import util
 
 
-def load_path(obj, file_type=None, **kwargs):
+def load_path(file_obj, file_type=None, **kwargs):
     """
-    Load a file to a Path object.
+    Load a file to a Path file_object.
 
     Parameters
     -----------
-    obj : One of the following:
-         - Path, Path2D, or Path3D objects
-         - open file object (dxf or svg)
+    file_obj : One of the following:
+         - Path, Path2D, or Path3D file_objects
+         - open file file_object (dxf or svg)
          - file name (dxf or svg)
          - shapely.geometry.Polygon
          - shapely.geometry.MultiLineString
@@ -24,43 +24,43 @@ def load_path(obj, file_type=None, **kwargs):
          - (n,2,(2|3)) float, line segments
     file_type : str
         Type of file is required if file
-        object passed.
+        file_object passed.
 
     Returns
     ---------
-    path : Path, Path2D, Path3D object
-        Data as a native trimesh Path object
+    path : Path, Path2D, Path3D file_object
+        Data as a native trimesh Path file_object
     """
 
-    if isinstance(obj, Path):
-        # we have been passed a Path object so
-        # do nothing and return the passed object
-        return obj
-    elif util.is_file(obj):
-        # for open file objects use loaders
+    if isinstance(file_obj, Path):
+        # we have been passed a Path file_object so
+        # do nothing and return the passed file_object
+        return file_obj
+    elif util.is_file(file_obj):
+        # for open file file_objects use loaders
         kwargs.update(path_loaders[file_type](
-            obj, file_type=file_type))
-    elif util.is_string(obj):
-        # strings passed are evaluated as file objects
-        with open(obj, 'rb') as file_obj:
+            file_obj, file_type=file_type))
+    elif util.is_string(file_obj):
+        # strings passed are evaluated as file file_objects
+        with open(file_obj, 'rb') as file_file_obj:
             # get the file type from the extension
-            file_type = os.path.splitext(obj)[-1][1:].lower()
+            file_type = os.path.splitext(file_obj)[-1][1:].lower()
             # call the loader
             kwargs.update(path_loaders[file_type](
-                file_obj, file_type=file_type))
-    elif util.is_instance_named(obj, 'Polygon'):
+                file_file_obj, file_type=file_type))
+    elif util.is_instance_named(file_obj, 'Polygon'):
         # convert from shapely polygons to Path2D
-        kwargs.update(misc.polygon_to_path(obj))
-    elif util.is_instance_named(obj, 'MultiLineString'):
+        kwargs.update(misc.polygon_to_path(file_obj))
+    elif util.is_instance_named(file_obj, 'MultiLineString'):
         # convert from shapely LineStrings to Path2D
-        kwargs.update(misc.linestrings_to_path(obj))
-    elif isinstance(obj, dict):
+        kwargs.update(misc.linestrings_to_path(file_obj))
+    elif isinstance(file_obj, dict):
         # load as kwargs
         from ...exchange.load import load_kwargs
-        return load_kwargs(obj)
-    elif util.is_sequence(obj):
+        return load_kwargs(file_obj)
+    elif util.is_sequence(file_obj):
         # load as lines in space
-        kwargs.update(misc.lines_to_path(obj))
+        kwargs.update(misc.lines_to_path(file_obj))
     else:
         raise ValueError('Not a supported object type!')
 
diff --git a/trimesh/remesh.py b/trimesh/remesh.py
index d69013a0..a44bbc2b 100644
--- a/trimesh/remesh.py
+++ b/trimesh/remesh.py
@@ -144,8 +144,8 @@ def subdivide_to_size(vertices,
     for i in range(max_iter + 1):
         # compute the length of every triangle edge
         edge_length = (np.diff(
-            current_vertices[current_faces[:, [0, 1, 2, 0]]],
-            axis=1) ** 2).sum(axis=2) ** .5
+            current_vertices[current_faces[:, [0, 1, 2, 0]], :3],
+            axis=1) ** 2).sum(axis=2) ** 0.5
         # check edge length against maximum
         too_long = (edge_length > max_edge).any(axis=1)
         # faces that are OK
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 731ccf05..68c39788 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -649,7 +649,7 @@ def kwargs_to_matrix(
         # a matrix takes immediate precedence over other options
         return np.array(matrix, dtype=np.float64)
     elif quaternion is not None:
-        result = transformations.quaternion_matrix(quaternion)
+        matrix = transformations.quaternion_matrix(quaternion)
     elif axis is not None and angle is not None:
         matrix = transformations.rotation_matrix(angle, axis)
     else:
@@ -658,6 +658,6 @@ def kwargs_to_matrix(
     if translation is not None:
         # translation can be used in conjunction with any
         # of the methods specifying transforms
-        result[:3, 3] += translation
+        matrix[:3, 3] += translation
 
-    return result
+    return matrix
diff --git a/trimesh/version.py b/trimesh/version.py
index 889ac5bf..615d9f18 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.9.20'
+__version__ = '3.9.21'
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index 9111387b..4eb28fc0 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -46,7 +46,8 @@ class SceneViewer(pyglet.window.Window):
                  background=None,
                  window_conf=None,
                  profile=False,
-                 ** kwargs):
+                 record=False,
+                 **kwargs):
         """
         Create a window that will display a trimesh.Scene object
         in an OpenGL context via pyglet.
@@ -81,6 +82,12 @@ class SceneViewer(pyglet.window.Window):
           Color for background
         window_conf : None, or gl.Config
           Passed to window init
+        profile : bool
+          If set will run a `pyinstrument` profile for
+          every call to `on_draw` and print the output.
+        record : bool
+          If True, will save a list of `png` bytes to
+          a list located in `scene.metadata['recording']`
         kwargs : dict
           Additional arguments to pass, including
           'background' for to set background color
@@ -109,6 +116,11 @@ class SceneViewer(pyglet.window.Window):
             from pyinstrument import Profiler
             self.Profiler = Profiler
 
+        self._record = bool(record)
+        if self._record:
+            # will save bytes here
+            self.scene.metadata['recording'] = []
+
         # store kwargs
         self.kwargs = kwargs
 
@@ -797,6 +809,16 @@ class SceneViewer(pyglet.window.Window):
             profiler.stop()
             print(profiler.output_text(unicode=True, color=True))
 
+    def flip(self):
+        super(SceneViewer, self).flip()
+        if self._record:
+            # will save a PNG-encoded bytes
+            img = self.save_image(util.BytesIO())
+            # seek start of file-like object
+            img.seek(0)
+            # save the bytes from the file object
+            self.scene.metadata['recording'].append(img.read())
+
     def save_image(self, file_obj):
         """
         Save the current color buffer to a file object
@@ -808,12 +830,12 @@ class SceneViewer(pyglet.window.Window):
         """
         manager = pyglet.image.get_buffer_manager()
         colorbuffer = manager.get_color_buffer()
-
         # if passed a string save by name
         if hasattr(file_obj, 'write'):
             colorbuffer.save(file=file_obj)
         else:
             colorbuffer.save(filename=file_obj)
+        return file_obj
 
 
 def geometry_hash(geometry):
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index a3fb8620..d5c355a9 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -4,6 +4,7 @@ material.py
 
 Store visual materials as objects.
 """
+import abc
 import copy
 import numpy as np
 
@@ -13,22 +14,29 @@ from .. import grouping
 from .. import exceptions
 
 
-class Material(object):
+class Material(util.ABC):
     def __init__(self, *args, **kwargs):
         raise NotImplementedError('material must be subclassed!')
 
     def __hash__(self):
         return id(self)
 
-    @property
+    @abc.abstractproperty
     def main_color(self):
-        raise NotImplementedError('material must be subclassed!')
+        """
+        The "average" color of this material.
+
+        Returns
+        ---------
+        color : (4,) uint8
+          Average color of this material.
+        """
 
     @property
     def name(self):
         if hasattr(self, '_name'):
             return self._name
-        return 'material0'
+        return 'material_0'
 
     @name.setter
     def name(self, value):
@@ -192,6 +200,83 @@ class SimpleMaterial(Material):
                            baseColorFactor=self.diffuse)
 
 
+class MultiMaterial(Material):
+    def __init__(self, materials=None, **kwargs):
+        """
+        Wrapper for a list of Materials.
+
+        Parameters
+        ----------
+        materials : Optional[List[Material]]
+            List of materials with which the container to be initialized.
+        """
+        if materials is None:
+            self.materials = []
+        else:
+            self._materials = materials
+
+    def __hash__(self):
+        """
+            Provide a hash of the multi material so we can detect
+            duplicates.
+
+            Returns
+            ------------
+            hash : int
+              Xor hash of the contained materials.
+        """
+        hashed = np.bitwise_xor.reduce(
+            [hash(m) for m in self._materials])
+
+        return hashed
+
+    def __iter__(self):
+        return iter(self._materials)
+
+    def __next__(self):
+        return next(self._materials)
+
+    def __len__(self):
+        return len(self._materials)
+
+    @property
+    def main_color(self):
+        """
+        The "average" color of this material.
+
+        Returns
+        ---------
+        color : (4,) uint8
+          Average color of this material.
+        """
+
+    def add(self, material):
+        """
+        Adds new material to the container.
+
+        Parameters
+        ----------
+        material : Material
+            The material to be added.
+        """
+        self._materials.append(material)
+
+    def get(self, idx):
+        """
+        Get material by index.
+
+        Parameters
+        ----------
+        idx : int
+            Index of the material to be retrieved.
+
+        Returns
+        -------
+            The material on the given index.
+        """
+        return self._materials[idx]
+
+
 class PBRMaterial(Material):
     """
     Create a material for physically based rendering as
@@ -406,7 +491,8 @@ def pack(materials, uvs, deduplicate=True):
     if deduplicate:
         # start by collecting a list of indexes for each material hash
         unique_idx = collections.defaultdict(list)
-        [unique_idx[hash(m)].append(i) for i, m in enumerate(materials)]
+        [unique_idx[hash(m)].append(i)
+         for i, m in enumerate(materials)]
         # now we only need the indexes and don't care about the hashes
         mat_idx = list(unique_idx.values())
     else:
diff --git a/trimesh/visual/texture.py b/trimesh/visual/texture.py
index 5a68ef7d..380a1763 100644
--- a/trimesh/visual/texture.py
+++ b/trimesh/visual/texture.py
@@ -14,7 +14,8 @@ class TextureVisuals(Visuals):
     def __init__(self,
                  uv=None,
                  material=None,
-                 image=None):
+                 image=None,
+                 face_materials=None):
         """
         Store a single material and per-vertex UV coordinates
         for a mesh.
@@ -50,6 +51,8 @@ class TextureVisuals(Visuals):
             # if passed assign
             self.material = material
 
+        self.face_materials = face_materials
+
     def _verify_crc(self):
         """
         Dump the cache if anything in self.vertex_attributes has changed.


Test patch:
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 1f1de70f..a44622a8 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -242,6 +242,28 @@ class GLTFTest(g.unittest.TestCase):
         b = g.get_mesh('CesiumMilkTruck.glb', merge_primitives=True)
         assert len(b.geometry) == 2
 
+    def test_merge_primitives_materials(self):
+        # test to see if the `merge_primitives` logic is working
+        a = g.get_mesh('rgb_cube_with_primitives.gltf', merge_primitives=True)
+        assert len(a.geometry['Cube'].visual.material) == 3
+        assert a.geometry['Cube'].visual.face_materials == [0, 0, 0, 0, 1, 1,
+                                                            1, 1, 2, 2, 2, 2]
+
+    def test_merge_primitives_materials_roundtrip(self):
+        # test to see if gltf loaded with `merge_primitives` and then exported back
+        # to gltf, produces a valid gltf.
+        a = g.get_mesh('rgb_cube_with_primitives.gltf', merge_primitives=True)
+        result = a.export(file_type='gltf', merge_buffers=True)
+        with g.TemporaryDirectory() as d:
+            for file_name, data in result.items():
+                with open(g.os.path.join(d, file_name), 'wb') as f:
+                    f.write(data)
+
+            rd = g.trimesh.load(
+                g.os.path.join(d, 'model.gltf'), merge_primitives=True)
+            # will assert round trip is roughly equal
+            g.scene_equal(rd, a)
+
     def test_optional_camera(self):
         gltf_cameras_key = 'cameras'
 
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
index efed5d86..73329b88 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -280,6 +280,14 @@ class GroupTests(g.unittest.TestCase):
         assert g.np.allclose(g.np.unique(diff),
                              g.np.arange(8))
 
+    def test_broken(self):
+        # create a broken mesh referencing
+        # vertices that don't exist
+        mesh = g.trimesh.Trimesh(vertices=[], faces=[[0, 1, 2]])
+        # shouldn't error because there are no vertices
+        # even though faces are wrong
+        mesh.merge_vertices()
+
 
 def check_roll_wrap(**kwargs):
     """
diff --git a/tests/test_remesh.py b/tests/test_remesh.py
index 88016885..9ce238f1 100644
--- a/tests/test_remesh.py
+++ b/tests/test_remesh.py
@@ -17,6 +17,31 @@ class SubDivideTest(g.unittest.TestCase):
             assert g.np.allclose(m.area, sub.area)
             assert len(sub.faces) > len(m.faces)
 
+            max_edge = m.scale / 50
+            sub, idx = m.subdivide_to_size(max_edge=max_edge, return_index=True)
+            assert g.np.allclose(m.area, sub.area)
+            edge_len = (g.np.diff(sub.vertices[sub.edges_unique],
+                                  axis=1).reshape((-1, 3))**2).sum(axis=1)**.5
+            assert (edge_len < max_edge).all()
+
+            # should be one index per new face
+            assert len(idx) == len(sub.faces)
+            # every face should be subdivided
+            assert idx.max() == (len(m.faces) - 1)
+
+            # check the original face index using barycentric coordinates
+            epsilon = 1e-3
+            for vid in sub.faces.T:
+                # find the barycentric coordinates
+                bary = g.trimesh.triangles.points_to_barycentric(
+                    m.triangles[idx], sub.vertices[vid])
+                # if face indexes are correct they will be on the triangle
+                # which means all barycentric coordinates are between 0.0-1.0
+                assert bary.max() < (1 + epsilon)
+                assert bary.min() > -epsilon
+                # make sure it's not all zeros
+                assert bary.ptp() > epsilon
+
             v, f = g.trimesh.remesh.subdivide(
                 vertices=m.vertices,
                 faces=m.faces)
@@ -65,9 +90,19 @@ class SubDivideTest(g.unittest.TestCase):
             # volume should be the same
             assert g.np.isclose(m.volume, s.volume)
 
+            max_edge = m.scale / 50
+            s = m.subdivide_to_size(max_edge=max_edge)
+            # shouldn't have subdivided in-place
+            assert len(s.faces) > len(m.faces)
+            # area should be the same
+            assert g.np.isclose(m.area, s.area)
+            # volume should be the same
+            assert g.np.isclose(m.volume, s.volume)
+
     def test_uv(self):
         # get a mesh with texture
         m = g.get_mesh('fuze.obj')
+        # m.show()
         # get the shape of the initial mesh
         shape = m.vertices.shape
         # subdivide the mesh
@@ -80,6 +115,34 @@ class SubDivideTest(g.unittest.TestCase):
         # should have UV coordinates matching vertices
         assert s.vertices.shape[0] == s.visual.uv.shape[0]
 
+        # original UV coordinates with faces
+        ov = m.visual.uv[m.faces]
+        # subdivided mesh faces
+        sv = s.visual.uv[s.faces]
+        # both subdivided and original should have faces
+        # that don't vary wildly
+        assert ov.ptp(axis=1).mean(axis=0).max() < 0.1
+        assert sv.ptp(axis=1).mean(axis=0).max() < 0.1
+
+        max_edge = m.scale / 50
+        s = m.subdivide_to_size(max_edge=max_edge)
+
+        # shouldn't have changed source mesh
+        assert m.vertices.shape == shape
+        # subdivided mesh should have more vertices
+        assert s.vertices.shape[0] > shape[0]
+        # should have UV coordinates matching vertices
+        assert s.vertices.shape[0] == s.visual.uv.shape[0]
+
+        # original UV coordinates with faces
+        ov = m.visual.uv[m.faces]
+        # subdivided mesh faces
+        sv = s.visual.uv[s.faces]
+        # both subdivided and original should have faces
+        # that don't vary wildly
+        assert ov.ptp(axis=1).mean(axis=0).max() < 0.1
+        assert sv.ptp(axis=1).mean(axis=0).max() < 0.1
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 600760f2..37dd74e2 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -3,8 +3,6 @@ try:
 except BaseException:
     import generic as g
 
-from trimesh.scene.transforms import EnforcedForest
-
 
 def random_chr():
     return chr(ord('a') + int(round(g.np.random.random() * 25)))
@@ -349,57 +347,6 @@ class SceneTests(g.unittest.TestCase):
         assert g.np.allclose(scene.bounds, [[.5, -.5, .5], [1.5, .5, 1.5]])
 
 
-class GraphTests(g.unittest.TestCase):
-
-    def test_forest(self):
-        g = EnforcedForest()
-        for i in range(5000):
-            g.add_edge(random_chr(), random_chr())
-
-    def test_cache(self):
-        for i in range(10):
-            scene = g.trimesh.Scene()
-            scene.add_geometry(g.trimesh.creation.box())
-
-            mod = [scene.graph.modified()]
-            scene.set_camera()
-            mod.append(scene.graph.modified())
-            assert mod[-1] != mod[-2]
-
-            assert not g.np.allclose(
-                scene.camera_transform,
-                g.np.eye(4))
-            scene.camera_transform = g.np.eye(4)
-            mod.append(scene.graph.modified())
-            assert mod[-1] != mod[-2]
-
-            assert g.np.allclose(
-                scene.camera_transform,
-                g.np.eye(4))
-            assert mod[-1] != mod[-2]
-
-    def test_successors(self):
-        s = g.get_mesh('CesiumMilkTruck.glb')
-        assert len(s.graph.nodes_geometry) == 5
-
-        # world should be root frame
-        assert (s.graph.transforms.successors(
-            s.graph.base_frame) == s.graph.nodes)
-
-        for n in s.graph.nodes:
-            # successors should always return subset of nodes
-            succ = s.graph.transforms.successors(n)
-            assert succ.issubset(
-                s.graph.nodes)
-            # we self-include node in successors
-            assert n in succ
-
-        # test getting a subscene from successors
-        ss = s.subscene('3')
-        assert len(ss.geometry) == 1
-        assert len(ss.graph.nodes_geometry) == 1
-
-
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
     g.unittest.main()
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
new file mode 100644
index 00000000..e15e6dad
--- /dev/null
+++ b/tests/test_scenegraph.py
@@ -0,0 +1,89 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+from trimesh.scene.transforms import EnforcedForest
+
+
+def random_chr():
+    return chr(ord('a') + int(round(g.np.random.random() * 25)))
+
+
+class GraphTests(g.unittest.TestCase):
+
+    def test_forest(self):
+        g = EnforcedForest()
+        for i in range(5000):
+            g.add_edge(random_chr(), random_chr())
+
+    def test_cache(self):
+        for i in range(10):
+            scene = g.trimesh.Scene()
+            scene.add_geometry(g.trimesh.creation.box())
+
+            mod = [scene.graph.modified()]
+            scene.set_camera()
+            mod.append(scene.graph.modified())
+            assert mod[-1] != mod[-2]
+
+            assert not g.np.allclose(
+                scene.camera_transform,
+                g.np.eye(4))
+            scene.camera_transform = g.np.eye(4)
+            mod.append(scene.graph.modified())
+            assert mod[-1] != mod[-2]
+
+            assert g.np.allclose(
+                scene.camera_transform,
+                g.np.eye(4))
+            assert mod[-1] != mod[-2]
+
+    def test_successors(self):
+        s = g.get_mesh('CesiumMilkTruck.glb')
+        assert len(s.graph.nodes_geometry) == 5
+
+        # world should be root frame
+        assert (s.graph.transforms.successors(
+            s.graph.base_frame) == s.graph.nodes)
+
+        for n in s.graph.nodes:
+            # successors should always return subset of nodes
+            succ = s.graph.transforms.successors(n)
+            assert succ.issubset(
+                s.graph.nodes)
+            # we self-include node in successors
+            assert n in succ
+
+        # test getting a subscene from successors
+        ss = s.subscene('3')
+        assert len(ss.geometry) == 1
+        assert len(ss.graph.nodes_geometry) == 1
+
+    def test_kwargs(self):
+        # test the function that converts various
+        # arguments into a homogenous transformation
+        f = g.trimesh.scene.transforms.kwargs_to_matrix
+        # no arguments should be an identity matrix
+        assert g.np.allclose(f(), g.np.eye(4))
+
+        # a passed matrix should return immediately
+        fix = g.np.random.random((4, 4))
+        assert g.np.allclose(f(matrix=fix), fix)
+
+        quat = g.trimesh.unitize([1, 2, 3, 1])
+        trans = [1.0, 2.0, 3.0]
+        rot = g.trimesh.transformations.quaternion_matrix(quat)
+        # should be the same as passed to transformations
+        assert g.np.allclose(rot, f(quaternion=quat))
+
+        # try passing both quaternion and translation
+        combine = f(quaternion=quat, translation=trans)
+        # should be the same as passed and computed
+        assert g.np.allclose(combine[:3, :3], rot[:3, :3])
+        assert g.np.allclose(combine[:3, 3], trans)
+
+
+if __name__ == '__main__':
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()