Repository: mikedh/trimesh

Issue:
Obj import/GLB export - Reuse materials and textures
Thank you sharing trimesh, its a great library!

I have an obj file containing two meshes, ObjectA and ObjectB. They both use the same material.
I want to convert this obj file to a GLB-file which should contain the two objects and they should still share the same material.

However in my GLB output I get the two objects but I also get two materials, one for each mesh. Each of this materials links to a copy of the same texture. This is unessary both for the size of the GLB file but also for loading copies of the same material and texture.

When loading the obj file I use the split_object = True and group_material = False

If I set group_material = True I get a single material but now the objects have been merged to one object.

For my case, is it possible to override so that the GLB output get a single material which is being used for both objects?

I'm attaching my files for debugging.
[trimesh_obj.zip](https://github.com/mikedh/trimesh/files/3851474/trimesh_obj.zip)


Discussion:

Patch:
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 1d9de2e8..599c7653 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -428,7 +428,12 @@ def _create_gltf_structure(scene,
     nodes = scene.graph.to_gltf(scene=scene)
     tree.update(nodes)
 
+    # store materials as {hash : index} to avoid duplicates
+    mat_hashes = {}
+    # store data from geometries
     buffer_items = []
+
+    # loop through every geometry
     for name, geometry in scene.geometry.items():
         if util.is_instance_named(geometry, "Trimesh"):
             # add the mesh
@@ -437,7 +442,8 @@ def _create_gltf_structure(scene,
                 name=name,
                 tree=tree,
                 buffer_items=buffer_items,
-                include_normals=include_normals)
+                include_normals=include_normals,
+                mat_hashes=mat_hashes)
         elif util.is_instance_named(geometry, "Path"):
             # add Path2D and Path3D objects
             _append_path(
@@ -464,7 +470,8 @@ def _append_mesh(mesh,
                  name,
                  tree,
                  buffer_items,
-                 include_normals):
+                 include_normals,
+                 mat_hashes):
     """
     Append a mesh to the scene structure and put the
     data into buffer_items.
@@ -481,6 +488,8 @@ def _append_mesh(mesh,
       Will have buffer appended with mesh data
     include_normals : bool
       Include vertex normals in export or not
+    mat_hashes : dict
+      Which materials have already been added
     """
     # meshes reference accessor indexes
     # mode 4 is GL_TRIANGLES
@@ -550,13 +559,12 @@ def _append_mesh(mesh,
         buffer_items.append(color_data)
 
     elif hasattr(mesh.visual, 'material'):
-        # set the material to the last index in the tree
-        tree["meshes"][-1]["primitives"][0]["material"] = len(tree["materials"])
-        # immediately append the material to materials list
-        # will also append necessary images and textures to tree
-        _append_material(mat=mesh.visual.material,
-                         tree=tree,
-                         buffer_items=buffer_items)
+        # append the material and then set from returned index
+        tree["meshes"][-1]["primitives"][0]["material"] = _append_material(
+            mat=mesh.visual.material,
+            tree=tree,
+            buffer_items=buffer_items,
+            mat_hashes=mat_hashes)
 
         # if mesh has UV coordinates defined export them
         if (hasattr(mesh.visual, 'uv') and
@@ -602,10 +610,10 @@ def _append_mesh(mesh,
 
 def _byte_pad(data, bound=4):
     """
-    GLTF wants chunks aligned with 4 byte boundaries
-    so this function will add padding to the end of a
-    chunk of bytes so that it aligns with a specified
-    boundary size
+    GLTF wants chunks aligned with 4 byte boundaries.
+    This function will add padding to the end of a
+    chunk of bytes so that it aligns with the passed
+    boundary size.
 
     Parameters
     --------------
@@ -633,7 +641,6 @@ def _byte_pad(data, bound=4):
                 'byte_pad failed! ori:{} res:{} pad:{} req:{}'.format(
                     len(data), len(result), count, bound))
         return result
-
     return data
 
 
@@ -1083,7 +1090,7 @@ def _append_image(img, tree, buffer_items):
     return len(tree['images']) - 1
 
 
-def _append_material(mat, tree, buffer_items):
+def _append_material(mat, tree, buffer_items, mat_hashes):
     """
     Add passed PBRMaterial as GLTF 2.0 specification JSON
     serializable data:
@@ -1091,7 +1098,6 @@ def _append_material(mat, tree, buffer_items):
     - texture is added to `tree['texture']`
     - material is added to `tree['materials']`
 
-
     Parameters
     ------------
     mat : trimesh.visual.materials.PBRMaterials
@@ -1100,45 +1106,59 @@ def _append_material(mat, tree, buffer_items):
       GLTF header blob
     buffer_items : (n,) bytes
       Binary blobs with various data
-    """
+    mat_hashes : dict
+      Which materials have already been added
+      Stored as { hashed : material index }
 
-    # if they have passed a material with
-    # a PBR conversion method call it
-    # TODO: implement this method on SimpleMaterial
+    Returns
+    -------------
+    index : int
+      Index at which material was added
+    """
+    # materials are hashable
+    hashed = hash(mat)
+    # check stored material indexes to see if material
+    # has already been added
+    if mat_hashes is not None and hashed in mat_hashes:
+        return mat_hashes[hashed]
+
+    # convert passed input to PBR if necessary
     if hasattr(mat, 'to_pbr'):
-        mat = mat.to_pbr()
+        as_pbr = mat.to_pbr()
+    else:
+        as_pbr = mat
 
     # a default PBR metallic material
-    pbr = {"pbrMetallicRoughness": {}}
+    result = {"pbrMetallicRoughness": {}}
     try:
         # try to convert base color to (4,) float color
-        pbr['baseColorFactor'] = visual.color.to_float(
-            mat.baseColorFactor).reshape(4).tolist()
+        result['baseColorFactor'] = visual.color.to_float(
+            as_pbr.baseColorFactor).reshape(4).tolist()
     except BaseException:
         pass
 
     try:
-        pbr['emissiveFactor'] = mat.emissiveFactor.reshape(3).tolist()
+        result['emissiveFactor'] = as_pbr.emissiveFactor.reshape(3).tolist()
     except BaseException:
         pass
 
     # if alphaMode is defined, export
-    if isinstance(mat.alphaMode, str):
-        pbr['alphaMode'] = mat.alphaMode
+    if isinstance(as_pbr.alphaMode, str):
+        result['alphaMode'] = as_pbr.alphaMode
 
     # if scalars are defined correctly export
-    if isinstance(mat.metallicFactor, float):
-        pbr['metallicFactor'] = mat.metallicFactor
-    if isinstance(mat.roughnessFactor, float):
-        pbr['roughnessFactor'] = mat.roughnessFactor
+    if isinstance(as_pbr.metallicFactor, float):
+        result['metallicFactor'] = as_pbr.metallicFactor
+    if isinstance(as_pbr.roughnessFactor, float):
+        result['roughnessFactor'] = as_pbr.roughnessFactor
 
     # which keys of the PBRMaterial are images
     image_mapping = {
-        'baseColorTexture': mat.baseColorTexture,
-        'emissiveTexture': mat.emissiveTexture,
-        'normalTexture': mat.normalTexture,
-        'occlusionTexture': mat.occlusionTexture,
-        'metallicRoughnessTexture': mat.metallicRoughnessTexture}
+        'baseColorTexture': as_pbr.baseColorTexture,
+        'emissiveTexture': as_pbr.emissiveTexture,
+        'normalTexture': as_pbr.normalTexture,
+        'occlusionTexture': as_pbr.occlusionTexture,
+        'metallicRoughnessTexture': as_pbr.metallicRoughnessTexture}
 
     for key, img in image_mapping.items():
         if img is None:
@@ -1152,7 +1172,7 @@ def _append_material(mat, tree, buffer_items):
         # if it failed for any reason, it will return None
         if index is not None:
             # add a reference to the base color texture
-            pbr[key] = {'index': len(tree['textures'])}
+            result[key] = {'index': len(tree['textures'])}
             # add an object for the texture
             tree['textures'].append({'source': index, 'sampler': 0})
 
@@ -1166,15 +1186,21 @@ def _append_material(mat, tree, buffer_items):
                   'metallicRoughnessTexture']
     # move keys down a level
     for key in pbr_subset:
-        if key in pbr:
-            pbr["pbrMetallicRoughness"][key] = pbr.pop(key)
+        if key in result:
+            result["pbrMetallicRoughness"][key] = result.pop(key)
 
     # if we didn't have any PBR keys remove the empty key
-    if len(pbr['pbrMetallicRoughness']) == 0:
-        pbr.pop('pbrMetallicRoughness')
+    if len(result['pbrMetallicRoughness']) == 0:
+        result.pop('pbrMetallicRoughness')
+
+    # which index are we inserting material at
+    index = len(tree['materials'])
+    # add the material to the data structure
+    tree['materials'].append(result)
+    # add the material index in-place
+    mat_hashes[hash(mat)] = index
 
-    # append the new material
-    tree['materials'].append(pbr)
+    return index
 
 
 def validate(header):
diff --git a/trimesh/path/exchange/dxf.py b/trimesh/path/exchange/dxf.py
index 498279ab..04e20ae7 100644
--- a/trimesh/path/exchange/dxf.py
+++ b/trimesh/path/exchange/dxf.py
@@ -1010,8 +1010,8 @@ def _teigha_convert(data, extension='dwg'):
         raise ValueError('conversion using Teigha failed!')
 
     # load converted file into a string
-    with open(name_result, 'r') as f:
-        converted = f.read()
+    with open(name_result, 'rb') as f:
+        converted = f.read().decode(errors='ignore')
 
     # remove the temporary directories
     shutil.rmtree(dir_out)
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 02cb4c37..54040787 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -691,9 +691,9 @@ class Scene(Geometry):
         file_type = file_type.strip().lower().lstrip('.')
 
         if file_type == 'gltf':
-            data = gltf.export_gltf(self)
+            data = gltf.export_gltf(self, **kwargs)
         elif file_type == 'glb':
-            data = gltf.export_glb(self)
+            data = gltf.export_glb(self, **kwargs)
         elif file_type == 'dict':
             from ..exchange.export import scene_to_dict
             data = scene_to_dict(self)
@@ -703,7 +703,7 @@ class Scene(Geometry):
         else:
             raise ValueError('unsupported export format: {}'.format(file_type))
 
-        # now write the data, or not
+        # now write the data or return bytes of result
         if hasattr(file_obj, 'write'):
             # if it's just a regular file object
             file_obj.write(data)
@@ -721,12 +721,15 @@ class Scene(Geometry):
 
         Parameters
         -----------
-        resolution: (2,) int, resolution to render image
-        **kwargs:  passed to SceneViewer constructor
+        resolution : (2,) int
+          Resolution to render image
+        **kwargs
+          Passed to SceneViewer constructor
 
         Returns
         -----------
-        png: bytes, render of scene in PNG form
+        png : bytes
+          Render of scene as a PNG
         """
         from ..viewer import render_scene
         png = render_scene(scene=self,
diff --git a/trimesh/util.py b/trimesh/util.py
index 6525f8e2..15e10a03 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -26,8 +26,10 @@ import shutil
 
 
 if sys.version_info >= (3, 4):
+    # for newer version of python
     ABC = abc.ABC
 else:
+    # an abstract base class that works on older versions
     ABC = abc.ABCMeta('ABC', (), {})
 
 # a flag we can check elsewhere for Python 3
diff --git a/trimesh/version.py b/trimesh/version.py
index c2d1211f..6f9c2538 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.4.3'
+__version__ = '3.4.4'
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index 92854491..f4e90e21 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -13,6 +13,9 @@ class Material(object):
     def __init__(self, *args, **kwargs):
         raise NotImplementedError('material must be subclassed!')
 
+    def __hash__(self):
+        return id(self)
+
     @property
     def main_color(self):
         raise NotImplementedError('material must be subclassed!')
@@ -48,6 +51,35 @@ class SimpleMaterial(Material):
     def to_color(self, uv):
         return color.uv_to_color(uv, self.image)
 
+    def __hash__(self):
+        """
+        Provide a hash of the material so we can detect
+        duplicates.
+
+        Returns
+        ------------
+        hash : int
+          Hash of image and parameters
+        """
+        if hasattr(self.image, 'tobytes'):
+            # start with hash of raw image bytes
+            hashed = hash(self.image.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.ambient, 'tobytes'):
+            hashed ^= hash(self.ambient.tobytes())
+        if hasattr(self.diffuse, 'tobytes'):
+            hashed ^= hash(self.diffuse.tobytes())
+        if hasattr(self.specular, 'tobytes'):
+            hashed ^= hash(self.specular.tobytes())
+        if isinstance(self.glossiness, float):
+            hashed ^= hash(int(self.glossiness * 1000))
+        return hashed
+
     @property
     def main_color(self):
         """


Test patch:
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 4650c99a..87d7ca4c 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -151,6 +151,53 @@ class GLTFTest(g.unittest.TestCase):
         # make basic assertions
         g.scene_equal(scene, reloaded)
 
+    def test_material_hash(self):
+
+        # load mesh twice independently
+        a = g.get_mesh('fuze.obj')
+        b = g.get_mesh('fuze.obj')
+        # move one of the meshes away from the other
+        a.apply_translation([a.scale, 0, 0])
+
+        # materials should not be the same object
+        assert id(a.visual.material) != id(b.visual.material)
+        # despite being loaded separately material hash should match
+        assert hash(a.visual.material) == hash(b.visual.material)
+
+        # create a scene with two meshes
+        scene = g.trimesh.Scene([a, b])
+        # get the exported GLTF header of a scene with both meshes
+        header = g.json.loads(scene.export(
+            file_type='gltf')['model.gltf'].decode('utf-8'))
+        # header should contain exactly one material
+        assert len(header['materials']) == 1
+        # both meshes should be contained in the export
+        assert len(header['meshes']) == 2
+
+        # get a reloaded version
+        reloaded = g.trimesh.load(
+            file_obj=g.trimesh.util.wrap_as_stream(
+                scene.export(file_type='glb')),
+            file_type='glb')
+
+        # meshes should have survived
+        assert len(reloaded.geometry) == 2
+        # get meshes back
+        ar, br = reloaded.geometry.values()
+
+        # should have been loaded as a PBR material
+        assert isinstance(ar.visual.material,
+                          g.trimesh.visual.material.PBRMaterial)
+
+        # materials should have the same memory location
+        assert id(ar.visual.material) == id(br.visual.material)
+
+        # make sure hash is returning something
+        ahash = hash(ar.visual.material)
+        # should be returning valid material hashes
+        assert isinstance(ahash, int)
+        assert ahash != 0
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()