Repository: pyvista/pyvista

Issue:
`merge` does not set active normals
### Describe the bug, what's wrong, and what you expected.

If I set custom normals and plot them, everything works as expected:
``` python
import pyvista as pv
plane = pv.Plane(i_resolution=1, j_resolution=1)
plane["Normals"][0]  # [0, 0, 1]
plane["Normals"] *= -1
plane.plot_normals()
```
<img  src="https://github.com/pyvista/pyvista/assets/89109579/924ae596-b667-48ba-ba02-1a998344c55d" width="300"> 

But if I merge the mesh, the normals are broken:
``` python
merged = pv.merge([plane]) 
merged.plot_normals() 
```
<img  src="https://github.com/pyvista/pyvista/assets/89109579/647ed33b-5934-4712-8f21-ce38ccc60f6b" width="300">

The expected result is that the normals should stay the same after merging. So, in the second image, the normals should be pointing down like the first. 

The issue seems to be that although `merge` will pass the `"Normals"` array to the output, they are not set as the _active_ normals.
``` python
merged["Normals"][0]  # [0, 0, -1]
merged.active_normals  # None
```
This means that when `point_normals` is accessed, it will compute new normals since none are active (and overwrite the custom normals).
 ``` python
merged.point_normals[0]  # [0, 0, 1]
```


### Steps to reproduce the bug.

See above

### System Information

```shell
OS : Darwin (macOS 14.4.1)
            CPU(s) : 8
           Machine : arm64
      Architecture : 64bit
       Environment : Python
        GPU Vendor : Apple
      GPU Renderer : Apple M2
       GPU Version : 4.1 Metal - 88
  MathText Support : True

  Python 3.9.13 (v3.9.13:6de2ca5339, May 17 2022, 11:37:23)  [Clang 13.0.0
  (clang-1300.0.29.30)]

           pyvista : 0.44.dev0
               vtk : 9.3.0
             numpy : 1.26.4
        matplotlib : 3.9.0
            scooby : 0.10.0
             pooch : 1.8.2
            pillow : 10.3.0
           imageio : 2.34.1
           IPython : 8.18.1
          colorcet : 3.1.0
           cmocean : 4.0.3
        ipywidgets : 8.1.3
             scipy : 1.13.1
              tqdm : 4.66.4
            meshio : 5.3.5
    pytest_pyvista : 0.1.9
             trame : 3.6.2
      trame_client : 3.2.0
      trame_server : 3.0.2
         trame_vtk : 2.8.9
     trame_vuetify : 2.6.0
--------------------------------------------------------------------------------
```


### Screenshots

_No response_

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pyvista/pyvista/pull/6371?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.20%. Comparing base [(`9a88c38`)](https://app.codecov.io/gh/pyvista/pyvista/commit/9a88c38b87969899709289abc0b45c42cfc1e5a0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) to head [(`cfa42ac`)](https://app.codecov.io/gh/pyvista/pyvista/commit/cfa42ac2157897e96bac14c3851228e0baee67e9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista).

> :exclamation:  There is a different number of reports uploaded between BASE (9a88c38) and HEAD (cfa42ac). Click for more details.
> 
> <details><summary>HEAD has 11 uploads less than BASE</summary>
>
>| Flag | BASE (9a88c38) | HEAD (cfa42ac) |
>|------|------|------|
>||12|1|
></details>

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #6371      +/-   ##
==========================================
- Coverage   97.23%   91.20%   -6.03%     
==========================================
  Files         142      142              
  Lines       26507    26528      +21     
==========================================
- Hits        25773    24196    -1577     
- Misses        734     2332    +1598     
```

</details>

Patch:
diff --git a/pyvista/core/datasetattributes.py b/pyvista/core/datasetattributes.py
index feb57baf..c7b93841 100644
--- a/pyvista/core/datasetattributes.py
+++ b/pyvista/core/datasetattributes.py
@@ -310,7 +310,7 @@ class DataSetAttributes(_vtk.VTKObjectWrapper):
         pyvista_ndarray([0, 1, 2, 3, 4, 5, 6, 7])
 
         """
-        self._raise_field_data_no_scalars_vectors()
+        self._raise_field_data_no_scalars_vectors_normals()
         if self.GetScalars() is not None:
             array = pyvista_ndarray(
                 self.GetScalars(),
@@ -351,7 +351,7 @@ class DataSetAttributes(_vtk.VTKObjectWrapper):
         (8, 3)
 
         """
-        self._raise_field_data_no_scalars_vectors()
+        self._raise_field_data_no_scalars_vectors_normals()
         vectors = self.GetVectors()
         if vectors is not None:
             return pyvista_ndarray(vectors, dataset=self.dataset, association=self.association)
@@ -1084,10 +1084,10 @@ class DataSetAttributes(_vtk.VTKObjectWrapper):
             if not 0 <= index < max_index:
                 raise KeyError(f'Array index ({index}) out of range [0, {max_index - 1}]')
 
-    def _raise_field_data_no_scalars_vectors(self):
+    def _raise_field_data_no_scalars_vectors_normals(self):
         """Raise a ``TypeError`` if FieldData."""
         if self.association == FieldAssociation.NONE:
-            raise TypeError('FieldData does not have active scalars or vectors.')
+            raise TypeError('FieldData does not have active scalars or vectors or normals.')
 
     @property
     def active_scalars_name(self) -> str | None:
@@ -1140,12 +1140,64 @@ class DataSetAttributes(_vtk.VTKObjectWrapper):
         if name is None:
             self.SetActiveScalars(None)
             return
-        self._raise_field_data_no_scalars_vectors()
+        self._raise_field_data_no_scalars_vectors_normals()
         dtype = self[name].dtype
         # only vtkDataArray subclasses can be set as active attributes
         if np.issubdtype(dtype, np.number) or np.issubdtype(dtype, bool):
             self.SetActiveScalars(name)
 
+    @property
+    def _active_normals_name(self) -> str | None:
+        """Return name of the active normals.
+
+        Returns
+        -------
+        Optional[str]
+            Name of the active normals.
+
+        Examples
+        --------
+        Create a mesh add a new point array with normals.
+
+        >>> import pyvista as pv
+        >>> import numpy as np
+        >>> mesh = pv.Sphere()
+        >>> normals = np.random.default_rng().random((mesh.n_points, 3))
+        >>> mesh.point_data['my-normals'] = normals
+
+        Set the active normals.
+
+        >>> mesh.point_data._active_normals_name = 'my-normals'
+        >>> mesh.point_data._active_normals_name
+        'my-normals'
+        """
+        if self.GetNormals() is not None:
+            return str(self.GetNormals().GetName())
+        return None
+
+    @_active_normals_name.setter
+    def _active_normals_name(self, name: str) -> None:  # numpydoc ignore=GL08
+        """Set name of the active normals.
+
+        Parameters
+        ----------
+        name : str
+            Name of the active normals.
+
+        """
+        # permit setting no active
+        if name is None:
+            self.SetActiveNormals(None)
+            return
+        self._raise_field_data_no_scalars_vectors_normals()
+        if name not in self:
+            raise KeyError(f'DataSetAttribute does not contain "{name}"')
+        # verify that the array has the correct number of components
+        n_comp = self.GetArray(name).GetNumberOfComponents()
+        if n_comp != 3:
+            raise ValueError(f'{name} needs 3 components, has ({n_comp})')
+        self.SetActiveNormals(name)
+
     @property
     def active_vectors_name(self) -> str | None:
         """Return name of the active vectors.
@@ -1186,7 +1238,7 @@ class DataSetAttributes(_vtk.VTKObjectWrapper):
         if name is None:
             self.SetActiveVectors(None)
             return
-        self._raise_field_data_no_scalars_vectors()
+        self._raise_field_data_no_scalars_vectors_normals()
         if name not in self:
             raise KeyError(f'DataSetAttribute does not contain "{name}"')
         # verify that the array has the correct number of components
diff --git a/pyvista/core/filters/poly_data.py b/pyvista/core/filters/poly_data.py
index 7975bf2f..d0154e1b 100644
--- a/pyvista/core/filters/poly_data.py
+++ b/pyvista/core/filters/poly_data.py
@@ -537,18 +537,22 @@ class PolyDataFilters(DataSetFilters):
                     faces=merged.GetCells(),
                     deep=False,
                 )
-                # Calling update() will modify the active scalars in this specific
-                # case. Store values to restore after updating.
+                # Calling update() will modify the active scalars and normals in this
+                # specific case. Store values to restore after updating.
                 active_point_scalars_name = merged.point_data.active_scalars_name
                 active_cell_scalars_name = merged.cell_data.active_scalars_name
+                active_point_normals_name = merged.point_data._active_normals_name
+                active_cell_normals_name = merged.cell_data._active_normals_name
 
                 polydata_merged.point_data.update(merged.point_data)
                 polydata_merged.cell_data.update(merged.cell_data)
                 polydata_merged.field_data.update(merged.field_data)
 
-                # restore active scalars
+                # restore active scalars and normals
                 polydata_merged.point_data.active_scalars_name = active_point_scalars_name
                 polydata_merged.cell_data.active_scalars_name = active_cell_scalars_name
+                polydata_merged.point_data._active_normals_name = active_point_normals_name
+                polydata_merged.cell_data._active_normals_name = active_cell_normals_name
 
                 merged = polydata_merged
 


Test patch:
diff --git a/tests/core/test_dataset_filters.py b/tests/core/test_dataset_filters.py
index 1f130739..cb1095a4 100644
--- a/tests/core/test_dataset_filters.py
+++ b/tests/core/test_dataset_filters.py
@@ -2862,6 +2862,29 @@ def test_merge_general(uniform):
     assert isinstance(merged, pv.PolyData)
 
 
+def test_merge_active_normals():
+    plane = pv.Plane()
+
+    # Check default normals
+    default_normal = np.array([0, 0, 1])
+    assert np.array_equal(plane["Normals"][0], default_normal)
+    assert np.array_equal(plane.active_normals[0], default_normal)
+    assert np.array_equal(plane.point_normals[0], default_normal)
+
+    # Customize the normals
+    plane["Normals"] *= -1
+    negative_normal = -default_normal
+    assert np.array_equal(plane["Normals"][0], negative_normal)
+    assert np.array_equal(plane.active_normals[0], negative_normal)
+    assert np.array_equal(plane.point_normals[0], negative_normal)
+
+    # Now test merge
+    merged = pv.merge([plane])
+    assert np.array_equal(merged["Normals"][0], negative_normal)
+    assert np.array_equal(merged.active_normals[0], negative_normal)
+    assert np.array_equal(merged.point_normals[0], negative_normal)
+
+
 def test_iadd_general(uniform, hexbeam, sphere):
     unstructured = hexbeam
     sphere_shifted = sphere.copy()
diff --git a/tests/core/test_datasetattributes.py b/tests/core/test_datasetattributes.py
index 2ae02f45..32f1ad95 100644
--- a/tests/core/test_datasetattributes.py
+++ b/tests/core/test_datasetattributes.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import os
 import platform
+import re
 from string import ascii_letters
 from string import digits
 from string import whitespace
@@ -171,6 +172,29 @@ def test_active_scalars_name(sphere):
     assert sphere.point_data.active_scalars_name is None
 
 
+def test_active_normals_name():
+    # Load dataset known to have active normals by default
+    sphere = pv.Sphere()
+    assert sphere.point_data._active_normals_name == 'Normals'
+    sphere.clear_data()
+    assert sphere.point_data._active_normals_name is None
+
+    # Set name of custom normals
+    key = 'data0'
+    normals = np.array([[0, 1, 0]] * sphere.n_points)
+    sphere.point_data[key] = normals
+    assert sphere.point_data._active_normals_name is None
+    sphere.point_data._active_normals_name = key
+    assert sphere.point_data._active_normals_name == 'data0'
+
+    # Test raises
+    sphere.point_data[key] = range(sphere.n_points)
+    with pytest.raises(ValueError, match=re.escape('data0 needs 3 components, has (1)')):
+        sphere.point_data._active_normals_name = key
+    with pytest.raises(KeyError, match='DataSetAttribute does not contain "foobar"'):
+        sphere.point_data._active_normals_name = 'foobar'
+
+
 def test_set_scalars(sphere):
     scalars = np.array(sphere.n_points)
     key = 'scalars'