BUG: Normal vectors in SlicePlot doesn't understand non-cartesian coordinate systems
### Bug report

Good news, this one already has a fix waiting in #3489, but I'm reporting it independently because it wasn't my main motivation in that PR and I actually encountered this bug now.

**Bug summary**
because `SlicePlot` instantiates `OffAxisSlicePlot` as well as `AxisAlignedSlicePlot` classes based on the value of the `normal` argument, it implements some magic to transcribe special values of normal vectors into strings, namely:
```
(1, 0, 0) -> "x"
(0, 1, 0) -> "y"
(0, 0, 1) -> "z"
```
The problem being that this correspondence is hard coded and doesn't account for the relevant geometry, so it only works with cartesian coordinates.

**Code for reproduction**


```python
import yt

ds = yt.load_sample("bw_spherical_2d")
yt.SlicePlot(ds, (0, 0, 1), "density")
```

**Actual outcome**

<details><summary>Traceback</summary>

```
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ <ipython-input-7-f6eeb60e5811>:1 in <module>                                                     │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:2484 in SlicePlot              │
│                                                                                                  │
│   2481 │   │   │   )                                                                             │
│   2482 │   │   │   del kwargs["north_vector"]                                                    │
│   2483 │   │                                                                                     │
│ ❱ 2484 │   │   return AxisAlignedSlicePlot(ds, normal, fields, *args, **kwargs)                  │
│   2485                                                                                           │
│   2486                                                                                           │
│   2487 def plot_2d(                                                                              │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:1584 in __init__               │
│                                                                                                  │
│   1581 │   ):                                                                                    │
│   1582 │   │   # this will handle time series data and controllers                               │
│   1583 │   │   axis = fix_axis(axis, ds)                                                         │
│ ❱ 1584 │   │   (bounds, center, display_center) = get_window_parameters(                         │
│   1585 │   │   │   axis, center, width, ds                                                       │
│   1586 │   │   )                                                                                 │
│   1587 │   │   if field_parameters is None:                                                      │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:77 in get_window_parameters    │
│                                                                                                  │
│     74                                                                                           │
│     75                                                                                           │
│     76 def get_window_parameters(axis, center, width, ds):                                       │
│ ❱   77 │   width = ds.coordinates.sanitize_width(axis, width, None)                              │
│     78 │   center, display_center = ds.coordinates.sanitize_center(center, axis)                 │
│     79 │   xax = ds.coordinates.x_axis[axis]                                                     │
│     80 │   yax = ds.coordinates.y_axis[axis]                                                     │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/geometry/coordinates/spherical_coordinates.py:304 in        │
│ sanitize_width                                                                                   │
│                                                                                                  │
│   301 │   │   return center, display_center                                                      │
│   302 │                                                                                          │
│   303 │   def sanitize_width(self, axis, width, depth):                                          │
│ ❱ 304 │   │   name = self.axis_name[axis]                                                        │
│   305 │   │   if width is not None:                                                              │
│   306 │   │   │   width = super().sanitize_width(axis, width, depth)                             │
│   307 │   │   elif name == "r":                                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
KeyError: 'z'
```

</details>

**Expected outcome**

Should be equivalent to
```python
yt.SlicePlot(ds, "phi", "density")
```
diff --git a/doc/cheatsheet.tex b/doc/cheatsheet.tex
index b3a2065e1..fcfeb248a 100644
--- a/doc/cheatsheet.tex
+++ b/doc/cheatsheet.tex
@@ -244,14 +244,13 @@ Or added to an existing dataset using \texttt{ds.add\_field}:
 
 \subsection{Slices and Projections}
 \settowidth{\MyLen}{\texttt{multicol} }
-\texttt{slc = yt.SlicePlot(ds, \textit{axis or normal vector}, \textit{field}, \textit{center=}, \textit{width=}, \textit{weight\_field=}, \textit{additional parameters})} \textemdash\ Make a slice plot
-perpendicular to \textit{axis} (specified via 'x', 'y', or 'z') or a normal vector for an off-axis slice of \textit{field} weighted by \textit{weight\_field} at (code-units) \textit{center} with
+\texttt{slc = yt.SlicePlot(ds, \textit{axis or normal vector}, \textit{fields}, \textit{center=}, \textit{width=}, \textit{weight\_field=}, \textit{additional parameters})} \textemdash\ Make a slice plot
+perpendicular to \textit{axis} (specified via 'x', 'y', or 'z') or a normal vector for an off-axis slice of \textit{fields} weighted by \textit{weight\_field} at (code-units) \textit{center} with
 \textit{width} in code units or a (value, unit) tuple. Hint: try \textit{yt.SlicePlot?} in IPython to see additional parameters.\\
 \texttt{slc.save(\textit{file\_prefix})} \textemdash\ Save the slice to a png with name prefix \textit{file\_prefix}.
 \texttt{.save()} works similarly for the commands below.\\
 
-\texttt{prj = yt.ProjectionPlot(ds, \textit{axis}, \textit{field}, \textit{additional params})} \textemdash\ Make a projection. \\
-\texttt{prj = yt.OffAxisProjectionPlot(ds, \textit{normal}, \textit{fields}, \textit{center=}, \textit{width=}, \textit{depth=},\textit{north\_vector=},\textit{weight\_field=})} \textemdash Make an off axis projection. Note this takes an array of fields. \\
+\texttt{prj = yt.ProjectionPlot(ds, \textit{axis or normal vector}, \textit{fields}, \textit{additional params})} \textemdash\ Same as \texttt{yt.SlicePlot} but for projections.\\
 
 \subsection{Plot Annotations}
 \settowidth{\MyLen}{\texttt{multicol} }
diff --git a/doc/source/analyzing/fields.rst b/doc/source/analyzing/fields.rst
index fb3ff0416..0be313dd5 100644
--- a/doc/source/analyzing/fields.rst
+++ b/doc/source/analyzing/fields.rst
@@ -643,16 +643,23 @@ this will be handled automatically:
 .. code-block:: python
 
     prj = yt.ProjectionPlot(
-        ds, "z", ("gas", "velocity_los"), weight_field=("gas", "density")
+        ds,
+        "z",
+        fields=("gas", "velocity_los"),
+        weight_field=("gas", "density"),
     )
 
 Which, because the axis is ``"z"``, will give you the same result if you had
-projected the ``"velocity_z"`` field. This also works for off-axis projections:
+projected the ``"velocity_z"`` field. This also works for off-axis projections,
+using an arbitrary normal vector
 
 .. code-block:: python
 
-    prj = yt.OffAxisProjectionPlot(
-        ds, [0.1, -0.2, 0.3], ("gas", "velocity_los"), weight_field=("gas", "density")
+    prj = yt.ProjectionPlot(
+        ds,
+        [0.1, -0.2, 0.3],
+        fields=("gas", "velocity_los"),
+        weight_field=("gas", "density"),
     )
 
 
diff --git a/doc/source/cookbook/complex_plots.rst b/doc/source/cookbook/complex_plots.rst
index ea8c4c6e9..efc6a79bb 100644
--- a/doc/source/cookbook/complex_plots.rst
+++ b/doc/source/cookbook/complex_plots.rst
@@ -34,7 +34,7 @@ with a fourth entering for particle data that is deposited onto a mesh
 is inherited by
 :class:`~yt.visualization.plot_window.AxisAlignedSlicePlot`,
 :class:`~yt.visualization.plot_window.OffAxisSlicePlot`,
-:class:`~yt.visualization.plot_window.ProjectionPlot`, and
+:class:`~yt.visualization.plot_window.AxisAlignedProjectionPlot`, and
 :class:`~yt.visualization.plot_window.OffAxisProjectionPlot`.  This
 controls the number of resolution elements in the
 :class:`~yt.visualization.fixed_resolution.FixedResolutionBuffer`,
diff --git a/doc/source/cookbook/simple_off_axis_projection.py b/doc/source/cookbook/simple_off_axis_projection.py
index 5570c8929..02e6ca320 100644
--- a/doc/source/cookbook/simple_off_axis_projection.py
+++ b/doc/source/cookbook/simple_off_axis_projection.py
@@ -11,7 +11,9 @@ L = sp.quantities.angular_momentum_vector()
 
 print(f"Angular momentum vector: {L}")
 
-# Create an OffAxisProjectionPlot of density centered on the object with the L
+# Create an off-axis ProjectionPlot of density centered on the object with the L
 # vector as its normal and a width of 25 kpc on a side
-p = yt.OffAxisProjectionPlot(ds, L, ("gas", "density"), sp.center, (25, "kpc"))
+p = yt.ProjectionPlot(
+    ds, L, fields=("gas", "density"), center=sp.center, width=(25, "kpc")
+)
 p.save()
diff --git a/doc/source/reference/api/api.rst b/doc/source/reference/api/api.rst
index e0f4a692a..f931a1aa7 100644
--- a/doc/source/reference/api/api.rst
+++ b/doc/source/reference/api/api.rst
@@ -15,6 +15,7 @@ SlicePlot and ProjectionPlot
    ~yt.visualization.plot_window.AxisAlignedSlicePlot
    ~yt.visualization.plot_window.OffAxisSlicePlot
    ~yt.visualization.plot_window.ProjectionPlot
+   ~yt.visualization.plot_window.AxisAlignedProjectionPlot
    ~yt.visualization.plot_window.OffAxisProjectionPlot
    ~yt.visualization.plot_window.WindowPlotMPL
    ~yt.visualization.plot_window.PlotWindow
diff --git a/doc/source/visualizing/callbacks.rst b/doc/source/visualizing/callbacks.rst
index ec5898caf..7c3567c10 100644
--- a/doc/source/visualizing/callbacks.rst
+++ b/doc/source/visualizing/callbacks.rst
@@ -14,8 +14,10 @@ lines, text, markers, streamlines, velocity vectors, contours, and more.
 Callbacks can be applied to plots created with
 :class:`~yt.visualization.plot_window.SlicePlot`,
 :class:`~yt.visualization.plot_window.ProjectionPlot`,
+:class:`~yt.visualization.plot_window.AxisAlignedSlicePlot`,
+:class:`~yt.visualization.plot_window.AxisAlignedProjectionPlot`,
 :class:`~yt.visualization.plot_window.OffAxisSlicePlot`, or
-:class:`~yt.visualization.plot_window.OffAxisProjectionPlot` by calling
+:class:`~yt.visualization.plot_window.OffAxisProjectionPlot`, by calling
 one of the ``annotate_`` methods that hang off of the plot object.
 The ``annotate_`` methods are dynamically generated based on the list
 of available callbacks.  For example:
diff --git a/doc/source/visualizing/plots.rst b/doc/source/visualizing/plots.rst
index 971ca569f..de3e93608 100644
--- a/doc/source/visualizing/plots.rst
+++ b/doc/source/visualizing/plots.rst
@@ -417,7 +417,7 @@ Here, ``W`` is the width of the projection in the x, y, *and* z
 directions.
 
 One can also generate annotated off axis projections using
-:class:`~yt.visualization.plot_window.OffAxisProjectionPlot`. These
+:class:`~yt.visualization.plot_window.ProjectionPlot`. These
 plots can be created in much the same way as an
 ``OffAxisSlicePlot``, requiring only an open dataset, a direction
 to project along, and a field to project.  For example:
@@ -429,12 +429,12 @@ to project along, and a field to project.  For example:
    ds = yt.load("IsolatedGalaxy/galaxy0030/galaxy0030")
    L = [1, 1, 0]  # vector normal to cutting plane
    north_vector = [-1, 1, 0]
-   prj = yt.OffAxisProjectionPlot(
+   prj = yt.ProjectionPlot(
        ds, L, ("gas", "density"), width=(25, "kpc"), north_vector=north_vector
    )
    prj.save()
 
-OffAxisProjectionPlots can also be created with a number of
+``OffAxisProjectionPlot`` objects can also be created with a number of
 keyword arguments, as described in
 :class:`~yt.visualization.plot_window.OffAxisProjectionPlot`
 
@@ -584,7 +584,7 @@ Note, the change in the field name from ``("deposit", "nbody_mass")`` to
 
    fn = cg.save_as_dataset(fields=[("deposit", "nbody_mass")])
    ds_grid = yt.load(fn)
-   p = yt.OffAxisProjectionPlot(ds_grid, [1, 1, 1], ("grid", "nbody_mass"))
+   p = yt.ProjectionPlot(ds_grid, [1, 1, 1], ("grid", "nbody_mass"))
    p.save()
 
 Plot Customization: Recentering, Resizing, Colormaps, and More
diff --git a/doc/source/yt4differences.rst b/doc/source/yt4differences.rst
index b9e4309fd..2a59c2c76 100644
--- a/doc/source/yt4differences.rst
+++ b/doc/source/yt4differences.rst
@@ -156,7 +156,7 @@ centre of the pixel and using the standard SPH smoothing formula. The heavy
 lifting in these functions is undertaken by cython functions.
 
 It is now possible to generate slice plots, projection plots, covering grids and
-arbitrary grids of smoothed quanitities using these operations. The following
+arbitrary grids of smoothed quantities using these operations. The following
 code demonstrates how this could be achieved. The following would use the scatter
 method:
 
diff --git a/yt/__init__.py b/yt/__init__.py
index bd9aa84f4..994b34523 100644
--- a/yt/__init__.py
+++ b/yt/__init__.py
@@ -102,6 +102,7 @@ from yt.utilities.parallel_tools.parallel_analysis_interface import (
 
 # Now individual component imports from the visualization API
 from yt.visualization.api import (
+    AxisAlignedProjectionPlot,
     AxisAlignedSlicePlot,
     FITSImageData,
     FITSOffAxisProjection,
diff --git a/yt/fields/tests/test_field_access.py b/yt/fields/tests/test_field_access.py
index 14951bdc0..f55a0c150 100644
--- a/yt/fields/tests/test_field_access.py
+++ b/yt/fields/tests/test_field_access.py
@@ -1,10 +1,6 @@
 from yt.data_objects.profiles import create_profile
 from yt.testing import assert_equal, fake_random_ds
-from yt.visualization.plot_window import (
-    OffAxisProjectionPlot,
-    ProjectionPlot,
-    SlicePlot,
-)
+from yt.visualization.plot_window import ProjectionPlot, SlicePlot
 from yt.visualization.profile_plotter import PhasePlot, ProfilePlot
 
 
@@ -29,7 +25,7 @@ def test_field_access():
         s = SlicePlot(ds, 2, field)
         oas = SlicePlot(ds, [1, 1, 1], field)
         p = ProjectionPlot(ds, 2, field)
-        oap = OffAxisProjectionPlot(ds, [1, 1, 1], field)
+        oap = ProjectionPlot(ds, [1, 1, 1], field)
 
         for plot_object in [s, oas, p, oap, prof, phase]:
             plot_object._setup_plots()
diff --git a/yt/visualization/api.py b/yt/visualization/api.py
index 3bd63bbb5..12bc0e601 100644
--- a/yt/visualization/api.py
+++ b/yt/visualization/api.py
@@ -23,6 +23,7 @@ from .line_plot import LineBuffer, LinePlot
 from .particle_plots import ParticlePhasePlot, ParticlePlot, ParticleProjectionPlot
 from .plot_modifications import PlotCallback, callback_registry
 from .plot_window import (
+    AxisAlignedProjectionPlot,
     AxisAlignedSlicePlot,
     OffAxisProjectionPlot,
     OffAxisSlicePlot,
diff --git a/yt/visualization/plot_window.py b/yt/visualization/plot_window.py
index 53a1fe9ad..89794400e 100644
--- a/yt/visualization/plot_window.py
+++ b/yt/visualization/plot_window.py
@@ -1,6 +1,8 @@
+import abc
 from collections import defaultdict
 from functools import wraps
 from numbers import Number
+from typing import Union
 
 import matplotlib
 import matplotlib.pyplot as plt
@@ -1491,7 +1493,272 @@ class PWViewerMPL(PlotWindow):
         return fig
 
 
-class AxisAlignedSlicePlot(PWViewerMPL):
+class NormalPlot(abc.ABC):
+    """This is the abstraction for SlicePlot and ProjectionPlot, where
+    we define the common sanitizing mechanism for user input (normal direction).
+    """
+
+    @staticmethod
+    def sanitize_normal_vector(ds, normal) -> Union[str, np.ndarray]:
+        """Return the name of a cartesian axis whener possible,
+        or a 3-element 1D ndarray of float64 in any other valid case.
+        Fail with a descriptive error message otherwise.
+        """
+        axis_names = ds.coordinates.axis_order
+
+        if isinstance(normal, str):
+            if normal not in axis_names:
+                names_str = ", ".join(f"'{name}'" for name in axis_names)
+                raise ValueError(
+                    f"'{normal}' is not a valid axis name. Expected one of {names_str}."
+                )
+            return normal
+
+        if isinstance(normal, (int, np.integer)):
+            if normal not in (0, 1, 2):
+                raise ValueError(
+                    f"{normal} is not a valid axis identifier. Expected either 0, 1, or 2."
+                )
+            return axis_names[normal]
+
+        if not is_sequence(normal):
+            raise TypeError(
+                f"{normal} is not a valid normal vector identifier. "
+                "Expected a string, integer or sequence of 3 floats."
+            )
+
+        if len(normal) != 3:
+            raise ValueError(
+                f"{normal} with length {len(normal)} is not a valid normal vector. "
+                "Expected a 3-element sequence."
+            )
+
+        try:
+            retv = np.array(normal, dtype="float64")
+            if retv.shape != (3,):
+                raise ValueError(f"{normal} is incorrectly shaped.")
+        except ValueError as exc:
+            raise TypeError(f"{normal} is not a valid normal vector.") from exc
+
+        nonzero_idx = np.nonzero(retv)[0]
+        if len(nonzero_idx) == 0:
+            raise ValueError(f"A null vector {normal} isn't a valid normal vector.")
+        if len(nonzero_idx) == 1:
+            return axis_names[nonzero_idx[0]]
+
+        return retv
+
+
+class SlicePlot(NormalPlot):
+    r"""
+    A dispatch class for :class:`yt.visualization.plot_window.AxisAlignedSlicePlot`
+    and :class:`yt.visualization.plot_window.OffAxisSlicePlot` objects.  This
+    essentially allows for a single entry point to both types of slice plots,
+    the distinction being determined by the specified normal vector to the
+    projection.
+
+    The returned plot object can be updated using one of the many helper
+    functions defined in PlotWindow.
+
+    Parameters
+    ----------
+
+    ds : :class:`yt.data_objects.static_output.Dataset`
+        This is the dataset object corresponding to the
+        simulation output to be plotted.
+    normal : int, str, or 3-element sequence of floats
+        This specifies the normal vector to the slice.
+        Valid int values are 0, 1 and 2. Coresponding str values depend on the
+        geometry of the dataset and are generally given by `ds.coordinates.axis_order`.
+        E.g. in cartesian they are 'x', 'y' and 'z'.
+        An arbitrary normal vector may be specified as a 3-element sequence of floats.
+
+        This returns a :class:`OffAxisSlicePlot` object or a
+        :class:`AxisAlignedSlicePlot` object, depending on wether the requested
+        normal directions corresponds to a natural axis of the dataset's geometry.
+
+    fields : a (or a list of) 2-tuple of strings (ftype, fname)
+         The name of the field(s) to be plotted.
+
+    The following are nominally keyword arguments passed onto the respective
+    slice plot objects generated by this function.
+
+    Keyword Arguments
+    -----------------
+
+    center : A sequence floats, a string, or a tuple.
+         The coordinate of the center of the image. If set to 'c', 'center' or
+         left blank, the plot is centered on the middle of the domain. If set to
+         'max' or 'm', the center will be located at the maximum of the
+         ('gas', 'density') field. Centering on the max or min of a specific
+         field is supported by providing a tuple such as ("min","temperature") or
+         ("max","dark_matter_density"). Units can be specified by passing in *center*
+         as a tuple containing a coordinate and string unit name or by passing
+         in a YTArray. If a list or unitless array is supplied, code units are
+         assumed.
+    width : tuple or a float.
+         Width can have four different formats to support windows with variable
+         x and y widths.  They are:
+
+         ==================================     =======================
+         format                                 example
+         ==================================     =======================
+         (float, string)                        (10,'kpc')
+         ((float, string), (float, string))     ((10,'kpc'),(15,'kpc'))
+         float                                  0.2
+         (float, float)                         (0.2, 0.3)
+         ==================================     =======================
+
+         For example, (10, 'kpc') requests a plot window that is 10 kiloparsecs
+         wide in the x and y directions, ((10,'kpc'),(15,'kpc')) requests a
+         window that is 10 kiloparsecs wide along the x axis and 15
+         kiloparsecs wide along the y axis.  In the other two examples, code
+         units are assumed, for example (0.2, 0.3) requests a plot that has an
+         x width of 0.2 and a y width of 0.3 in code units.  If units are
+         provided the resulting plot axis labels will use the supplied units.
+    axes_unit : string
+         The name of the unit for the tick labels on the x and y axes.
+         Defaults to None, which automatically picks an appropriate unit.
+         If axes_unit is '1', 'u', or 'unitary', it will not display the
+         units, and only show the axes name.
+    origin : string or length 1, 2, or 3 sequence.
+         The location of the origin of the plot coordinate system for
+         `AxisAlignedSlicePlot` object; for `OffAxisSlicePlot` objects this
+         parameter is discarded. This is typically represented by a '-'
+         separated string or a tuple of strings. In the first index the
+         y-location is given by 'lower', 'upper', or 'center'. The second index
+         is the x-location, given as 'left', 'right', or 'center'. Finally, the
+         whether the origin is applied in 'domain' space, plot 'window' space or
+         'native' simulation coordinate system is given. For example, both
+         'upper-right-domain' and ['upper', 'right', 'domain'] place the
+         origin in the upper right hand corner of domain space. If x or y
+         are not given, a value is inferred. For instance, 'left-domain'
+         corresponds to the lower-left hand corner of the simulation domain,
+         'center-domain' corresponds to the center of the simulation domain,
+         or 'center-window' for the center of the plot window. In the event
+         that none of these options place the origin in a desired location,
+         a sequence of tuples and a string specifying the
+         coordinate space can be given. If plain numeric types are input,
+         units of `code_length` are assumed. Further examples:
+
+         =============================================== ===============================
+         format                                          example
+         =============================================== ===============================
+         '{space}'                                       'domain'
+         '{xloc}-{space}'                                'left-window'
+         '{yloc}-{space}'                                'upper-domain'
+         '{yloc}-{xloc}-{space}'                         'lower-right-window'
+         ('{space}',)                                    ('window',)
+         ('{xloc}', '{space}')                           ('right', 'domain')
+         ('{yloc}', '{space}')                           ('lower', 'window')
+         ('{yloc}', '{xloc}', '{space}')                 ('lower', 'right', 'window')
+         ((yloc, '{unit}'), (xloc, '{unit}'), '{space}') ((0, 'm'), (.4, 'm'), 'window')
+         (xloc, yloc, '{space}')                         (0.23, 0.5, 'domain')
+         =============================================== ===============================
+    north_vector : a sequence of floats
+        A vector defining the 'up' direction in the `OffAxisSlicePlot`; not
+        used in `AxisAlignedSlicePlot`.  This option sets the orientation of the
+        slicing plane.  If not set, an arbitrary grid-aligned north-vector is
+        chosen.
+    fontsize : integer
+         The size of the fonts for the axis, colorbar, and tick labels.
+    field_parameters : dictionary
+         A dictionary of field parameters than can be accessed by derived
+         fields.
+    data_source : YTSelectionContainer Object
+         Object to be used for data selection.  Defaults to a region covering
+         the entire simulation.
+
+    Raises
+    ------
+
+    ValueError or TypeError
+        If `normal` cannot be interpreted as a valid normal direction.
+
+    Examples
+    --------
+
+    >>> from yt import load
+    >>> ds = load("IsolatedGalaxy/galaxy0030/galaxy0030")
+    >>> slc = SlicePlot(ds, "x", ("gas", "density"), center=[0.2, 0.3, 0.4])
+
+    >>> slc = SlicePlot(
+    ...     ds, [0.4, 0.2, -0.1], ("gas", "pressure"), north_vector=[0.2, -0.3, 0.1]
+    ... )
+
+    """
+
+    def __new__(
+        cls, ds, normal, fields, *args, **kwargs
+    ) -> Union["AxisAlignedSlicePlot", "OffAxisSlicePlot"]:
+        if cls is SlicePlot:
+            normal = cls.sanitize_normal_vector(ds, normal)
+            if isinstance(normal, str):
+                cls = AxisAlignedSlicePlot
+            else:
+                cls = OffAxisSlicePlot
+        self = object.__new__(cls)
+        return self
+
+
+class ProjectionPlot(NormalPlot):
+    r"""
+    A dispatch class for :class:`yt.visualization.plot_window.AxisAlignedProjectionPlot`
+    and :class:`yt.visualization.plot_window.OffAxisProjectionPlot` objects.  This
+    essentially allows for a single entry point to both types of projection plots,
+    the distinction being determined by the specified normal vector to the
+    slice.
+
+    The returned plot object can be updated using one of the many helper
+    functions defined in PlotWindow.
+
+    Parameters
+    ----------
+
+    ds : :class:`yt.data_objects.static_output.Dataset`
+        This is the dataset object corresponding to the
+        simulation output to be plotted.
+    normal : int, str, or 3-element sequence of floats
+        This specifies the normal vector to the slice.
+        Valid int values are 0, 1 and 2. Coresponding str values depend on the
+        geometry of the dataset and are generally given by `ds.coordinates.axis_order`.
+        E.g. in cartesian they are 'x', 'y' and 'z'.
+        An arbitrary normal vector may be specified as a 3-element sequence of floats.
+
+        This function will return a :class:`OffAxisProjectionPlot` object or a
+        :class:`AxisAlignedProjectionPlot` object, depending on wether the requested
+        normal directions corresponds to a natural axis of the dataset's geometry.
+
+    fields : a (or a list of) 2-tuple of strings (ftype, fname)
+         The name of the field(s) to be plotted.
+
+
+    Any additional positional and keyword arguments are passed down to the appropriate
+    return class. See :class:`yt.visualization.plot_window.AxisAlignedProjectionPlot`
+    and :class:`yt.visualization.plot_window.OffAxisProjectionPlot`.
+
+    Raises
+    ------
+
+    ValueError or TypeError
+        If `normal` cannot be interpreted as a valid normal direction.
+
+    """
+
+    def __new__(
+        cls, ds, normal, fields, *args, **kwargs
+    ) -> Union["AxisAlignedProjectionPlot", "OffAxisProjectionPlot"]:
+        if cls is ProjectionPlot:
+            normal = cls.sanitize_normal_vector(ds, normal)
+            if isinstance(normal, str):
+                cls = AxisAlignedProjectionPlot
+            else:
+                cls = OffAxisProjectionPlot
+        self = object.__new__(cls)
+        return self
+
+
+class AxisAlignedSlicePlot(SlicePlot, PWViewerMPL):
     r"""Creates a slice plot from a dataset
 
     Given a ds object, an axis to slice along, and a field name
@@ -1626,7 +1893,17 @@ class AxisAlignedSlicePlot(PWViewerMPL):
         aspect=None,
         data_source=None,
         buff_size=(800, 800),
+        *,
+        north_vector=None,
     ):
+        if north_vector is not None:
+            # this kwarg exists only for symmetry reasons with OffAxisSlicePlot
+            mylog.warning(
+                "Ignoring 'north_vector' keyword as it is ill-defined for "
+                "an AxisAlignedSlicePlot object."
+            )
+            del north_vector
+
         # this will handle time series data and controllers
         axis = fix_axis(axis, ds)
         (bounds, center, display_center) = get_window_parameters(
@@ -1676,7 +1953,7 @@ class AxisAlignedSlicePlot(PWViewerMPL):
         self.set_axes_unit(axes_unit)
 
 
-class ProjectionPlot(PWViewerMPL):
+class AxisAlignedProjectionPlot(ProjectionPlot, PWViewerMPL):
     r"""Creates a projection plot from a dataset
 
     Given a ds object, an axis to project along, and a field name
@@ -1820,7 +2097,7 @@ class ProjectionPlot(PWViewerMPL):
 
     >>> from yt import load
     >>> ds = load("IsolateGalaxygalaxy0030/galaxy0030")
-    >>> p = ProjectionPlot(ds, "z", ("gas", "density"), width=(20, "kpc"))
+    >>> p = AxisAlignedProjectionPlot(ds, "z", ("gas", "density"), width=(20, "kpc"))
 
     """
     _plot_type = "Projection"
@@ -1916,7 +2193,7 @@ class ProjectionPlot(PWViewerMPL):
         self.set_axes_unit(axes_unit)
 
 
-class OffAxisSlicePlot(PWViewerMPL):
+class OffAxisSlicePlot(SlicePlot, PWViewerMPL):
     r"""Creates an off axis slice plot from a dataset
 
     Given a ds object, a normal vector defining a slicing plane, and
@@ -2009,7 +2286,18 @@ class OffAxisSlicePlot(PWViewerMPL):
         field_parameters=None,
         data_source=None,
         buff_size=(800, 800),
+        *,
+        origin=None,
     ):
+        if origin is not None:
+            # this kwarg exists only for symmetry reasons with AxisAlignedSlicePlot
+            # in OffAxisSlicePlot, the origin is hardcoded
+            mylog.warning(
+                "Ignoring 'origin' keyword as it is ill-defined for "
+                "an OffAxisSlicePlot object."
+            )
+            del origin
+
         (bounds, center_rot) = get_oblique_window_parameters(normal, center, width, ds)
         if field_parameters is None:
             field_parameters = {}
@@ -2095,7 +2383,7 @@ class OffAxisProjectionDummyDataSource:
         return self.dd._determine_fields(*args)
 
 
-class OffAxisProjectionPlot(PWViewerMPL):
+class OffAxisProjectionPlot(ProjectionPlot, PWViewerMPL):
     r"""Creates an off axis projection plot from a dataset
 
     Given a ds object, a normal vector to project along, and
@@ -2341,197 +2629,6 @@ class WindowPlotMPL(ImagePlotMPL):
         self.axes = self.figure.add_axes(axrect, projection=self._projection)
 
 
-def SlicePlot(ds, normal=None, fields=None, axis=None, *args, **kwargs):
-    r"""
-    A factory function for
-    :class:`yt.visualization.plot_window.AxisAlignedSlicePlot`
-    and :class:`yt.visualization.plot_window.OffAxisSlicePlot` objects.  This
-    essentially allows for a single entry point to both types of slice plots,
-    the distinction being determined by the specified normal vector to the
-    slice.
-
-    The returned plot object can be updated using one of the many helper
-    functions defined in PlotWindow.
-
-    Parameters
-    ----------
-
-    ds : :class:`yt.data_objects.static_output.Dataset`
-        This is the dataset object corresponding to the
-        simulation output to be plotted.
-    normal : int or one of 'x', 'y', 'z', or sequence of floats
-        This specifies the normal vector to the slice.  If given as an integer
-        or a coordinate string (0=x, 1=y, 2=z), this function will return an
-        :class:`AxisAlignedSlicePlot` object.  If given as a sequence of floats,
-        this is interpreted as an off-axis vector and an
-        :class:`OffAxisSlicePlot` object is returned.
-    fields : string
-         The name of the field(s) to be plotted.
-    axis : int or one of 'x', 'y', 'z'
-         An int corresponding to the axis to slice along (0=x, 1=y, 2=z)
-         or the axis name itself.  If specified, this will replace normal.
-
-
-    The following are nominally keyword arguments passed onto the respective
-    slice plot objects generated by this function.
-
-    Keyword Arguments
-    -----------------
-
-    center : A sequence floats, a string, or a tuple.
-         The coordinate of the center of the image. If set to 'c', 'center' or
-         left blank, the plot is centered on the middle of the domain. If set to
-         'max' or 'm', the center will be located at the maximum of the
-         ('gas', 'density') field. Centering on the max or min of a specific
-         field is supported by providing a tuple such as ("min","temperature") or
-         ("max","dark_matter_density"). Units can be specified by passing in *center*
-         as a tuple containing a coordinate and string unit name or by passing
-         in a YTArray. If a list or unitless array is supplied, code units are
-         assumed.
-    width : tuple or a float.
-         Width can have four different formats to support windows with variable
-         x and y widths.  They are:
-
-         ==================================     =======================
-         format                                 example
-         ==================================     =======================
-         (float, string)                        (10,'kpc')
-         ((float, string), (float, string))     ((10,'kpc'),(15,'kpc'))
-         float                                  0.2
-         (float, float)                         (0.2, 0.3)
-         ==================================     =======================
-
-         For example, (10, 'kpc') requests a plot window that is 10 kiloparsecs
-         wide in the x and y directions, ((10,'kpc'),(15,'kpc')) requests a
-         window that is 10 kiloparsecs wide along the x axis and 15
-         kiloparsecs wide along the y axis.  In the other two examples, code
-         units are assumed, for example (0.2, 0.3) requests a plot that has an
-         x width of 0.2 and a y width of 0.3 in code units.  If units are
-         provided the resulting plot axis labels will use the supplied units.
-    axes_unit : string
-         The name of the unit for the tick labels on the x and y axes.
-         Defaults to None, which automatically picks an appropriate unit.
-         If axes_unit is '1', 'u', or 'unitary', it will not display the
-         units, and only show the axes name.
-    origin : string or length 1, 2, or 3 sequence.
-         The location of the origin of the plot coordinate system for
-         `AxisAlignedSlicePlot` object; for `OffAxisSlicePlot` objects this
-         parameter is discarded. This is typically represented by a '-'
-         separated string or a tuple of strings. In the first index the
-         y-location is given by 'lower', 'upper', or 'center'. The second index
-         is the x-location, given as 'left', 'right', or 'center'. Finally, the
-         whether the origin is applied in 'domain' space, plot 'window' space or
-         'native' simulation coordinate system is given. For example, both
-         'upper-right-domain' and ['upper', 'right', 'domain'] place the
-         origin in the upper right hand corner of domain space. If x or y
-         are not given, a value is inferred. For instance, 'left-domain'
-         corresponds to the lower-left hand corner of the simulation domain,
-         'center-domain' corresponds to the center of the simulation domain,
-         or 'center-window' for the center of the plot window. In the event
-         that none of these options place the origin in a desired location,
-         a sequence of tuples and a string specifying the
-         coordinate space can be given. If plain numeric types are input,
-         units of `code_length` are assumed. Further examples:
-
-         =============================================== ===============================
-         format                                          example
-         =============================================== ===============================
-         '{space}'                                       'domain'
-         '{xloc}-{space}'                                'left-window'
-         '{yloc}-{space}'                                'upper-domain'
-         '{yloc}-{xloc}-{space}'                         'lower-right-window'
-         ('{space}',)                                    ('window',)
-         ('{xloc}', '{space}')                           ('right', 'domain')
-         ('{yloc}', '{space}')                           ('lower', 'window')
-         ('{yloc}', '{xloc}', '{space}')                 ('lower', 'right', 'window')
-         ((yloc, '{unit}'), (xloc, '{unit}'), '{space}') ((0, 'm'), (.4, 'm'), 'window')
-         (xloc, yloc, '{space}')                         (0.23, 0.5, 'domain')
-         =============================================== ===============================
-    north_vector : a sequence of floats
-        A vector defining the 'up' direction in the `OffAxisSlicePlot`; not
-        used in `AxisAlignedSlicePlot`.  This option sets the orientation of the
-        slicing plane.  If not set, an arbitrary grid-aligned north-vector is
-        chosen.
-    fontsize : integer
-         The size of the fonts for the axis, colorbar, and tick labels.
-    field_parameters : dictionary
-         A dictionary of field parameters than can be accessed by derived
-         fields.
-    data_source : YTSelectionContainer Object
-         Object to be used for data selection.  Defaults to a region covering
-         the entire simulation.
-
-    Raises
-    ------
-
-    AssertionError
-        If a proper normal axis is not specified via the normal or axis
-        keywords, and/or if a field to plot is not specified.
-
-    Examples
-    --------
-
-    >>> from yt import load
-    >>> ds = load("IsolatedGalaxy/galaxy0030/galaxy0030")
-    >>> slc = SlicePlot(ds, "x", ("gas", "density"), center=[0.2, 0.3, 0.4])
-
-    >>> slc = SlicePlot(
-    ...     ds, [0.4, 0.2, -0.1], ("gas", "pressure"), north_vector=[0.2, -0.3, 0.1]
-    ... )
-
-    """
-    if axis is not None:
-        issue_deprecation_warning(
-            "SlicePlot's argument 'axis' is a deprecated alias for 'normal', it "
-            "will be removed in a future version of yt.",
-            since="4.0.0",
-            removal="4.1.0",
-        )
-        if normal is not None:
-            raise TypeError(
-                "SlicePlot() received incompatible arguments 'axis' and 'normal'"
-            )
-        normal = axis
-
-    # to keep positional ordering we had to make 'normal' and 'fields' keywords
-    if normal is None:
-        raise TypeError("Missing argument in SlicePlot(): 'normal'")
-
-    if fields is None:
-        raise TypeError("Missing argument in SlicePlot(): 'fields'")
-
-    # use an AxisAlignedSlicePlot where possible, e.g.:
-    # maybe someone passed normal=[0,0,0.2] when they should have just used "z"
-    if is_sequence(normal) and not isinstance(normal, str):
-        if np.count_nonzero(normal) == 1:
-            normal = ("x", "y", "z")[np.nonzero(normal)[0][0]]
-        else:
-            normal = np.array(normal, dtype="float64")
-            np.divide(normal, np.dot(normal, normal), normal)
-
-    # by now the normal should be properly set to get either a On/Off Axis plot
-    if is_sequence(normal) and not isinstance(normal, str):
-        # OffAxisSlicePlot has hardcoded origin; remove it if in kwargs
-        if "origin" in kwargs:
-            mylog.warning(
-                "Ignoring 'origin' keyword as it is ill-defined for "
-                "an OffAxisSlicePlot object."
-            )
-            del kwargs["origin"]
-
-        return OffAxisSlicePlot(ds, normal, fields, *args, **kwargs)
-    else:
-        # north_vector not used in AxisAlignedSlicePlots; remove it if in kwargs
-        if "north_vector" in kwargs:
-            mylog.warning(
-                "Ignoring 'north_vector' keyword as it is ill-defined for "
-                "an AxisAlignedSlicePlot object."
-            )
-            del kwargs["north_vector"]
-
-        return AxisAlignedSlicePlot(ds, normal, fields, *args, **kwargs)
-
-
 def plot_2d(
     ds,
     fields,
diff --git a/yt/visualization/tests/test_plotwindow.py b/yt/visualization/tests/test_plotwindow.py
index 08730a2c5..c73e77c3a 100644
--- a/yt/visualization/tests/test_plotwindow.py
+++ b/yt/visualization/tests/test_plotwindow.py
@@ -15,6 +15,7 @@ from yt.testing import (
     assert_fname,
     assert_raises,
     assert_rel_equal,
+    fake_amr_ds,
     fake_random_ds,
     requires_file,
 )
@@ -26,7 +27,10 @@ from yt.utilities.answer_testing.framework import (
     requires_ds,
 )
 from yt.utilities.exceptions import YTInvalidFieldType
-from yt.visualization.api import (
+from yt.visualization.plot_window import (
+    AxisAlignedProjectionPlot,
+    AxisAlignedSlicePlot,
+    NormalPlot,
     OffAxisProjectionPlot,
     OffAxisSlicePlot,
     ProjectionPlot,
@@ -730,3 +734,73 @@ def test_nan_data():
 
     with tempfile.NamedTemporaryFile(suffix="png") as f:
         plot.save(f.name)
+
+
+def test_sanitize_valid_normal_vector():
+    # note: we don't test against non-cartesian geometries
+    # because the way normal "vectors" work isn't cleary
+    # specified and works more as an implementation detail
+    # at the moment
+    ds = fake_amr_ds(geometry="cartesian")
+
+    # We allow maximal polymorphism for axis-aligned directions:
+    # even if 3-component vector is received, we want to use the
+    # AxisAligned* plotting class (as opposed to OffAxis*) because
+    # it's much easier to optimize so it's expected to be more
+    # performant.
+    axis_label_from_inputs = {
+        "x": ["x", 0, [1, 0, 0], [0.1, 0.0, 0.0], [-10, 0, 0]],
+        "y": ["y", 1, [0, 1, 0], [0.0, 0.1, 0.0], [0, -10, 0]],
+        "z": ["z", 2, [0, 0, 1], [0.0, 0.0, 0.1], [0, 0, -10]],
+    }
+    for expected, user_inputs in axis_label_from_inputs.items():
+        for ui in user_inputs:
+            assert NormalPlot.sanitize_normal_vector(ds, ui) == expected
+
+    # arbitrary 3-floats sequences are also valid input.
+    # They should be returned as np.ndarrays, but the norm and orientation
+    # could be altered. What's important is that their direction is preserved.
+    for ui in [(1, 1, 1), [0.0, -3, 1e9], np.ones(3, dtype="int8")]:
+        res = NormalPlot.sanitize_normal_vector(ds, ui)
+        assert isinstance(res, np.ndarray)
+        assert res.dtype == np.float64
+        assert_array_equal(
+            np.cross(ui, res),
+            [0, 0, 0],
+        )
+
+
+def test_reject_invalid_normal_vector():
+    ds = fake_amr_ds(geometry="cartesian")
+    for ui in [0.0, 1.0, 2.0, 3.0]:
+        # acceptable scalar numeric values are restricted to integers.
+        # Floats might be a sign that something went wrong upstream
+        # e.g., rounding errors, parsing error...
+        assert_raises(TypeError, NormalPlot.sanitize_normal_vector, ds, ui)
+    for ui in [
+        "X",
+        "xy",
+        "not-an-axis",
+        (0, 0, 0),
+        [0, 0, 0],
+        np.zeros(3),
+        [1, 0, 0, 0],
+        [1, 0],
+        [1],
+        [0],
+        3,
+        10,
+    ]:
+        assert_raises(ValueError, NormalPlot.sanitize_normal_vector, ds, ui)
+
+
+def test_dispatch_plot_classes():
+    ds = fake_random_ds(16)
+    p1 = ProjectionPlot(ds, "z", ("gas", "density"))
+    p2 = ProjectionPlot(ds, (1, 2, 3), ("gas", "density"))
+    s1 = SlicePlot(ds, "z", ("gas", "density"))
+    s2 = SlicePlot(ds, (1, 2, 3), ("gas", "density"))
+    assert isinstance(p1, AxisAlignedProjectionPlot)
+    assert isinstance(p2, OffAxisProjectionPlot)
+    assert isinstance(s1, AxisAlignedSlicePlot)
+    assert isinstance(s2, OffAxisSlicePlot)
['doc/source/reference/api/api.rst', 'yt/visualization/plot_window.py', 'doc/source/cookbook/simple_off_axis_projection.py', 'yt/__init__.py', 'yt/visualization/tests/test_plotwindow.py', 'doc/source/visualizing/callbacks.rst', 'doc/cheatsheet.tex', 'yt/fields/tests/test_field_access.py', 'doc/source/analyzing/fields.rst', 'doc/source/cookbook/complex_plots.rst', 'doc/source/visualizing/plots.rst', 'doc/source/yt4differences.rst', 'yt/visualization/api.py']