Repository: pyvista/pyvista

Issue:
Generalize and/or rename `PolyData.flip_normals` using `vtkReverseSense`
### Describe the feature you would like to be added.

[`PolyData.flip_normals`](https://dev.pyvista.org/api/core/_autosummary/pyvista.polydatafilters.flip_normals#pyvista.PolyDataFilters.flip_normals) has a misleading name. It does not operate on normals, but instead reverses the ordering of points. I would have expected this filter to do something more like `mesh.active_normals *= -1`. It also only works with triangles.

Instead, I think the filter should be called something like `reverse_sense` and  use [vtkReverseSense](https://vtk.org/doc/nightly/html/classvtkReverseSense.html#details) to reverse the point order for any polygon, not just triangles. This filter also handles the `normals *= -1` case (though it's simple enough to implement this without a vtk filter).

### Links to VTK Documentation, Examples, or Class Definitions.

_No response_

### Pseudocode or Screenshots

_No response_

Discussion:

Patch:
diff --git a/doc/source/index.rst b/doc/source/index.rst
index e5ca5e96..7ef961d2 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -174,8 +174,7 @@ Subtract a sphere from a cube mesh.
     def make_cube():
         x = np.linspace(-0.5, 0.5, 25)
         grid = pyvista.StructuredGrid(*np.meshgrid(x, x, x))
-        surf = grid.extract_surface().triangulate()
-        surf.flip_normals()
+        surf = grid.extract_surface().triangulate().flip_faces()
         return surf
 
     # Create example PolyData meshes for boolean operations
diff --git a/examples/01-filter/boolean-operations.py b/examples/01-filter/boolean-operations.py
index c7164b4e..5f7f92fd 100644
--- a/examples/01-filter/boolean-operations.py
+++ b/examples/01-filter/boolean-operations.py
@@ -118,12 +118,15 @@ pl.show()
 
 
 # %%
-# Behavior due to flipped normals
+# Behavior due to flipped faces
 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 # Note that these boolean filters behave differently depending on the
-# orientation of the normals.
+# orientation of the faces. This is because the orientation determines
+# which parts are considered to be the "outside" or the "inside" portion
+# of the mesh. This example uses :meth:`~pyvista.PolyDataFilters.flip_faces`
+# to flip the faces.
 #
-# Boolean difference with both cube and sphere normals pointed
+# Boolean difference with both cube and sphere faces oriented
 # outward.  This is the "normal" behavior.
 
 cube = pv.Cube().triangulate().subdivide(3)
@@ -133,32 +136,28 @@ result.plot(color='lightblue')
 
 
 # %%
-# Boolean difference with cube normals outward, sphere inward.
+# Boolean difference with cube faces outward, sphere faces inward.
 
 cube = pv.Cube().triangulate().subdivide(3)
-sphere = pv.Sphere(radius=0.6)
-sphere.flip_normals()
+sphere = pv.Sphere(radius=0.6).flip_faces()
 result = cube.boolean_difference(sphere)
 result.plot(color='lightblue')
 
 
 # %%
-# Boolean difference with cube normals inward, sphere outward.
+# Boolean difference with cube faces inward, sphere faces outward.
 
-cube = pv.Cube().triangulate().subdivide(3)
-cube.flip_normals()
+cube = pv.Cube().triangulate().subdivide(3).flip_faces()
 sphere = pv.Sphere(radius=0.6)
 result = cube.boolean_difference(sphere)
 result.plot(color='lightblue')
 
 
 # %%
-# Both cube and sphere normals inward.
+# Both cube and sphere faces inward.
 
-cube = pv.Cube().triangulate().subdivide(3)
-cube.flip_normals()
-sphere = pv.Sphere(radius=0.6)
-sphere.flip_normals()
+cube = pv.Cube().triangulate().subdivide(3).flip_faces()
+sphere = pv.Sphere(radius=0.6).flip_faces()
 result = cube.boolean_difference(sphere)
 result.plot(color='lightblue')
 # %%
diff --git a/pyvista/core/_vtk_core.py b/pyvista/core/_vtk_core.py
index bfb4633f..fba4be0d 100644
--- a/pyvista/core/_vtk_core.py
+++ b/pyvista/core/_vtk_core.py
@@ -320,6 +320,7 @@ from vtkmodules.vtkFiltersCore import vtkPointDataToCellData as vtkPointDataToCe
 from vtkmodules.vtkFiltersCore import vtkPolyDataNormals as vtkPolyDataNormals
 from vtkmodules.vtkFiltersCore import vtkQuadricDecimation as vtkQuadricDecimation
 from vtkmodules.vtkFiltersCore import vtkResampleWithDataSet as vtkResampleWithDataSet
+from vtkmodules.vtkFiltersCore import vtkReverseSense as vtkReverseSense
 from vtkmodules.vtkFiltersCore import vtkSmoothPolyDataFilter as vtkSmoothPolyDataFilter
 from vtkmodules.vtkFiltersCore import vtkStripper as vtkStripper
 from vtkmodules.vtkFiltersCore import vtkThreshold as vtkThreshold
diff --git a/pyvista/core/filters/poly_data.py b/pyvista/core/filters/poly_data.py
index 10273907..3fe57a64 100644
--- a/pyvista/core/filters/poly_data.py
+++ b/pyvista/core/filters/poly_data.py
@@ -16,6 +16,7 @@ from pyvista.core import _vtk_core as _vtk
 from pyvista.core._typing_core import NumpyArray
 from pyvista.core.errors import MissingDataError
 from pyvista.core.errors import NotAllTrianglesError
+from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.errors import PyVistaFutureWarning
 from pyvista.core.errors import VTKVersionError
 from pyvista.core.filters import _get_output
@@ -1803,6 +1804,10 @@ class PolyDataFilters(DataSetFilters):
             Returns the array of point normals.
         pyvista.PolyData.cell_normals
             Returns the array of cell normals.
+        pyvista.PolyDataFilters.flip_normal_vectors
+            Flip cell and point normals.
+        pyvista.PolyDataFilters.flip_faces
+            Flip the orientation of the faces.
 
         Examples
         --------
@@ -2743,10 +2748,16 @@ class PolyDataFilters(DataSetFilters):
         >>> import pyvista as pv
         >>> sphere = pv.Sphere()
         >>> sphere.plot_normals(mag=0.1)
-        >>> sphere.flip_normals()
+        >>> sphere.flip_normals()  # doctest:+SKIP
         >>> sphere.plot_normals(mag=0.1, opacity=0.5)
 
         """
+        # Deprecated on v0.45.0, estimated removal on v0.48.0
+        warnings.warn(
+            '`flip_normals` is deprecated. Use `flip_faces` instead. '
+            'Note that `inplace` is now `False` by default for the new filter.',
+            PyVistaDeprecationWarning,
+        )
         if not self.is_all_triangles:  # type: ignore[attr-defined]
             raise NotAllTrianglesError('Can only flip normals on an all triangle mesh.')
 
@@ -2756,6 +2767,186 @@ class PolyDataFilters(DataSetFilters):
         # See: https://stackoverflow.com/a/33362288/
         f[::3], f[2::3] = f[2::3], f[::3].copy()
 
+    def _reverse_sense(  # type: ignore[misc]
+        self: PolyData,
+        *,
+        reverse_cells: bool,
+        reverse_normals: bool,
+        inplace: bool,
+        progress_bar: bool,
+    ):
+        """Flip faces and/or normal vectors."""
+        alg = _vtk.vtkReverseSense()
+        alg.SetInputData(self)
+        alg.SetReverseNormals(reverse_normals)
+        alg.SetReverseCells(reverse_cells)
+        _update_alg(alg, progress_bar)
+        output = _get_output(alg)
+        if inplace:
+            self.copy_from(output, deep=False)
+            return self
+        return output
+
+    def flip_faces(  # type: ignore[misc]
+        self: PolyData,
+        *,
+        inplace: bool = False,
+        progress_bar: bool = False,
+    ):
+        """Flip the orientation of the faces.
+
+        The flip is performed by reversing the order of indices in the cell
+        connectivity list. In other libraries, this operation is sometimes
+        referred to as "flip orientation", "reverse cells", "reverse face
+        orientations", or similar.
+
+        .. note::
+
+            Polygon cells have an implicitly-defined orientation, and reversing
+            the ordering affects how normals are computed by filters like
+            :meth:`~pyvista.PolyDataFilters.compute_normals`.
+
+        .. note::
+            This filter does not modify any existing normals which may be present
+            in the dataset. Use :meth:`~pyvista.PolyDataFilters.flip_normal_vectors`
+            to flip the normal vectors.
+
+        .. warning::
+
+            This filter does not produce the correct output for :attr:`~pyvista.CellType.TRIANGLE_STRIP`
+            cells, see https://gitlab.kitware.com/vtk/vtk/-/issues/18634.
+            Use :meth:`~pyvista.PolyDataFilters.triangulate` to triangulate the mesh
+            first.
+
+        .. versionadded:: 0.45
+
+        Parameters
+        ----------
+        inplace : bool, default: False
+            Overwrites the original mesh in-place.
+
+        progress_bar : bool, default: False
+            Display a progress bar to indicate progress.
+
+        Returns
+        -------
+        pyvista.PolyData
+            Mesh with flipped faces.
+
+        See Also
+        --------
+        pyvista.PolyDataFilters.flip_normal_vectors
+            Flip the direction of the point and cell normals.
+        pyvista.PolyDataFilters.compute_normals
+            Compute new normals. Includes the option to flip the normals.
+        pyvista.PolyData.point_normals
+            Returns the array of point normals.
+        pyvista.PolyData.cell_normals
+            Returns the array of cell normals.
+
+        Examples
+        --------
+        Flip the faces of a sphere. Show the point ids of the first cell
+        before and after the flip.
+
+        >>> import pyvista as pv
+        >>> sphere = pv.Sphere()
+        >>> sphere.regular_faces[0]
+        array([ 2, 30,  0])
+
+        >>> sphere_flipped = sphere.flip_faces()
+        >>> sphere_flipped.regular_faces[0]
+        array([ 0, 30,  2])
+
+        Note that the sphere also has pre-computed normals which have not been
+        affected by this filter.
+
+        >>> sphere.point_data['Normals'][0]
+        pyvista_ndarray([0., 0., 1.], dtype=float32)
+
+        >>> sphere_flipped.point_data['Normals'][0]
+        pyvista_ndarray([0., 0., 1.], dtype=float32)
+
+        See :ref:`boolean_example` for more examples using this filter.
+
+        """
+        return self._reverse_sense(
+            reverse_cells=True, reverse_normals=False, inplace=inplace, progress_bar=progress_bar
+        )
+
+    def flip_normal_vectors(  # type: ignore[misc]
+        self: PolyData,
+        *,
+        inplace: bool = False,
+        progress_bar: bool = False,
+    ):
+        """Flip the direction of the mesh's point and cell normal vectors.
+
+        This filter effectively multiplies the point and cell normals by ``-1``.
+        It has no effect if no active normals are currently set.
+
+        .. note::
+
+            Polygon cells have an implicitly-defined orientation which may differ
+            from the orientation of the normal vectors. To ensure that the normals
+            are consistent with this implicit definition, consider also using
+            :meth:`~pyvista.PolyDataFilters.flip_faces` or re-computing normals with
+            :meth:`~pyvista.PolyDataFilters.compute_normals` and enabling the
+            `flip_normals` option.
+
+        .. versionadded:: 0.45
+
+        Parameters
+        ----------
+        inplace : bool, default: False
+            Overwrites the original mesh in-place.
+
+        progress_bar : bool, default: False
+            Display a progress bar to indicate progress.
+
+        Returns
+        -------
+        pyvista.PolyData
+            Mesh with point and cell normal directions flipped.
+
+        See Also
+        --------
+        pyvista.PolyDataFilters.flip_faces
+            Flip the orientation of the faces.
+        pyvista.PolyDataFilters.compute_normals
+            Compute new normals. Includes the option to flip the normals.
+        pyvista.PolyData.point_normals
+            Returns the array of point normals.
+        pyvista.PolyData.cell_normals
+            Returns the array of cell normals.
+
+        Examples
+        --------
+        Flip the normal vectors of a sphere. Show one of the normal vectors
+        before and after the flip.
+
+        >>> import pyvista as pv
+        >>> sphere = pv.Sphere()
+        >>> sphere.point_data['Normals'][0]
+        pyvista_ndarray([0., 0., 1.], dtype=float32)
+
+        >>> sphere_flipped = sphere.flip_normal_vectors()
+        >>> sphere_flipped.point_data['Normals'][0]
+        pyvista_ndarray([-0., -0., -1.], dtype=float32)
+
+        Note that the sphere's cell ordering has not been affected by this filter.
+
+        >>> sphere.regular_faces[0]
+        array([ 2, 30,  0])
+
+        >>> sphere_flipped.regular_faces[0]
+        array([ 2, 30,  0])
+
+        """
+        return self._reverse_sense(
+            reverse_cells=False, reverse_normals=True, inplace=inplace, progress_bar=progress_bar
+        )
+
     def delaunay_2d(
         self,
         tol=1e-05,


Test patch:
diff --git a/tests/core/test_polydata.py b/tests/core/test_polydata.py
index b6589668..94156473 100644
--- a/tests/core/test_polydata.py
+++ b/tests/core/test_polydata.py
@@ -14,6 +14,7 @@ import pyvista as pv
 from pyvista import examples
 from pyvista.core.errors import CellSizeError
 from pyvista.core.errors import NotAllTrianglesError
+from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.errors import PyVistaFutureWarning
 
 radius = 0.5
@@ -1140,17 +1141,39 @@ def test_extrude_capping_warnings():
         arc.extrude_rotate()
 
 
-def test_flip_normals(sphere, plane):
-    sphere_flipped = sphere.copy()
-    sphere_flipped.flip_normals()
+def test_flip_normals(sphere):
+    with pytest.warns(PyVistaDeprecationWarning):
+        sphere.flip_normals()
 
-    sphere.compute_normals(inplace=True)
-    sphere_flipped.compute_normals(inplace=True)
-    assert np.allclose(sphere_flipped.point_data['Normals'], -sphere.point_data['Normals'])
 
-    # invalid case
-    with pytest.raises(NotAllTrianglesError):
-        plane.flip_normals()
+@pytest.mark.parametrize('mesh', [pv.Sphere(), pv.Plane()])
+def test_flip_normal_vectors(mesh):
+    mesh = mesh.compute_normals()
+    flipped = mesh.flip_normal_vectors(inplace=True, progress_bar=True)
+    assert flipped is mesh
+
+    flipped = mesh.flip_normal_vectors()
+    assert flipped is not mesh
+
+    assert np.allclose(flipped.point_data['Normals'], -mesh.point_data['Normals'])
+    assert np.allclose(flipped.cell_data['Normals'], -mesh.cell_data['Normals'])
+
+    # Test ordering is unaffected
+    assert np.allclose(flipped.faces, mesh.faces)
+
+
+@pytest.mark.parametrize('mesh', [pv.Sphere(), pv.Plane()])
+def test_flip_faces(mesh):
+    flipped = mesh.flip_faces(inplace=True, progress_bar=True)
+    assert flipped is mesh
+
+    flipped = mesh.flip_faces()
+    assert flipped is not mesh
+
+    assert np.allclose(flipped.regular_faces[0], mesh.regular_faces[0][::-1])
+
+    # Test normals are unaffected
+    assert np.allclose(flipped.point_data['Normals'], mesh.point_data['Normals'])
 
 
 def test_n_verts():