Repository: mikedh/trimesh

Issue:
Missing texture coordinates while exporting mesh as OBJ
Hi, 

I have been trying to export a mesh in .obj format. But, the texture coordinates are missing. The wavefront .obj file consists of `v`, `vn`, `vt` and `f` values. The exported obj has only `v` and `f` values.

I tried to obtain the vertex normals, `vn`  through `trimesh.visual.ColorVisuals(mesh).vertex_colors` and uv coordinates from `mesh.visual.uv` and parsed it to the exported obj file. However, the texture is missing when visualized in blender API. Are there any options to conserve texture coordinates?

Thank you.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 861fa6a0..828b009f 100644
--- a/README.md
+++ b/README.md
@@ -195,7 +195,7 @@ You can check out the [Github network](https://github.com/mikedh/trimesh/network
 
 Quick recommendation: `GLB` or `PLY`. Every time you replace `OBJ` with `GLB` an angel gets its wings.
 
-If you want things like by-index faces, instancing, colors, textures, etc, `GLB` is a terrific choice. GLTF/GLB is an [extremely well specified](https://github.com/KhronosGroup/glTF/tree/master/specification/2.0) modern format that is easy and fast to parse: it has a JSON header describing data in a binary blob. It has a simple hierarchical scene graph, a great looking modern physically based material system, support in [dozens-to-hundreds of libraries](https://github.com/KhronosGroup/glTF/issues/1058), and a [John Carmack endorsment](https://www.khronos.org/news/press/significant-gltf-momentum-for-efficient-transmission-of-3d-scenes-models).
+If you want things like by-index faces, instancing, colors, textures, etc, `GLB` is a terrific choice. GLTF/GLB is an [extremely well specified](https://github.com/KhronosGroup/glTF/tree/master/specification/2.0) modern format that is easy and fast to parse: it has a JSON header describing data in a binary blob. It has a simple hierarchical scene graph, a great looking modern physically based material system, support in [dozens-to-hundreds of libraries](https://github.com/KhronosGroup/glTF/issues/1058), and a [John Carmack endorsment](https://www.khronos.org/news/press/significant-gltf-momentum-for-efficient-transmission-of-3d-scenes-models). Note that GLTF is a large specification, and `trimesh` only supports a subset of features: loading basic geometry is supported, NOT supported are fancier things like animations, skeletons, etc.  
 
 In the wild, `STL` is perhaps the most common format. `STL` files are extremely simple: it is basically just a list of triangles. They are robust and are a good choice for basic geometry. Binary `PLY` files are a good step up, as they support indexed faces and colors.
 
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index 790b6d41..681dc1cd 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -10,11 +10,6 @@ try:
 except BaseException:
     collada = None
 
-try:
-    import PIL.Image
-except ImportError:
-    pass
-
 from .. import util
 from .. import visual
 
@@ -257,8 +252,10 @@ def _load_texture(file_name, resolver):
     """
     Load a texture from a file into a PIL image.
     """
+    from PIL import Image
+
     file_data = resolver.get(file_name)
-    image = PIL.Image.open(util.wrap_as_stream(file_data))
+    image = Image.open(util.wrap_as_stream(file_data))
     return image
 
 
diff --git a/trimesh/exchange/export.py b/trimesh/exchange/export.py
index 5f412287..f409083a 100644
--- a/trimesh/exchange/export.py
+++ b/trimesh/exchange/export.py
@@ -4,6 +4,7 @@ import numpy as np
 
 from ..constants import log
 from .. import util
+from .. import resolvers
 
 from .urdf import export_urdf  # NOQA
 from .gltf import export_glb, export_gltf
@@ -15,7 +16,7 @@ from .dae import _collada_exporters
 from .xyz import _xyz_exporters
 
 
-def export_mesh(mesh, file_obj, file_type=None, **kwargs):
+def export_mesh(mesh, file_obj, file_type=None, resolver=None, **kwargs):
     """
     Export a Trimesh object to a file- like object, or to a filename
 
@@ -25,6 +26,8 @@ def export_mesh(mesh, file_obj, file_type=None, **kwargs):
       Where should mesh be exported to
     file_type : str or None
       Represents file type (eg: 'stl')
+    resolver : None or trimesh.resolvers.Resolver
+      Resolver to write referenced assets to
 
     Returns
     ----------
@@ -48,6 +51,9 @@ def export_mesh(mesh, file_obj, file_type=None, **kwargs):
             # get full path of file before opening
             file_path = os.path.abspath(os.path.expanduser(file_obj))
             file_obj = open(file_path, 'wb')
+            if resolver is None:
+                # create a resolver which can write files to the path
+                resolver = resolvers.FilePathResolver(file_path)
 
     # make sure file type is lower case
     file_type = str(file_type).lower()
@@ -65,6 +71,10 @@ def export_mesh(mesh, file_obj, file_type=None, **kwargs):
         # if the mesh has faces log the number
         log.debug('Exporting %d faces as %s', len(mesh.faces),
                   file_type.upper())
+
+    # OBJ files save assets everywhere
+    if file_type == 'obj':
+        kwargs['resolver'] = resolver
     export = _mesh_exporters[file_type](mesh, **kwargs)
 
     if hasattr(file_obj, 'write'):
@@ -92,10 +102,10 @@ def export_dict(mesh, encoding=None):
 
     Parameters
     ------------
-    mesh : Trimesh object
-             Mesh to be exported
-    encoding : str, or None
-                 'base64'
+    mesh : trimesh.Trimesh
+      Mesh to be exported
+    encoding : str or None
+      Such as 'base64'
 
     Returns
     -------------
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index c285ff97..c15c9c93 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -626,9 +626,8 @@ def _append_mesh(mesh,
             # immediately add UV data so bufferView indices are correct
             buffer_items.append(uv_data)
 
-    if (include_normals or
-        (include_normals is None and
-         'vertex_normals' in mesh._cache.cache)):
+    if (include_normals or (include_normals is None and
+                            'vertex_normals' in mesh._cache.cache)):
         # add the reference for vertex color
         tree["meshes"][-1]["primitives"][0]["attributes"][
             "NORMAL"] = len(tree["accessors"])
@@ -1009,7 +1008,8 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             if len(m["primitives"]) > 1:
                 name += "_{}".format(j)
 
-            custom_attrs = [attr for attr in p["attributes"] if attr.startswith("_")]
+            custom_attrs = [attr for attr in p["attributes"]
+                            if attr.startswith("_")]
             if len(custom_attrs):
                 vertex_attributes = {}
                 for attr in custom_attrs:
@@ -1032,8 +1032,12 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             if len(names) <= 1:
                 mesh_prim_replace[mesh_index] = names
                 continue
-            name = '_'.join(names[0].split('_')[:-1])
-            mesh_pop.extend(set(names).difference({name}))
+            # use the first name
+            name = names[0]
+            # remove the other meshes after we're done looping
+            mesh_pop.extend(names[1:])
+            # 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]
@@ -1111,8 +1115,8 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
         # parent -> child relationships have matrix stored in child
         # for the transform from parent to child
         if "matrix" in child:
-            kwargs["matrix"] = np.array(child["matrix"],
-                                        dtype=np.float64).reshape((4, 4)).T
+            kwargs["matrix"] = np.array(
+                child["matrix"], dtype=np.float64).reshape((4, 4)).T
         else:
             # if no matrix set identity
             kwargs["matrix"] = np.eye(4)
@@ -1123,37 +1127,38 @@ def _read_buffers(header, buffers, mesh_kwargs, merge_primitives=False, resolver
             kwargs["matrix"] = np.dot(
                 kwargs["matrix"],
                 transformations.translation_matrix(child["translation"]))
-
         if "rotation" in child:
             # GLTF rotations are stored as (4,) XYZW unit quaternions
             # we need to re- order to our quaternion style, WXYZ
             quat = np.reshape(child["rotation"], 4)[[3, 0, 1, 2]]
-
             # add the rotation to the matrix
             kwargs["matrix"] = np.dot(
                 kwargs["matrix"], transformations.quaternion_matrix(quat))
-
         if "scale" in child:
             # add scale to the matrix
             kwargs["matrix"] = np.dot(
                 kwargs["matrix"],
                 np.diag(np.concatenate((child['scale'], [1.0]))))
 
-        # append the nodes for connectivity without the mesh
-        graph.append(kwargs.copy())
         if "mesh" in child:
-            # append a new node per- geometry instance
-            geometries = mesh_prim[child["mesh"]]
-            for name in geometries:
-                kwargs["geometry"] = name
-                if 'name' in child:
-                    kwargs['frame_to'] = names[b]
+            # if the node has a mesh associated with it
+            for i, geom_name in enumerate(mesh_prim[child["mesh"]]):
+                # save the name of the geometry
+                kwargs["geometry"] = geom_name
+                if i == 0:
+                    # for the first primitive just use the frame name
+                    frame_to = names[b]
                 else:
-                    kwargs["frame_to"] = "{}_{}".format(
-                        name, util.unique_id(
-                            length=6, increment=len(graph)).upper())
+                    # if we have more than one primitive assign a new UUID
+                    # frame name for the primitives after the first one
+                    frame_to = '{}_{}'.format(
+                        names[b], util.unique_id(length=6))
+                kwargs['frame_to'] = frame_to
                 # append the edge with the mesh frame
                 graph.append(kwargs.copy())
+        else:
+            # if the node doesn't have any geometry just add
+            graph.append(kwargs)
 
     # kwargs for load_kwargs
     result = {"class": "Scene",
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index 9bdf0695..ab33a7e8 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -710,7 +710,10 @@ def _preprocess_faces(text, split_object=False):
 def export_obj(mesh,
                include_normals=True,
                include_color=True,
-               include_texture=False,
+               include_texture=True,
+               return_texture=False,
+               write_texture=True,
+               resolver=None,
                digits=8):
     """
     Export a mesh as a Wavefront OBJ file.
@@ -725,14 +728,17 @@ def export_obj(mesh,
     include_color : bool
       Include vertex color in export
     include_texture bool
-      Include texture in export?
-      False by default as it will change the return
-      values to include files that must be saved in the
-      same location as the exported mesh.
+      Include `vt` texture in file text
+    return_texture : bool
+      If True, return a dict with texture files
+    write_texture : bool
+      If True and a writable resolver is passed
+      write the referenced texture files with resolver
+    resolver : None or trimesh.resolvers.Resolver
+      Resolver which can write referenced text objects
     digits : int
       Number of digits to include for floating point
 
-
     Returns
     -----------
     export : str
@@ -831,8 +837,14 @@ def export_obj(mesh,
     objects.appendleft('# https://github.com/mikedh/trimesh')
     # combine elements into a single string
     text = '\n'.join(objects)
+
+    # if we have a resolver and have asked to write texture
+    if write_texture and resolver is not None and tex_data is not None:
+        # not all resolvers have a write method
+        [resolver.write(k, v) for k, v in tex_data.items()]
+
     # if we exported texture it changes returned values
-    if include_texture and tex_data is not None:
+    if return_texture:
         return text, tex_data
 
     return text
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index 3372b513..4f62ca07 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -31,8 +31,8 @@ class RectangleBin:
             self.bounds = np.asanyarray(bounds,
                                         dtype=np.float64)
         elif size is not None:
-            self.bounds = np.append([0.0, 0.0],
-                                    size).astype(np.float64)
+            self.bounds = np.append(
+                [0.0, 0.0], size).astype(np.float64)
         else:
             raise ValueError('need to pass size or bounds!')
 
@@ -43,10 +43,11 @@ class RectangleBin:
 
         Returns
         ----------
-        extents: (2,) float, edge lengths of bounding box
+        extents : (2,) float
+          Edge lengths of bounding box
         """
-        extents = np.subtract(*self.bounds.reshape((2, 2))[::-1])
-        return extents
+        bounds = self.bounds
+        return bounds[2:] - bounds[:2]
 
     def insert(self, rectangle):
         """
@@ -54,35 +55,43 @@ class RectangleBin:
 
         Parameters
         -------------
-        rectangle: (2,) float, size of rectangle to insert
-        """
-        rectangle = np.asanyarray(rectangle, dtype=np.float64)
+        rectangle : (2,) float
+          Size of rectangle to insert
 
+        Returns
+        ----------
+        inserted : None or (2,) float
+          Position of insertion in the tree
+        """
         for child in self.child:
             if child is not None:
+                # try inserting into child cells
                 attempt = child.insert(rectangle)
                 if attempt is not None:
                     return attempt
 
+        # can't insert into occupied cells
         if self.occupied:
             return None
 
         # compare the bin size to the insertion candidate size
-        size_test = self.extents - rectangle
+        bounds = self.bounds
+        # manually compute extents here to avoid function call
+        size_test = (bounds[2:] - bounds[:2]) - rectangle
 
         # this means the inserted rectangle is too big for the cell
-        if np.any(size_test < -tol.zero):
+        if any(size_test < -tol.zero):
             return None
 
         # since the cell is big enough for the current rectangle, either it
         # is going to be inserted here, or the cell is going to be split
-        # either way, the cell is now occupied.
+        # either way the cell is now occupied.
         self.occupied = True
 
         # this means the inserted rectangle fits perfectly
         # since we already checked to see if it was negative, no abs is needed
-        if np.all(size_test < tol.zero):
-            return self.bounds[0:2]
+        if all(size_test < tol.zero):
+            return self.bounds[:2]
 
         # since the rectangle fits but the empty space is too big,
         # we need to create some children to insert into
@@ -91,6 +100,7 @@ class RectangleBin:
         length = rectangle[int(not vertical)]
         child_bounds = self.split(length, vertical)
 
+        # create the child objects
         self.child[0] = RectangleBin(bounds=child_bounds[0])
         self.child[1] = RectangleBin(bounds=child_bounds[1])
 
@@ -103,29 +113,31 @@ class RectangleBin:
 
         Parameters
         -------------
-        length:   float, length to split
-        vertical: bool, if True will split box vertically
+        length : float
+          Length to split
+        vertical: bool
+          Split the box vertically rather than horizontally
 
         Returns
         -------------
-        box: (2,4) float, two bounding boxes consisting of:
-                          [minx, miny, maxx, maxy]
+        box : (2, 4) float
+          Two bounding boxes with min-max:
+            [minx, miny, maxx, maxy]
         """
         # also know as [minx, miny, maxx, maxy]
-        [left, bottom, right, top] = self.bounds
+        (left, bottom, right, top) = self.bounds
         if vertical:
-            box = [[left, bottom, left + length, top],
-                   [left + length, bottom, right, top]]
+            return [[left, bottom, left + length, top],
+                    [left + length, bottom, right, top]]
         else:
-            box = [[left, bottom, right, bottom + length],
-                   [left, bottom + length, right, top]]
-        return box
+            return [[left, bottom, right, bottom + length],
+                    [left, bottom + length, right, top]]
 
 
-def pack_rectangles(rectangles, sheet_size, shuffle=False):
+def rectangles_single(rectangles, sheet_size=None, shuffle=False):
     """
-    Pack smaller rectangles onto a larger rectangle, using a binary
-    space partition tree.
+    Execute a single insertion order of smaller rectangles onto
+    a larger rectangle using a binary space partition tree.
 
     Parameters
     ----------
@@ -156,11 +168,17 @@ def pack_rectangles(rectangles, sheet_size, shuffle=False):
     area = 0.0
     density = 0.0
 
+    # if no sheet size specified, make a large one
+    if sheet_size is None:
+        sheet_size = [rectangles[:, 0].sum(),
+                      rectangles[:, 1].max() * 2]
+
     if shuffle:
         shuffle_len = int(np.random.random() * len(rectangles)) - 1
         box_order[0:shuffle_len] = np.random.permutation(
             box_order[0:shuffle_len])
 
+    # start the tree
     sheet = RectangleBin(size=sheet_size)
     for index in box_order:
         insert_location = sheet.insert(rectangles[index])
@@ -168,14 +186,13 @@ def pack_rectangles(rectangles, sheet_size, shuffle=False):
             area += np.prod(rectangles[index])
             offset[index] += insert_location
             inserted[index] = True
-
     consumed_box = np.max((offset + rectangles)[inserted], axis=0)
     density = area / np.product(consumed_box)
 
     return density, offset[inserted], inserted, consumed_box
 
 
-def pack_paths(paths, **kwargs):
+def paths(paths, **kwargs):
     """
     Pack a list of Path2D objects into a rectangle.
 
@@ -201,12 +218,12 @@ def pack_paths(paths, **kwargs):
             quantity.append(1)
 
     # pack using exterior polygon (will OBB)
-    polygons = [i.polygons_closed[i.root[0]] for i in paths]
+    packable = [i.polygons_closed[i.root[0]] for i in paths]
 
     # pack the polygons using rectangular bin packing
-    inserted, transforms = multipack(polygons=polygons,
-                                     quantity=quantity,
-                                     **kwargs)
+    inserted, transforms = polygons(polygons=packable,
+                                    quantity=quantity,
+                                    **kwargs)
 
     multi = []
     for i, T in zip(inserted, transforms):
@@ -218,12 +235,12 @@ def pack_paths(paths, **kwargs):
     return packed, inserted
 
 
-def multipack(polygons,
-              sheet_size=None,
-              iterations=50,
-              density_escape=.95,
-              spacing=0.094,
-              quantity=None):
+def polygons(polygons,
+             sheet_size=None,
+             iterations=50,
+             density_escape=.95,
+             spacing=0.094,
+             quantity=None):
     """
     Pack polygons into a rectangle by taking each Polygon's OBB
     and then packing that as a rectangle.
@@ -261,13 +278,13 @@ def multipack(polygons,
         raise ValueError('quantity must match polygons')
 
     # find the oriented bounding box of the polygons
-    obb, rectangles = polygons_obb(polygons)
+    obb, rect = polygons_obb(polygons)
 
     # pad all sides of the rectangle
-    rectangles += 2.0 * spacing
+    rect += 2.0 * spacing
     # move the OBB transform so the polygon is centered
     # in the padded rectangle
-    for i, r in enumerate(rectangles):
+    for i, r in enumerate(rect):
         obb[i][0:2, 2] += r * .5
 
     # for polygons occurring multiple times
@@ -275,46 +292,152 @@ def multipack(polygons,
                          for i, q in enumerate(quantity)])
     # stack using advanced indexing
     obb = obb[indexes]
-    rectangles = rectangles[indexes]
+    rect = rect[indexes]
 
     # store timing
     tic = time.time()
-    overall_density = 0.0
+    density = 0.0
 
     # if no sheet size specified, make a large one
     if sheet_size is None:
-        max_dim = np.max(rectangles, axis=0)
-        sum_dim = np.sum(rectangles, axis=0)
-        sheet_size = [sum_dim[0], max_dim[1] * 2]
-
-    log.debug('packing %d polygons', len(polygons))
-    # run packing for a number of iterations, shuffling insertion order
-    for i in range(iterations):
-        (density,
-         offset,
-         inserted,
-         sheet) = pack_rectangles(rectangles,
-                                  sheet_size=sheet_size,
-                                  shuffle=(i != 0))
-        if density > overall_density:
-            overall_density = density
-            overall_offset = offset
-            overall_inserted = inserted
-            if density > density_escape:
-                break
+        sheet_size = [rect[:, 0].sum(),
+                      rect[:, 1].max() * 2]
+
+    # run packing for a number of iterations
+    (density,
+     offset,
+     inserted,
+     sheet) = rectangles(
+         rectangles=rect,
+         sheet_size=sheet_size,
+         spacing=spacing,
+         density_escape=density_escape,
+         iterations=iterations)
 
     toc = time.time()
     log.debug('packing finished %i iterations in %f seconds',
               i + 1,
               toc - tic)
     log.debug('%i/%i parts were packed successfully',
-              np.sum(overall_inserted),
+              np.sum(inserted),
               quantity.sum())
-    log.debug('final rectangular density is %f.', overall_density)
 
     # transformations to packed positions
-    packed = obb[overall_inserted]
+    packed = obb[inserted]
     # apply the offset and inter- polygon spacing
-    packed.reshape(-1, 9)[:, [2, 5]] += overall_offset + spacing
+    packed.reshape(-1, 9)[:, [2, 5]] += offset + spacing
+
+    return indexes[inserted], packed
+
+
+def rectangles(rectangles,
+               sheet_size=None,
+               density_escape=0.9,
+               spacing=0.0,
+               iterations=50):
+    """
+    Run multiple iterations of rectangle packing.
+
+    Parameters
+    ------------
+    rectangles : (n, 2) float
+      Size of rectangles to be packed
+    sheet_size : None or (2,) float
+      Size of sheet to pack onto
+    density_escape : float
+      Exit early if density is above this threshold
+    spacing : float
+      Distance to allow between rectangles
+    iterations : int
+      Number of iterations to run
+
+    Returns
+    ---------
+    density : float
+      Area filled over total sheet area
+    offset :  (m,2) float
+      Offsets to move rectangles to their packed location
+    inserted : (n,) bool
+      Which of the original rectangles were packed
+    consumed_box : (2,) float
+      Bounding box size of packed result
+    """
+    rectangles = np.array(rectangles)
+
+    # best density percentage in 0.0 - 1.0
+    best_density = 0.0
+    # how many rectangles were inserted
+    best_insert = 0
+    # if no sheet size specified, make a large one
+    if sheet_size is None:
+        sheet_size = [rectangles[:, 0].sum(),
+                      rectangles[:, 1].max() * 2]
+
+    for i in range(iterations):
+        # run a single insertion order
+        # don't shuffle the first run, shuffle subsequent runs
+        packed = rectangles_single(
+            rectangles,
+            sheet_size=sheet_size,
+            shuffle=(i != 0))
+        density = packed[0]
+        insert = packed[2].sum()
+
+        # compare this packing density against our best
+        if density > best_density or insert > best_insert:
+            best_density = density
+            best_insert = insert
+            # save the result
+            result = packed
+            # exit early if everything is inserted and
+            # we have exceeded our target density
+            if density > density_escape and packed[2].all():
+                break
+
+    return result
+
+
+def images(images, power_resize=False):
+    """
+    Pack a list of images and return result and offsets.
+
+    Parameters
+    ------------
+    images : (n,) PIL.Image
+      Images to be packed
+    deduplicate : bool
+      If True deduplicate images before packing
+
+    Returns
+    -----------
+    packed : PIL.Image
+      Multiple images packed into result
+    offsets : (n, 2) int
+       Offsets for original image to pack
+    """
+    from PIL import Image
+
+    # use the number of pixels as the rectangle size
+    rect = np.array([i.size for i in images])
+
+    (density,
+     offset,
+     insert,
+     sheet) = rectangles(rectangles=rect)
+    # really should have inserted all the rectangles
+    assert insert.all()
+
+    # offsets should be integer multiple of pizels
+    offset = offset.round().astype(int)
+
+    size = sheet.round().astype(int)
+    if power_resize:
+        size = (2 ** np.ceil(np.log2(size))).astype(np.int64)
+
+    # create the image
+    result = Image.new('RGB', tuple(size))
+    # paste each image into the result
+    for img, off in zip(images, offset):
+        result.paste(img, tuple(off))
 
-    return indexes[overall_inserted], packed
+    return result, offset
diff --git a/trimesh/resolvers.py b/trimesh/resolvers.py
index fe1c7e9e..8da1e599 100644
--- a/trimesh/resolvers.py
+++ b/trimesh/resolvers.py
@@ -8,6 +8,7 @@ archives, web assets, or a local file path.
 """
 
 import os
+from . import util
 
 # URL parsing for remote resources via WebResolver
 try:
@@ -26,6 +27,10 @@ class Resolver(object):
     def __init__(self, *args, **kwargs):
         raise NotImplementedError('Use a resolver subclass!')
 
+    def write(self, name, data):
+        raise NotImplementedError('write not implemented for {}'.format(
+            self.__class__.__name__))
+
     def __getitem__(self, key):
         return self.get(key)
 
@@ -71,11 +76,26 @@ class FilePathResolver(Resolver):
           Loaded data from asset
         """
         # load the file by path name
-        with open(os.path.join(self.parent,
-                               name.strip()), 'rb') as f:
+        with open(os.path.join(self.parent, name.strip()), 'rb') as f:
             data = f.read()
         return data
 
+    def write(self, name, data):
+        """
+        Write an asset to a file path.
+
+        Parameters
+        -----------
+        name : str
+          Name of the file to write
+        data : str or bytes
+          Data to write to the file
+        """
+        # write files to path name
+        with open(os.path.join(self.parent, name.strip()), 'wb') as f:
+            # handle encodings correctly for str/bytes
+            util.write_encoded(file_obj=f, stuff=data)
+
 
 class ZipResolver(Resolver):
     """
diff --git a/trimesh/util.py b/trimesh/util.py
index 7ed4e0a4..4732daee 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1374,12 +1374,16 @@ def concatenate(a, b=None):
 
     Parameters
     ------------
-    a: Trimesh object, or list of such
-    b: Trimesh object, or list of such
+    a : trimesh.Trimesh
+      Mesh or list of meshes to be concatenated
+      object, or list of such
+    b : trimesh.Trimesh
+      Mesh or list of meshes to be concatenated
 
     Returns
     ----------
-    result: Trimesh object containing concatenated mesh
+    result : trimesh.Trimesh
+      Concatenated mesh
     """
     if b is None:
         b = []
diff --git a/trimesh/version.py b/trimesh/version.py
index 6b504209..a83d3ed3 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.6.38'
+__version__ = '3.6.39'
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 9d482355..682fc53b 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -438,6 +438,12 @@ class ColorVisuals(Visuals):
 
         return color
 
+    def to_texture(self):
+        from .materials import from_color
+        from .texture import TextureVisuals
+        mat, uv = from_color(vertex_colors=self.vertex_colors)
+        return TextureVisuals(material=mat, uv=uv)
+
     def concatenate(self, other, *args):
         """
         Concatenate two or more ColorVisuals objects into a single object.
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index a15ba5ef..9a750927 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -8,6 +8,7 @@ import numpy as np
 
 from . import color
 from .. import util
+from .. import grouping
 
 
 class Material(object):
@@ -253,10 +254,39 @@ class PBRMaterial(Material):
         result = color.to_rgba(self.baseColorFactor)
         return result
 
+    def __hash__(self):
+        """
+        Provide a hash of the material so we can detect
+        duplicate materials.
 
-def empty_material():
+        Returns
+        ------------
+        hash : int
+          Hash of image and parameters
+        """
+        if hasattr(self.baseColorTexture, 'tobytes'):
+            # start with hash of raw image bytes
+            hashed = hash(self.baseColorTexture.tobytes())
+        else:
+            # otherwise start with zero
+            hashed = 0
+        # we will add additional parameters with
+        # an in-place xor of the additional value
+        # if stored as numpy arrays add parameters
+        if hasattr(self.baseColorFactor, 'tobytes'):
+            hashed ^= hash(self.baseColorFactor.tobytes())
+
+        return hashed
+
+
+def empty_material(color=None):
     """
-    Return an empty material.
+    Return an empty material set to a single color
+
+    Parameters
+    -----------
+    color : None or (3,) uint8
+      RGB color
 
     Returns
     -------------
@@ -264,7 +294,114 @@ def empty_material():
       Image is a a one pixel RGB
     """
     from PIL import Image
-
+    if color is None or np.shape(color) not in ((3,), (4,)):
+        color = np.array([255, 255, 255], dtype=np.uint8)
+    else:
+        color = np.array(color, dtype=np.uint8)[:3]
     # create a one pixel RGB image
-    image = Image.new(mode='RGB', size=(1, 1))
+    image = Image.fromarray(
+        np.tile(color, (4, 1)).reshape((2, 2, 3)))
     return SimpleMaterial(image=image)
+
+
+def from_color(vertex_colors):
+    """
+    Convert vertex colors into UV coordinates and materials.
+
+    TODO : pack colors
+
+    Parameters
+    ------------
+    vertex_colors : (n, 3) float
+      Array of vertex colors
+
+    Returns
+    ------------
+    material : SimpleMaterial
+      Material containing color information
+    uvs : (n, 2) float
+      UV coordinates
+    """
+    unique, inverse = grouping.unique_rows(vertex_colors)
+    # TODO : tile colors nicely
+    material = empty_material(color=vertex_colors[unique[0]])
+    uvs = np.zeros((len(vertex_colors), 2)) + 0.5
+
+    return material, uvs
+
+
+def pack(materials, uvs, deduplicate=True):
+    """
+    Pack multiple materials with texture into a single material.
+
+    Parameters
+    -----------
+    materials : (n,) Material
+      List of multiple materials
+    uvs : (n, m, 2) float
+      Original UV coordinates
+
+    Returns
+    ------------
+    material : Material
+      Combined material
+    uv : (p, 2) float
+      Combined UV coordinates
+    """
+
+    from PIL import Image
+    from ..path import packing
+    import collections
+
+    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)]
+        # now we only need the indexes and don't care about the hashes
+        mat_idx = list(unique_idx.values())
+    else:
+        # otherwise just use all the indexes
+        mat_idx = np.arange(len(materials)).reshape((-1, 1))
+
+    # store the images to combine later
+    images = []
+    # first collect the images from the materials
+    for idx in mat_idx:
+        # get the first material from the group
+        m = materials[idx[0]]
+        # extract an image for each material
+        if isinstance(m, PBRMaterial):
+            if m.baseColorTexture is not None:
+                img = m.baseColorTexture
+            elif m.baseColorFactor is not None:
+                img = Image.fromarray(m.baseColorFactor[:3].reshape((1, 1, 3)))
+            else:
+                img = Image.new(mode='RGB', size=(1, 1))
+        elif hasattr(m, 'image'):
+            img = m.image
+        else:
+            raise ValueError('no image to pack!')
+        images.append(img)
+
+    # pack the multiple images into a single large image
+    final, offsets = packing.images(images, power_resize=True)
+
+    # the size of the final texture image
+    final_size = np.array(final.size, dtype=np.float64)
+    # collect scaled new UV coordinates
+    new_uv = []
+
+    for idxs, img, off in zip(mat_idx, images, offsets):
+        # how big was the original image
+        scale = img.size / final_size
+        # what is the offset in fractions of final image
+        uv_off = off / final_size
+        # scale and translate each of the new UV coordinates
+        # [new_uv.append((uvs[i] * scale) + uv_off) for i in idxs]
+        # TODO : figure out why this is broken sometimes...
+        [new_uv.append((uvs[i] * scale) + uv_off) for i in idxs]
+
+    # stack UV coordinates into single (n, 2) array
+    stacked = np.vstack(new_uv)
+
+    return SimpleMaterial(image=final), stacked
diff --git a/trimesh/visual/objects.py b/trimesh/visual/objects.py
index b513f2be..fb3b910c 100644
--- a/trimesh/visual/objects.py
+++ b/trimesh/visual/objects.py
@@ -7,8 +7,9 @@ ColorVisuals and TextureVisuals.
 """
 import numpy as np
 
+from .material import from_color, pack
+from .texture import TextureVisuals
 from .color import ColorVisuals
-from ..util import log
 
 
 def create_visual(**kwargs):
@@ -16,14 +17,18 @@ def create_visual(**kwargs):
     Create Visuals object from keyword arguments.
 
     Parameters
-    ----------
-    face_colors   :   (n,3|4) uint8, colors
-    vertex_colors : (n,3|4) uint8, colors
-    mesh:          Trimesh object
+    -----------
+    face_colors : (n, 3|4) uint8
+      Face colors
+    vertex_colors : (n, 3|4) uint8
+      Vertex colors
+    mesh : trimesh.Trimesh
+      Mesh object
 
     Returns
     ----------
-    visuals: ColorVisuals object.
+    visuals : ColorVisuals
+      Visual object created from arguments
     """
     return ColorVisuals(**kwargs)
 
@@ -34,32 +39,56 @@ def concatenate(visuals, *args):
 
     Parameters
     ----------
-    visuals: ColorVisuals object, or list of same
-    *args:  ColorVisuals object, or list of same
+    visuals : ColorVisuals or list
+      Visuals to concatenate
+    *args :  ColorVisuals or list
+      More visuals to concatenate
 
     Returns
     ----------
-    concat: ColorVisuals object
+    concat : Visuals
+      If all are color
     """
-    # get a flat list of ColorVisuals objects
+    # get a flat list of Visuals objects
     if len(args) > 0:
         visuals = np.append(visuals, args)
     else:
         visuals = np.array(visuals)
 
-    try:
-        # get the mode of the first visual
-        mode = visuals[0].kind
-        if mode == 'face':
-            colors = np.vstack([
-                v.face_colors for v in visuals])
-            return ColorVisuals(face_colors=colors)
-        elif mode == 'vertex':
-            colors = np.vstack([
-                v.vertex_colors for v in visuals])
-            return ColorVisuals(vertex_colors=colors)
-    except BaseException:
-        log.warning('failed to concatenate visuals!',
-                    exc_info=True)
+    # if there are any texture visuals convert all to texture
+    if any(v.kind == 'texture' for v in visuals):
+        # first collect materials and UV coordinates
+        mats = []
+        uvs = []
+        for v in visuals:
+            if v.kind == 'texture':
+                mats.append(v.material)
+                if v.uv is None:
+                    # otherwise use zeros
+                    uvs.append(np.zeros((len(v.mesh.vertices), 2)) + 0.5)
+                else:
+                    # if uvs are of correct shape use them
+                    uvs.append(v.uv)
+
+            else:
+                # create a material and UV coordinates from vertex colors
+                color_mat, color_uv = from_color(
+                    vertex_colors=v.vertex_colors)
+                mats.append(color_mat)
+                uvs.append(color_uv)
+        # pack the materials and UV coordinates into one
+        new_mat, new_uv = pack(materials=mats, uvs=uvs)
+        return TextureVisuals(material=new_mat, uv=new_uv)
+
+    # convert all visuals to the kind of the first
+    kind = visuals[0].kind
+    if kind == 'face':
+        colors = np.vstack([
+            v.face_colors for v in visuals])
+        return ColorVisuals(face_colors=colors)
+    elif kind == 'vertex':
+        colors = np.vstack([
+            v.vertex_colors for v in visuals])
+        return ColorVisuals(vertex_colors=colors)
 
     return ColorVisuals()
diff --git a/trimesh/visual/texture.py b/trimesh/visual/texture.py
index 9591d5c5..8d6858b4 100644
--- a/trimesh/visual/texture.py
+++ b/trimesh/visual/texture.py
@@ -177,9 +177,24 @@ class TextureVisuals(Visuals):
         """
         pass
 
-    def concatenate(self, *args, **kwargs):
-        util.log.warning('returning empty visuals!')
-        return TextureVisuals()
+    def concatenate(self, others):
+        """
+        Concatenate this TextureVisuals object with others
+        and return the result without modifying this visual.
+
+        Parameters
+        -----------
+        others : (n,) Visuals
+          Other visual objects to concatenate
+
+        Returns
+        -----------
+        concatenated : TextureVisuals
+          Concatenated visual objects
+        """
+        util.log.warning('concatenating texture: may result in visual artifacts')
+        from .objects import concatenate
+        return concatenate(self, others)
 
 
 def unmerge_faces(faces, *args):


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index f7c53e3b..cd274e73 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -339,6 +339,28 @@ def scene_equal(a, b):
             m.volume, b.geometry[k].volume, rtol=0.001)
 
 
+def check_fuze(fuze):
+    """
+    Check the classic textured mesh: a fuze bottle
+    """
+    # these loaded fuze bottles should have textures
+    assert isinstance(
+        fuze.visual, trimesh.visual.TextureVisuals)
+    # image should be loaded with correct resolution
+    assert fuze.visual.material.image.size == (1024, 1024)
+    # UV coordinates should be unmerged correctly
+    assert len(fuze.visual.uv) == 664
+    # UV coordinates shouldn't be all zero- ish
+    assert fuze.visual.uv[:, :2].ptp(axis=0).min() > 0.1
+    # vertices should correspond with UV
+    assert fuze.vertices.shape == (664, 3)
+    # convert TextureVisuals to ColorVisuals
+    viz = fuze.visual.to_color()
+    assert viz.kind == 'vertex'
+    # should be actual colors defined
+    assert viz.vertex_colors.ptp(axis=0).ptp() != 0
+
+
 TemporaryDirectory = trimesh.util.TemporaryDirectory
 
 # all the JSON files with truth data
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index f0724259..5a54b59d 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -352,10 +352,23 @@ class GLTFTest(g.unittest.TestCase):
         assert all(r.metadata['extras'][k] == v
                    for k, v in dummy.items())
 
-    def test_samename(self):
+    def test_same_name(self):
         s = g.get_mesh('TestScene.gltf')
-        assert len(s.graph.nodes_geometry) == 7
+        # hardcode correct bounds to check against
+        bounds = g.np.array([[-5., -1.82578002, -5.],
+                             [5., 1.86791301, 5.]])
+
+        # icosahedrons have two primitives each
+        print(len(s.geometry), len(s.graph.nodes_geometry))
+        assert len(s.graph.nodes_geometry) == 9
         assert len(s.geometry) == 7
+        assert g.np.allclose(s.bounds, bounds, atol=1e-3)
+
+        # if merged should have combined the icosahedrons
+        s = g.get_mesh('TestScene.gltf', merge_primitives=True)
+        assert len(s.graph.nodes_geometry) == 7
+        assert len(s.geometry) == 6
+        assert g.np.allclose(s.bounds, bounds, atol=1e-3)
 
 
 if __name__ == '__main__':
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 8f4d7fa5..2875b7ac 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -156,12 +156,22 @@ class OBJTest(g.unittest.TestCase):
         assert len(m.vertices) > 0
         assert len(m.faces) == 1
 
+    def test_export_path(self):
+        m = g.get_mesh('fuze.obj')
+        g.check_fuze(m)
+        with g.TemporaryDirectory() as d:
+            file_path = g.os.path.join(d, 'fz.obj')
+            m.export(file_path)
+
+            r = g.trimesh.load(file_path)
+            g.check_fuze(r)
+
     def test_mtl(self):
         # get a mesh with texture
         m = g.get_mesh('fuze.obj')
         # export the mesh including data
         obj, data = g.trimesh.exchange.export.export_obj(
-            m, include_texture=True)
+            m, return_texture=True)
         with g.trimesh.util.TemporaryDirectory() as path:
             # where is the OBJ file going to be saved
             obj_path = g.os.path.join(path, 'test.obj')
diff --git a/tests/test_packing.py b/tests/test_packing.py
index 7534e379..a171f430 100644
--- a/tests/test_packing.py
+++ b/tests/test_packing.py
@@ -12,13 +12,13 @@ class PackingTest(g.unittest.TestCase):
 
     def test_obb(self):
         from trimesh.path import packing
-        inserted, transforms = packing.multipack(self.nestable)
+        inserted, transforms = packing.polygons(self.nestable)
 
     def test_paths(self):
         from trimesh.path import packing
         paths = [g.trimesh.load_path(i) for i in self.nestable]
 
-        r, inserted = packing.pack_paths(paths)
+        r, inserted = packing.paths(paths)
 
         # number of paths inserted
         count = len(g.np.unique(inserted))
diff --git a/tests/test_texture.py b/tests/test_texture.py
index 99530330..f9cfb0a4 100644
--- a/tests/test_texture.py
+++ b/tests/test_texture.py
@@ -24,39 +24,12 @@ class TextureTest(g.unittest.TestCase):
 
     def test_fuze(self):
 
-        def check_fuze(fuze):
-            """
-            Check the classic textured mesh: a fuze bottle
-            """
-            # these loaded fuze bottles should have textures
-            assert isinstance(
-                fuze.visual, g.trimesh.visual.TextureVisuals)
-
-            # image should be loaded with correct resolution
-            assert fuze.visual.material.image.size == (1024, 1024)
-
-            # UV coordinates should be unmerged correctly
-            assert len(fuze.visual.uv) == 664
-
-            # UV coordinates shouldn't be all zero- ish
-            assert fuze.visual.uv[:, :2].ptp(axis=0).min() > 0.1
-
-            # vertices should correspond with UV
-            assert fuze.vertices.shape == (664, 3)
-
-            # convert TextureVisuals to ColorVisuals
-            viz = fuze.visual.to_color()
-            assert viz.kind == 'vertex'
-
-            # should be actual colors defined
-            assert viz.vertex_colors.ptp(axis=0).ptp() != 0
-
         # create a local web server to test remote assets
         with g.serve_meshes() as address:
             # see if web resolvers work
             tex = g.trimesh.exchange.load.load_remote(
                 url=address + '/fuze.obj', process=False)
-            check_fuze(tex)
+            g.check_fuze(tex)
 
             # see if web + zip resolvers work
             scene = g.trimesh.exchange.load.load_remote(
@@ -65,12 +38,12 @@ class TextureTest(g.unittest.TestCase):
             # zip files get loaded into a scene
             assert len(scene.geometry) == 1
             # scene should just be a fuze bottle
-            check_fuze(next(iter(scene.geometry.values())))
+            g.check_fuze(next(iter(scene.geometry.values())))
 
         # obj with texture, assets should be loaded
         # through a FilePathResolver
         m = g.get_mesh('fuze.obj', process=False)
-        check_fuze(tex)
+        g.check_fuze(tex)
 
         # obj with texture, assets should be loaded
         # through a ZipResolver into a scene
@@ -79,15 +52,15 @@ class TextureTest(g.unittest.TestCase):
         # zip files get loaded into a scene
         assert len(scene.geometry) == 1
         m = next(iter(scene.geometry.values()))
-        check_fuze(m)
+        g.check_fuze(m)
 
         # the PLY should have textures defined
         m = g.get_mesh('fuze.ply', process=False)
-        check_fuze(m)
+        g.check_fuze(m)
 
         # ASCII PLY should have textures defined
         m = g.get_mesh('fuze_ascii.ply', process=False)
-        check_fuze(m)
+        g.check_fuze(m)
 
         # textured meshes should subdivide OK-ish
         s = m.subdivide()
@@ -133,6 +106,19 @@ class TextureTest(g.unittest.TestCase):
         assert resize(img).size == (32, 32)
         assert resize(img, square=True).size == (32, 32)
 
+    def test_concatenate(self):
+        # test concatenation with texture
+        a = g.get_mesh('fuze.obj')
+        b = a.copy()
+        b.apply_translation([b.extents[0] * 1.25, 0, 0])
+
+        c = a + b
+        assert len(c.vertices) > len(a.vertices)
+        assert len(c.visual.uv) == len(c.vertices)
+        # should have deduplicated image texture
+        assert g.np.allclose(c.visual.material.image.size,
+                             a.visual.material.image.size)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()