Repository: mikedh/trimesh

Issue:
crash when processing some meshes with degenerated faces
Thank you for this amazing library.

## Problem:
I got  a crash when processing some meshes with degenerated faces.

In order to reproduce the bug, run:
```
import trimesh

mesh = trimesh.Trimesh(
    vertices=[[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]],
    faces=[[3, 4, 4], [0, 1, 2], [0, 1, 2]],
    validate=False,
)
print(mesh.triangles)
mesh.process(validate=True)
print(mesh.triangles)
```
creates the exception:
`Exception has occurred: IndexError       (note: full exception trace is shown but execution is paused at: _run_module_as_main)
boolean index did not match indexed array along dimension 0; dimension is 2 but corresponding boolean dimension is 3` on line with code `self.faces = faces[mask]` in `Trimesh.update_faces`

## Fix:
add line with `self._cache.clear()` at the end of the method `Trimesh.update_faces`

Discussion:

Patch:
diff --git a/setup.py b/setup.py
index 794e67e9..68f72d90 100644
--- a/setup.py
+++ b/setup.py
@@ -67,7 +67,7 @@ requirements_test = set(['pytest',       # run all unit tests
                          'pytest-cov',   # coverage plugin
                          'pyinstrument',  # profile code
                          'coveralls',    # report coverage stats
-                         'autopep8',     # check and autoformat
+                         'autopep8<2',     # check and autoformat
                          'ruff',         # static code analysis
                          'pymeshlab',    # used as a validator for exports
                          'ezdxf'])       # used as a validator for exports
diff --git a/trimesh/base.py b/trimesh/base.py
index 7f04620d..561db402 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -228,21 +228,24 @@ class Trimesh(Geometry3D):
 
         # avoid clearing the cache during operations
         with self._cache:
-            self.remove_infinite_values()
-            self.merge_vertices(merge_tex=merge_tex,
-                                merge_norm=merge_norm)
             # if we're cleaning remove duplicate
             # and degenerate faces
             if validate:
-                self.remove_duplicate_faces()
-                self.remove_degenerate_faces()
+                # get a mask with only unique and non-degenerate faces
+                mask = self.unique_faces() & self.nondegenerate_faces()
                 self.fix_normals()
-        # since none of our process operations moved vertices or faces
-        # we can keep face and vertex normals in the cache without recomputing
-        # if faces or vertices have been removed, normals are validated before
-        # being returned so there is no danger of inconsistent dimensions
-        self._cache.clear(exclude={'face_normals',
-                                   'vertex_normals'})
+                self.update_faces(mask)
+
+            # since none of our process operations moved vertices or faces
+            # we can keep face and vertex normals in the cache without recomputing
+            # if faces or vertices have been removed, normals are validated before
+            # being returned so there is no danger of inconsistent dimensions
+            self.remove_infinite_values()
+            self.merge_vertices(merge_tex=merge_tex,
+                                merge_norm=merge_norm)
+            self._cache.clear(exclude={'face_normals',
+                                       'vertex_normals'})
+
         self.metadata['processed'] = True
         return self
 
@@ -1274,14 +1277,30 @@ class Trimesh(Geometry3D):
             vertex_mask = np.isfinite(self.vertices).all(axis=1)
             self.update_vertices(vertex_mask)
 
-    def remove_duplicate_faces(self):
+    def unique_faces(self):
+        """
+        On the current mesh find which faces are unique.
+
+        Returns
+        --------
+        unique : (len(faces),) bool
+          A mask where the first occurance of a unique face is true.
         """
-        On the current mesh remove any faces which are duplicates.
+        mask = np.zeros(len(self.faces), dtype=bool)
+        mask[grouping.unique_rows(np.sort(self.faces, axis=1))[0]] = True
+        return mask
 
-        Alters `self.faces` to remove duplicate faces
+    def remove_duplicate_faces(self):
+        """
+        DERECATED MARCH 2024 REPLACE WITH:
+        `mesh.update_faces(mesh.unique_faces())`
         """
-        unique, inverse = grouping.unique_rows(np.sort(self.faces, axis=1))
-        self.update_faces(unique)
+        warnings.warn(
+            '`remove_duplicate_faces` is deprecated ' +
+            'and will be removed in March 2024: ' +
+            'replace with `mesh.update_faces(mesh.unique_faces())`',
+            category=DeprecationWarning, stacklevel=2)
+        self.update_faces(self.unique_faces())
 
     def rezero(self):
         """
@@ -1651,6 +1670,18 @@ class Trimesh(Geometry3D):
         return tree
 
     def remove_degenerate_faces(self, height=tol.merge):
+        """
+        DERECATED MARCH 2024 REPLACE WITH:
+        `self.update_faces(self.nondegenerate_faces(height=height))`
+        """
+        warnings.warn(
+            '`remove_degenerate_faces` is deprecated ' +
+            'and will be removed in March 2024 replace with ' +
+            '`self.update_faces(self.nondegenerate_faces(height=height))`',
+            category=DeprecationWarning, stacklevel=2)
+        self.update_faces(self.nondegenerate_faces(height=height))
+
+    def nondegenerate_faces(self, height=tol.merge):
         """
         Remove degenerate faces (faces without 3 unique vertex indices)
         from the current mesh.
@@ -1671,14 +1702,10 @@ class Trimesh(Geometry3D):
         nondegenerate : (len(self.faces), ) bool
           Mask used to remove faces
         """
-        nondegenerate = triangles.nondegenerate(
+        return triangles.nondegenerate(
             self.triangles,
             areas=self.area_faces,
-
             height=height)
-        self.update_faces(nondegenerate)
-
-        return nondegenerate
 
     @caching.cache_decorator
     def facets(self):
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index 40885e3c..abee1dbd 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -759,7 +759,7 @@ def _preprocess_faces(text):
 
 
 def export_obj(mesh,
-               include_normals=True,
+               include_normals=None,
                include_color=True,
                include_texture=True,
                return_texture=False,
@@ -776,8 +776,9 @@ def export_obj(mesh,
     -----------
     mesh : trimesh.Trimesh
       Mesh to be exported
-    include_normals : bool
-      Include vertex normals in export
+    include_normals : Optional[bool]
+      Include vertex normals in export. If None
+      will only be included if vertex normals are in cache.
     include_color : bool
       Include vertex color in export
     include_texture : bool
@@ -853,10 +854,13 @@ def export_obj(mesh,
                 col_delim=' ',
                 row_delim='\nv ',
                 digits=digits)])
-        # only include vertex normals if they're already stored
-        if include_normals and current._cache.cache.get(
-                'vertex_normals') is not None:
 
+        # if include_normals is None then
+        # only include if they're already stored
+        if include_normals is None:
+            include_normals = 'vertex_normals' in current._cache.cache
+
+        if include_normals:
             try:
                 converted = util.array_to_string(
                     current.vertex_normals,
diff --git a/trimesh/exchange/off.py b/trimesh/exchange/off.py
index 234bfab7..d9a62551 100644
--- a/trimesh/exchange/off.py
+++ b/trimesh/exchange/off.py
@@ -26,7 +26,7 @@ def load_off(file_obj, **kwargs):
         util.decode_text(text)).strip()
 
     # split the first key
-    _, header, raw = re.split('(COFF|OFF)', text, 1)
+    _, header, raw = re.split('(COFF|OFF)', text, maxsplit=1)
     if header.upper() not in ['OFF', 'COFF']:
         raise NameError(
             'Not an OFF file! Header was: `{}`'.format(header))
diff --git a/trimesh/interfaces/scad.py b/trimesh/interfaces/scad.py
index 83793bd6..a1f06e28 100644
--- a/trimesh/interfaces/scad.py
+++ b/trimesh/interfaces/scad.py
@@ -54,7 +54,7 @@ def interface_scad(meshes, script, debug=False, **kwargs):
     except CalledProcessError as e:
         # Check if scad is complaining about an empty top level geometry.
         # If so, just return an empty Trimesh object.
-        if "Current top level object is empty." in e.output.decode().split("\n"):
+        if "Current top level object is empty." in e.output.decode():
             from .. import Trimesh
             return Trimesh()
         else:
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index ac5fe016..5044ad41 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -174,7 +174,7 @@ class _Primitive(Trimesh):
             raise ValueError('matrix must be `(4, 4)`!')
         if util.allclose(matrix, _IDENTITY, 1e-8):
             # identity matrix is a no-op
-            return
+            return self
 
         prim = self.primitive
         # copy the current transform
diff --git a/trimesh/version.py b/trimesh/version.py
index 4bbf2de3..64548f97 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,4 +1,4 @@
-__version__ = '3.22.3'
+__version__ = '3.22.4'
 
 if __name__ == '__main__':
     # print version if run directly i.e. in a CI script


Test patch:
diff --git a/tests/test_cache.py b/tests/test_cache.py
index b4fb902f..cb8cd629 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -228,8 +228,8 @@ class CacheTest(g.unittest.TestCase):
         # are suspicious of a method caching you could uncomment this out:
         # attempts.extend([tuple(G) for G in itertools.permutations(flat, 3)])
 
-        skip = set(['__array_ufunc__', # currently segfaulting when called with `(2.3, 1)`
-                    'astype', 
+        skip = set(['__array_ufunc__',  # segfaulting when called with `(2.3, 1)`
+                    'astype',
                     ])
 
         # collect functions which mutate arrays but don't change our hash
@@ -266,6 +266,25 @@ class CacheTest(g.unittest.TestCase):
                 '`TrackedArray` incorrectly hashing methods: {}'.format(
                     method_busted))
 
+    def test_validate(self):
+        # create a mesh with two duplicate triangles
+        # and one degenerate triangle
+        m = g.trimesh.Trimesh(
+            vertices=[[0, 0, 0],
+                      [1, 0, 0],
+                      [0, 1, 0],
+                      [1, 0, 0],
+                      [0, 1, 0],
+                      [1, 1, 0]],
+            faces=[[3, 4, 4], [0, 1, 2], [0, 1, 2]],
+            validate=False)
+
+        # should not have removed any triangles
+        assert m.triangles.shape == (3, 3, 3)
+        # should remove every triangle except one
+        m.process(validate=True)
+        assert m.triangles.shape == (1, 3, 3)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 532831f4..b57343b7 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -437,13 +437,33 @@ class OBJTest(g.unittest.TestCase):
         m = g.get_mesh('face_in_group_name_mid_file.obj')
         assert len(m.vertices) == 5
         assert len(m.faces) == 2
-        
+
     def test_chunk_parsing_with_no_faces_but_with_f_in_chunk(self):
         # Checks that a chunk with no faces but with 'f ' in it loads properly
         m = g.get_mesh('obj_with_no_face_in_chunk.obj')
         assert len(m.vertices) == 3
         assert len(m.faces) == 1
 
+    def test_export_normals(self):
+        m = g.trimesh.creation.box()
+        assert 'vertex_normals' not in m._cache.cache
+
+        e = m.export(file_type='obj', include_normals=None)
+        assert 'vn ' not in e
+        e = m.export(file_type='obj', include_normals=True)
+        # should have included normals
+        assert 'vn ' in e
+        # should have forced generation of normals
+        assert 'vertex_normals' in m._cache.cache
+
+        # now that they're in cache include_normals=None should get them
+        e = m.export(file_type='obj', include_normals=None)
+        assert 'vn ' in e
+
+        # or skip them if explicitly asked
+        e = m.export(file_type='obj', include_normals=False)
+        assert 'vn ' not in e
+
 
 def simple_load(text):
     # we're going to load faces in a basic text way
diff --git a/tests/test_primitives.py b/tests/test_primitives.py
index f11d7642..0b5f808d 100644
--- a/tests/test_primitives.py
+++ b/tests/test_primitives.py
@@ -274,6 +274,13 @@ class PrimitiveTest(g.unittest.TestCase):
             # check to see if outline function works
             assert g.np.allclose(box.as_outline().extents, start)
 
+    def test_transform_return(self):
+        # make sure primitives return `self`
+        Box = g.trimesh.primitives.Box
+
+        assert isinstance(Box().apply_transform(g.np.eye(4)), Box)
+        assert isinstance(Box().apply_transform(g.transforms[0]), Box)
+
     def test_cyl_buffer(self):
         # test our inflation of cylinder primitives
         c = g.trimesh.primitives.Cylinder(