Repository: pyvista/pyvista

Issue:
Make `center` consistently return a tuple
### Describe what maintenance you would like added.

The `center` property has an inconsistent return type. Some examples:

``` python
import pyvista as pv
pv.PolyData().center  # list
pv.MultiBlock().center  # numpy array
pv.Sphere().get_cell(0).center  # tuple
pv.PlaneSource().center  # tuple
```
Like most (if not all) vtk-related x-y-z properties, I expected the output to always be a tuple.

For datasets, the casting to a list was made with this commit https://github.com/pyvista/pyvista/commit/5145b98324df57f4aefe8c1d7560de8bba2a2e97

I suggest reverting this and standardizing `center` to always return a tuple of built-in floats for all classes across the entire repository.

### Links to source code.

See above.

### Pseudocode or Screenshots

See above.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pyvista/pyvista/pull/6469?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.35%. Comparing base [(`0ca3db6`)](https://app.codecov.io/gh/pyvista/pyvista/commit/0ca3db685269661e9836bbb59a2bc6a1f76bc46b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) to head [(`ddfb937`)](https://app.codecov.io/gh/pyvista/pyvista/commit/ddfb937d763620e99c610f5de2984ee0d5b8d74c?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 (0ca3db6) and HEAD (ddfb937). Click for more details.
> 
> <details><summary>HEAD has 11 uploads less than BASE</summary>
>
>| Flag | BASE (0ca3db6) | HEAD (ddfb937) |
>|------|------|------|
>||12|1|
></details>

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


```diff
@@            Coverage Diff             @@
##             main    #6469      +/-   ##
==========================================
- Coverage   97.28%   91.35%   -5.93%     
==========================================
  Files         142      142              
  Lines       27004    27006       +2     
==========================================
- Hits        26270    24672    -1598     
- Misses        734     2334    +1600     
```

</details>
pyvista-bot: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY - APP ID SHA256: d3574f413fe51079937885070831e027ca5b10f7facb9e609494e0a481a31f79 -->
🚀 Deployed on https://66b3cda0be40e514ff015836--pyvista-dev.netlify.app
user27182: pre-commit.ci autofix
user27182: Looks like all the other related PRs for refactoring/updating the typing tests have been merged, so this PR is ready for further review.

Patch:
diff --git a/doc/source/user-guide/simple.rst b/doc/source/user-guide/simple.rst
index 6dcb1d61..79936ed4 100644
--- a/doc/source/user-guide/simple.rst
+++ b/doc/source/user-guide/simple.rst
@@ -79,7 +79,7 @@ First, check out some common meta-properties:
     Where is the center of this mesh?
 
     >>> mesh.center
-    [897.0, 676.02, 132.19]
+    (897.0, 676.02, 132.19)
 
 
 Access the points by fetching the :attr:`points
diff --git a/pyvista/core/composite.py b/pyvista/core/composite.py
index 16f54a54..8c7cb26a 100644
--- a/pyvista/core/composite.py
+++ b/pyvista/core/composite.py
@@ -26,6 +26,7 @@ from ._typing_core import BoundsTuple
 
 if TYPE_CHECKING:  # pragma: no cover
     from ._typing_core import NumpyArray
+
 from .dataset import DataObject
 from .dataset import DataSet
 from .filters import CompositeFilters
@@ -210,12 +211,12 @@ class MultiBlock(
         return BoundsTuple(minima[0], maxima[0], minima[1], maxima[1], minima[2], maxima[2])
 
     @property
-    def center(self) -> NumpyArray[float]:
+    def center(self) -> tuple[float, float, float]:
         """Return the center of the bounding box.
 
         Returns
         -------
-        np.ndarray
+        tuple[float, float, float]
             Center of the bounding box.
 
         Examples
@@ -231,8 +232,7 @@ class MultiBlock(
         array([1., 1., 0.])
 
         """
-        # (typing.cast necessary to make mypy happy with np.reshape())
-        return np.reshape(cast(List[float], self.bounds), (3, 2)).mean(axis=1)
+        return tuple(np.reshape(self.bounds, (3, 2)).mean(axis=1).tolist())
 
     @property
     def length(self) -> float:
diff --git a/pyvista/core/dataset.py b/pyvista/core/dataset.py
index 69e25c0a..0038f0fa 100644
--- a/pyvista/core/dataset.py
+++ b/pyvista/core/dataset.py
@@ -1252,10 +1252,10 @@ class DataSet(DataSetFilters, DataObject):
         >>> import pyvista as pv
         >>> mesh = pv.Sphere()
         >>> mesh.center
-        [0.0, 0.0, 0.0]
+        (0.0, 0.0, 0.0)
         >>> trans = mesh.translate((2, 1, 2), inplace=False)
         >>> trans.center
-        [2.0, 1.0, 2.0]
+        (2.0, 1.0, 2.0)
 
         """
         transform = _vtk.vtkTransform()
@@ -1855,12 +1855,12 @@ class DataSet(DataSetFilters, DataObject):
         return self.GetLength()
 
     @property
-    def center(self) -> list[float]:
+    def center(self) -> tuple[float, float, float]:
         """Return the center of the bounding box.
 
         Returns
         -------
-        Vector
+        tuple[float, float, float]
             Center of the bounding box.
 
         Examples
@@ -1870,10 +1870,10 @@ class DataSet(DataSetFilters, DataObject):
         >>> import pyvista as pv
         >>> mesh = pv.Sphere(center=(1, 2, 0))
         >>> mesh.center
-        [1.0, 2.0, 0.0]
+        (1.0, 2.0, 0.0)
 
         """
-        return list(self.GetCenter())
+        return self.GetCenter()
 
     @property
     def volume(self) -> float:
diff --git a/pyvista/core/pointset.py b/pyvista/core/pointset.py
index ab16d6f4..102f3a6a 100644
--- a/pyvista/core/pointset.py
+++ b/pyvista/core/pointset.py
@@ -217,10 +217,10 @@ class _PointSet(DataSet):
         >>> import pyvista as pv
         >>> mesh = pv.Sphere()
         >>> mesh.center
-        [0.0, 0.0, 0.0]
+        (0.0, 0.0, 0.0)
         >>> trans = mesh.translate((2, 1, 2), inplace=True)
         >>> trans.center
-        [2.0, 1.0, 2.0]
+        (2.0, 1.0, 2.0)
 
         """
         if inplace:
diff --git a/pyvista/core/utilities/geometric_sources.py b/pyvista/core/utilities/geometric_sources.py
index b664674e..d4329334 100644
--- a/pyvista/core/utilities/geometric_sources.py
+++ b/pyvista/core/utilities/geometric_sources.py
@@ -141,12 +141,12 @@ if _vtk.vtk_version_info < (9, 3):
             self.phi_resolution = phi_resolution
 
         @property
-        def center(self) -> Sequence[float]:
+        def center(self) -> tuple[float, float, float]:
             """Get the center in ``[x, y, z]``. Axis of the capsule passes through this point.
 
             Returns
             -------
-            sequence[float]
+            tuple[float, float, float]
                 Center in ``[x, y, z]``. Axis of the capsule passes through this
                 point.
             """
@@ -358,12 +358,12 @@ class ConeSource(_vtk.vtkConeSource):
         self.resolution = resolution
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``. Axis of the cone passes through this point.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``. Axis of the cone passes through this
             point.
         """
@@ -588,7 +588,7 @@ class CylinderSource(_vtk.vtkCylinderSource):
     The above examples are similar in terms of their behavior.
     """
 
-    _new_attr_exceptions: ClassVar[list[str]] = ['_center', '_direction']
+    _new_attr_exceptions: ClassVar[list[str]] = ['_center', 'center', '_direction']
 
     def __init__(
         self,
@@ -601,7 +601,7 @@ class CylinderSource(_vtk.vtkCylinderSource):
     ):
         """Initialize the cylinder source class."""
         super().__init__()
-        self._center = center
+        self.center = center
         self._direction = direction
         self.radius = radius
         self.height = height
@@ -609,12 +609,12 @@ class CylinderSource(_vtk.vtkCylinderSource):
         self.capping = capping
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get location of the centroid in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``. Axis of the cylinder passes through this
             point.
         """
@@ -630,7 +630,8 @@ class CylinderSource(_vtk.vtkCylinderSource):
             Center in ``[x, y, z]``. Axis of the cylinder passes through this
             point.
         """
-        self._center = center
+        valid_center = _validation.validate_array3(center, dtype_out=float, to_tuple=True)
+        self._center = cast(Tuple[float, float, float], valid_center)
 
     @property
     def direction(self) -> Sequence[float]:
@@ -880,6 +881,7 @@ class Text3DSource(vtkVectorText):
     """
 
     _new_attr_exceptions: ClassVar[list[str]] = [
+        'center',
         '_center',
         '_height',
         '_width',
@@ -896,8 +898,8 @@ class Text3DSource(vtkVectorText):
         depth=None,
         width=None,
         height=None,
-        center=(0, 0, 0),
-        normal=(0, 0, 1),
+        center=(0.0, 0.0, 0.0),
+        normal=(0.0, 0.0, 1.0),
         process_empty_string=True,
     ):
         """Initialize source."""
@@ -908,7 +910,7 @@ class Text3DSource(vtkVectorText):
         # Set params
         self.string = "" if string is None else string
         self._process_empty_string = process_empty_string
-        self._center = center
+        self.center = center
         self._normal = normal
         self._height = height
         self._width = width
@@ -973,7 +975,8 @@ class Text3DSource(vtkVectorText):
 
     @center.setter
     def center(self, center: Sequence[float]):  # numpydoc ignore=GL08
-        self._center = float(center[0]), float(center[1]), float(center[2])
+        valid_center = _validation.validate_array3(center, dtype_out=float, to_tuple=True)
+        self._center = cast(Tuple[float, float, float], valid_center)
 
     @property
     def normal(self) -> tuple[float, float, float]:  # numpydoc ignore=RT01
@@ -1207,12 +1210,12 @@ class CubeSource(_vtk.vtkCubeSource):
         self.SetBounds(bounds)
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``.
         """
         return self.GetCenter()
@@ -1391,12 +1394,12 @@ class DiscSource(_vtk.vtkDiskSource):
         self.c_res = c_res
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``.
         """
         if pyvista.vtk_version_info >= (9, 2):  # pragma: no cover
@@ -1722,12 +1725,12 @@ class SphereSource(_vtk.vtkSphereSource):
         self.end_phi = end_phi
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``.
         """
         if pyvista.vtk_version_info >= (9, 2):
@@ -1970,12 +1973,12 @@ class PolygonSource(_vtk.vtkRegularPolygonSource):
         self.fill = fill
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``.
         """
         return self.GetCenter()
@@ -2283,14 +2286,14 @@ class PlaneSource(_vtk.vtkPlaneSource):
         self.SetYResolution(j_resolution)
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center in ``[x, y, z]``.
 
         The center of the plane is translated to the specified point.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center in ``[x, y, z]``.
         """
         return self.GetCenter()
@@ -2745,12 +2748,12 @@ class SuperquadricSource(_vtk.vtkSuperquadricSource):
         self.thickness = thickness
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Center of the superquadric in ``[x, y, z]``.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             Center of the superquadric in ``[x, y, z]``.
         """
         return self.GetCenter()
@@ -3141,7 +3144,7 @@ class AxesGeometrySource:
         BoundsTuple(x_min=-1.0, x_max=1.0, y_min=-1.0, y_max=1.0, z_min=-1.0, z_max=1.0)
 
         >>> axes_geometry_source.output.center
-        array([0.0, 0.0, 0.0])
+        (0.0, 0.0, 0.0)
 
         Get the asymmetric bounds.
 
@@ -3150,7 +3153,7 @@ class AxesGeometrySource:
         BoundsTuple(x_min=-0.10000000149011612, x_max=1.0, y_min=-0.10000000149011612, y_max=1.0, z_min=-0.10000000149011612, z_max=1.0)
 
         >>> axes_geometry_source.output.center
-        array([0.45, 0.45, 0.45])
+        (0.45, 0.45, 0.45)
 
         Show the difference in camera positioning with and without
         symmetric bounds. Orientation is added for visualization.
diff --git a/pyvista/core/utilities/image_sources.py b/pyvista/core/utilities/image_sources.py
index 1df3e1d0..c389bbc1 100644
--- a/pyvista/core/utilities/image_sources.py
+++ b/pyvista/core/utilities/image_sources.py
@@ -76,12 +76,12 @@ class ImageEllipsoidSource(_vtk.vtkImageEllipsoidSource):
         self.SetWholeExtent(whole_extent)
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center of the ellipsoid.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
             The center of the ellipsoid.
         """
         return self.GetCenter()
@@ -604,12 +604,12 @@ class ImageGaussianSource(_vtk.vtkImageGaussianSource):
             self.std = std
 
     @property
-    def center(self) -> Sequence[float]:
+    def center(self) -> tuple[float, float, float]:
         """Get the center of the gaussian.
 
         Returns
         -------
-        sequence[float]
+        tuple[float, float, float]
           The center of the gaussian.
         """
         return self.GetCenter()
diff --git a/pyvista/plotting/plotter.py b/pyvista/plotting/plotter.py
index bcc45723..79a675a6 100644
--- a/pyvista/plotting/plotter.py
+++ b/pyvista/plotting/plotter.py
@@ -1684,7 +1684,7 @@ class BasePlotter(PickingHelper, WidgetHelper):
 
         Returns
         -------
-        list[numpy.float64, numpy.float64, numpy.float64]
+        tuple[float, float, float]
             Center of the active renderer.
 
         Examples


Test patch:
diff --git a/tests/typing/test_return_type.py b/tests/typing/test_return_type.py
index 356b2609..a42b7d6f 100644
--- a/tests/typing/test_return_type.py
+++ b/tests/typing/test_return_type.py
@@ -47,6 +47,11 @@ def pytest_generate_tests(metafunc):
         class_names, class_types = get_classes_with_attribute('bounds')
         metafunc.parametrize('class_with_bounds', class_types, ids=class_names)
 
+    if 'class_with_center' in metafunc.fixturenames:
+        # Generate a separate test for any class that has a center
+        class_names, class_types = get_classes_with_attribute('center')
+        metafunc.parametrize('class_with_center', class_types, ids=class_names)
+
 
 def is_all_floats(iterable: Iterable):
     """Return True if input only has built-in floats."""
@@ -85,7 +90,7 @@ def test_bounds_tuple(class_with_bounds):
 
     instance = try_init_object(class_with_bounds, kwargs)
 
-    # Do test
+    # Test type at runtime
     bounds = instance.bounds
     assert len(bounds) == 6
     assert isinstance(bounds, pv.BoundsTuple)
@@ -94,3 +99,22 @@ def test_bounds_tuple(class_with_bounds):
     # Test type annotations
     return_type = get_property_return_type(class_with_bounds.bounds)
     assert return_type == 'BoundsTuple'
+
+
+def test_center_tuple(class_with_center):
+    # Define kwargs as required for some cases.
+    kwargs = {}
+    if class_with_center is pv.Renderer:
+        kwargs['parent'] = pv.Plotter()
+
+    instance = try_init_object(class_with_center, kwargs)
+
+    # Test type at runtime
+    center = instance.center
+    assert len(center) == 3
+    assert isinstance(center, tuple)
+    assert is_all_floats(center)
+
+    # Test type annotations
+    return_type = get_property_return_type(class_with_center.center)
+    assert return_type == 'tuple[float, float, float]'