Repository: matplotlib/matplotlib

Issue:
[Bug]: Poly3DCollection initialization cannot properly handle parameter verts when it is a list of nested tuples and shade is False 
### Bug summary

The initialization of an `mpl_toolkits.mplot3d.Poly3DCollection` object cannot properly handle the parameter `verts` when `verts` = a list of (N, 3) array-like nested tuples, and `shade=False`.

### Code for reproduction

```Python
from mpl_toolkits.mplot3d import art3d
corners = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0))
tri = art3d.Poly3DCollection([corners], shade=True) # Failed when shade=True
# tri = art3d.Poly3DCollection([corners]) # Passed with the default setting shade=False
```


### Actual outcome

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[3], line 1
----> 1 tri = art3d.Poly3DCollection([corners], shade=True)

File ~/anaconda3/envs/testmpl/lib/python3.12/site-packages/mpl_toolkits/mplot3d/art3d.py:905, in Poly3DCollection.__init__(self, verts, zsort, shade, lightsource, *args, **kwargs)
    875 """
    876 Parameters
    877 ----------
   (...)
    902 and _edgecolors properties.
    903 """
    904 if shade:
--> 905     normals = _generate_normals(verts)
    906     facecolors = kwargs.get('facecolors', None)
    907     if facecolors is not None:

File ~/anaconda3/envs/testmpl/lib/python3.12/site-packages/mpl_toolkits/mplot3d/art3d.py:1222, in _generate_normals(polygons)
   1220         n = len(ps)
   1221         i1, i2, i3 = 0, n//3, 2*n//3
-> 1222         v1[poly_i, :] = ps[i1, :] - ps[i2, :]
   1223         v2[poly_i, :] = ps[i2, :] - ps[i3, :]
   1224 return np.cross(v1, v2)

TypeError: tuple indices must be integers or slices, not tuple

### Expected outcome

No error.

### Additional information

When `shade=True`, the `__init__` function will first call the function `_generate_normals(polygons)`, where `polygons=verts`. In our case, `verts` is not an instance of `np.ndarray` but a list, so it enters the for loop:
```python
        for poly_i, ps in enumerate(polygons):
            n = len(ps)
            i1, i2, i3 = 0, n//3, 2*n//3
            v1[poly_i, :] = ps[i1, :] - ps[i2, :]
            v2[poly_i, :] = ps[i2, :] - ps[i3, :]
```
`polygons` is `[((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0))]`, and `ps` is a nested tuple, so we need to convert `ps` to `np.ndarray` before using the array slicing like `ps[i1, :]`. A possible fix may be setting `ps = np.asarray(ps)` before array slicing.

### Operating system

_No response_

### Matplotlib Version

3.9.2

### Matplotlib Backend

_No response_

### Python version

_No response_

### Jupyter version

_No response_

### Installation

None


Discussion:
Yeah, I think your assessment is correct, the private function (and public constructor) is documented and says it takes "array-like" inputs, but then tries to do multiple indexing/subtraction on them. Since it says array like, it should be converted into an actual array before treating it as one. since it can be ragged, the outermost dimension is capable of being a list/tuple/etc, and that is why the loop in question exists.
### Good first issue - notes for new contributors

This issue is suited to new contributors because it does not require understanding of the
Matplotlib internals. To get started, please see our [contributing
guide](https://matplotlib.org/stable/devel/index).

**We do not assign issues**. Check the *Development* section in the sidebar for linked pull
requests (PRs). If there are none, feel free to start working on it. If there is an open PR, please
collaborate on the work by reviewing it rather than duplicating it in a competing PR.

If something is unclear, please reach out on any of our [communication
channels](https://matplotlib.org/stable/devel/contributing.html#get-connected).
I think this is a good first issue, as it is a bugfix with a simple fix and well defined scope. Tasks:
1) Fix the bug
2) Add tests which cover this case
Hello, I would like to work on this. I will follow the contributing guide and create a PR. 



Patch:
diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py
index 0467d2e96e5e..deb0ca34302c 100644
--- a/lib/mpl_toolkits/mplot3d/art3d.py
+++ b/lib/mpl_toolkits/mplot3d/art3d.py
@@ -1382,6 +1382,7 @@ def _generate_normals(polygons):
         v2 = np.empty((len(polygons), 3))
         for poly_i, ps in enumerate(polygons):
             n = len(ps)
+            ps = np.asarray(ps)
             i1, i2, i3 = 0, n//3, 2*n//3
             v1[poly_i, :] = ps[i1, :] - ps[i2, :]
             v2[poly_i, :] = ps[i2, :] - ps[i3, :]


Test patch:
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
index f4f7067b76bb..174c12608ae9 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py
@@ -3,7 +3,11 @@
 import matplotlib.pyplot as plt
 
 from matplotlib.backend_bases import MouseEvent
-from mpl_toolkits.mplot3d.art3d import Line3DCollection, _all_points_on_plane
+from mpl_toolkits.mplot3d.art3d import (
+    Line3DCollection,
+    Poly3DCollection,
+    _all_points_on_plane,
+)
 
 
 def test_scatter_3d_projection_conservation():
@@ -85,3 +89,14 @@ def test_all_points_on_plane():
     # All points lie on a plane
     points = np.array([[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0], [1, 2, 0]])
     assert _all_points_on_plane(*points.T)
+
+
+def test_generate_normals():
+    # Smoke test for https://github.com/matplotlib/matplotlib/issues/29156
+    vertices = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0))
+    shape = Poly3DCollection([vertices], edgecolors='r', shade=True)
+
+    fig = plt.figure()
+    ax = fig.add_subplot(projection='3d')
+    ax.add_collection3d(shape)
+    plt.draw()