Repository: qutip/qutip

Issue:
`Bloch` instance errors when trying render vectors
### Bug Description

Call to `b.render()` errors after performing `b.add_vectors(vec)` where `b` is an instance of `qutip.Bloch()`.

### Code to Reproduce the Bug

```shell
import qutip
b = qutip.Bloch()
b.make_sphere()
b.add_vectors([0,0,1])
b.render()
```


### Code Output

```shell
/tmp/ipykernel_377526/3029128797.py in <module>
      2 b.make_sphere()
      3 b.add_vectors([0,0,1])
----> 4 b.render()

~/.conda/envs/myenv/lib/python3.8/site-packages/qutip/bloch.py in render(self)
    503         self.plot_annotations()
    504         # Trigger an update of the Bloch sphere if it is already shown:
--> 505         self.fig.canvas.draw()
    506 
    507     def plot_back(self):

~/.local/lib/python3.8/site-packages/matplotlib/backends/backend_agg.py in draw(self)
    434              (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
    435               else nullcontext()):
--> 436             self.figure.draw(self.renderer)
    437             # A GUI class may be need to update a window using this draw, so
    438             # don't forget to call the superclass.

~/.local/lib/python3.8/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer, *args, **kwargs)
     71     @wraps(draw)
     72     def draw_wrapper(artist, renderer, *args, **kwargs):
---> 73         result = draw(artist, renderer, *args, **kwargs)
     74         if renderer._rasterizing:
     75             renderer.stop_rasterizing()

~/.local/lib/python3.8/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer)
     48                 renderer.start_filter()
     49 
---> 50             return draw(artist, renderer)
     51         finally:
     52             if artist.get_agg_filter() is not None:

~/.local/lib/python3.8/site-packages/matplotlib/figure.py in draw(self, renderer)
   2808 
   2809             self.patch.draw(renderer)
-> 2810             mimage._draw_list_compositing_images(
   2811                 renderer, self, artists, self.suppressComposite)
   2812 

~/.local/lib/python3.8/site-packages/matplotlib/image.py in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    130     if not_composite or not has_images:
    131         for a in artists:
--> 132             a.draw(renderer)
    133     else:
    134         # Composite any adjacent images together

~/.local/lib/python3.8/site-packages/matplotlib/artist.py in draw_wrapper(artist, renderer)
     48                 renderer.start_filter()
     49 
---> 50             return draw(artist, renderer)
     51         finally:
     52             if artist.get_agg_filter() is not None:

~/.local/lib/python3.8/site-packages/mpl_toolkits/mplot3d/axes3d.py in draw(self, renderer)
    449                                     for axis in self._get_axis_list()) + 1
    450                 collection_zorder = patch_zorder = zorder_offset
--> 451                 for artist in sorted(collections_and_patches,
    452                                      key=do_3d_projection,
    453                                      reverse=True):

~/.local/lib/python3.8/site-packages/mpl_toolkits/mplot3d/axes3d.py in do_3d_projection(artist)
    424                 """
    425                 try:
--> 426                     signature = inspect.signature(artist.do_3d_projection)
    427                     signature.bind()
    428                 # ValueError if `inspect.signature` cannot provide a signature

AttributeError: 'Arrow3D' object has no attribute 'do_3d_projection'
```


### Expected Behaviour

The code runs without an error.

### Your Environment

```shell
QuTiP Version:      4.6.3
Numpy Version:      1.22.2
Scipy Version:      1.8.0
Cython Version:     None
Matplotlib Version: 3.5.1
Python Version:     3.8.12
Number of CPUs:     16
BLAS Info:          OPENBLAS
OPENMP Installed:   False
INTEL MKL Ext:      False
Platform Info:      Linux (x86_64)
```


### Additional Context

This seems to be caused by [this](https://github.com/matplotlib/matplotlib/issues/21688) update in matplotlib. The solution seems to be to add the following method to `Arrow3D`.

```python
def do_3d_projection(self, renderer=None):
        xs3d, ys3d, zs3d = self._verts3d
        xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
        self.set_positions((xs[0],ys[0]),(xs[1],ys[1]))

        return np.min(zs)
```


Discussion:

Resolving patch:
diff --git a/qutip/bloch.py b/qutip/bloch.py
--- a/qutip/bloch.py
+++ b/qutip/bloch.py
@@ -41,6 +41,13 @@ def draw(self, renderer):
 
             self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
             FancyArrowPatch.draw(self, renderer)
+
+        def do_3d_projection(self, renderer=None):
+            # only called by matplotlib >= 3.5
+            xs3d, ys3d, zs3d = self._verts3d
+            xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
+            self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
+            return np.min(zs)
 except ImportError:
     pass
 
@@ -358,7 +365,7 @@ def add_vectors(self, vectors):
         vectors : array_like
             Array with vectors of unit length or smaller.
         """
-        if isinstance(vectors[0], (list, ndarray)):
+        if isinstance(vectors[0], (list, tuple, ndarray)):
             for vec in vectors:
                 self.vectors.append(vec)
         else:


Test patch:
diff --git a/qutip/tests/test_bloch.py b/qutip/tests/test_bloch.py
--- a/qutip/tests/test_bloch.py
+++ b/qutip/tests/test_bloch.py
@@ -165,3 +165,48 @@ def test_line(
     ):
         self.plot_line_test(fig_test, start_test, end_test, **kwargs)
         self.plot_line_ref(fig_ref, start_ref, end_ref, **kwargs)
+
+    def plot_vector_test(self, fig, vectors):
+        b = Bloch(fig=fig)
+        b.add_vectors(vectors)
+        b.render()
+
+    def plot_vector_ref(self, fig, vectors):
+        from qutip.bloch import Arrow3D
+        b = Bloch(fig=fig)
+        b.render()
+        colors = ['g', '#CC6600', 'b', 'r']
+
+        if not isinstance(vectors[0], (list, tuple, np.ndarray)):
+            vectors = [vectors]
+
+        for i, v in enumerate(vectors):
+            color = colors[i % len(colors)]
+            xs3d = v[1] * np.array([0, 1])
+            ys3d = -v[0] * np.array([0, 1])
+            zs3d = v[2] * np.array([0, 1])
+            a = Arrow3D(
+                xs3d, ys3d, zs3d,
+                mutation_scale=20, lw=3, arrowstyle="-|>", color=color)
+            b.axes.add_artist(a)
+
+    @pytest.mark.parametrize([
+        "vectors",
+    ], [
+        pytest.param(
+            (0, 0, 1), id="single-vector-tuple"),
+        pytest.param(
+            [0, 0, 1], id="single-vector-list"),
+       pytest.param(
+            np.array([0, 0, 1]), id="single-vector-numpy"),
+        pytest.param(
+            [(0, 0, 1), (0, 1, 0)], id="list-vectors-tuple"),
+        pytest.param(
+            [[0, 0, 1]], id="list-vectors-list"),
+        pytest.param(
+            [np.array([0, 0, 1])], id="list-vectors-numpy"),
+    ])
+    @check_pngs_equal
+    def test_vector(self, vectors, fig_test, fig_ref):
+        self.plot_vector_test(fig_test, vectors)
+        self.plot_vector_ref(fig_ref, vectors)