Repository: GenericMappingTools/pygmt

Issue:
Wrapper for plot3d
**Description of the desired feature**

Implement [`plot3d`](https://docs.generic-mapping-tools.org/latest/plot3d.html) which "Plot lines, polygons, and symbols in 3-D". Basically a 3D version of ['plot`](https://docs.generic-mapping-tools.org/latest/plot.html). Below is an example of such a 3D plot from the GMT gallery:

![Example 8 using plot3d from GMT gallery](https://docs.generic-mapping-tools.org/latest/_images/ex08.png)

<!-- Please be as detailed as you can in your description. If possible, include an example of how you would like to use this feature (even better if it's a code example). -->

This would build upon the [`grdview`](https://www.pygmt.org/latest/api/generated/pygmt.Figure.grdview.html) function I last wrapped in #330, extending it to vector symbols. For example, we could plot the change in height of a DEM surface (h) along a transect line (x), over time (t)! Will be keen to get this done for some of my current satellite altimetry work :grin:

**Are you willing to help implement and maintain this feature?** Yes

<!-- Every feature we add is code that we will have to maintain and keep updated. This takes a lot of effort. If you are willing to be involved in the project and help maintain your feature, it will make it easier for us to accept it. -->

Discussion:

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 09e822bc..0598fd10 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -26,6 +26,7 @@ Plotting data and laying out the map:
     Figure.coast
     Figure.colorbar
     Figure.plot
+    Figure.plot3d
     Figure.contour
     Figure.grdcontour
     Figure.grdimage
diff --git a/examples/gallery/plot/scatter3d.py b/examples/gallery/plot/scatter3d.py
new file mode 100644
index 00000000..047b2225
--- /dev/null
+++ b/examples/gallery/plot/scatter3d.py
@@ -0,0 +1,53 @@
+"""
+3D Scatter plots
+----------------
+
+The :meth:`pygmt.Figure.plot3d` method can be used to plot symbols in 3D.
+In the example below, we show how the
+`Iris flower dataset <https://en.wikipedia.org/wiki/Iris_flower_data_set/>`__
+can be visualized using a perspective 3-dimensional plot. The ``region``
+argument has to include the :math:`x`, :math:`y`, :math:`z` axis limits in the
+form of (xmin, xmax, ymin, ymax, zmin, zmax), which can be done automatically
+using :meth:`pygmt.info`. To include the z-axis stick, set ``frame`` as a
+minimum to something like ``frame=["WsNeZ", "zaf"]``. Use ``perspective`` to
+control the azimuth and elevation angle of the view, and ``zscale`` to adjust
+the vertical exaggeration factor.
+"""
+
+import pandas as pd
+import pygmt
+
+# Load sample iris data, and convert 'species' column to categorical dtype
+df = pd.read_csv("https://github.com/mwaskom/seaborn-data/raw/master/iris.csv")
+df["species"] = df.species.astype(dtype="category")
+
+# Use pygmt.info to get region bounds (xmin, xmax, ymin, ymax, zmin, zmax)
+# The below example will return a numpy array like [0., 3., 4., 8., 1., 7.]
+region = pygmt.info(
+    table=df[["petal_width", "sepal_length", "petal_length"]],  # x, y, z columns
+    per_column=True,  # report output as a numpy array
+    spacing="1/2/0.5",  # rounds x, y and z intervals by 1, 2 and 0.5 respectively
+)
+
+# Make our 3D scatter plot, coloring each of the 3 species differently
+fig = pygmt.Figure()
+pygmt.makecpt(cmap="cubhelix", color_model="+c", series=(0, 3, 1))
+fig.plot3d(
+    x=df.petal_width,
+    y=df.sepal_length,
+    z=df.petal_length,
+    sizes=0.1 * df.sepal_width,  # Vary each symbol size according to a data column
+    color=df.species.cat.codes.astype(int),  # Points colored by categorical number code
+    cmap=True,  # Use colormap created by makecpt
+    region=region,  # (xmin, xmax, ymin, ymax, zmin, zmax)
+    frame=[
+        "WsNeZ3",  # z axis label positioned on 3rd corner
+        'xafg+l"Petal Width"',
+        'yafg+l"Sepal Length"',
+        'zafg+l"Petal Length"',
+    ],
+    style="uc",  # 3D cUbe, with size in centimeter units
+    perspective=[315, 25],  # Azimuth NorthWest (315°), at elevation 25°
+    zscale=1.5,  # Vertical exaggeration factor
+)
+fig.show()
diff --git a/pygmt/base_plotting.py b/pygmt/base_plotting.py
index 37b938c7..f75f69da 100644
--- a/pygmt/base_plotting.py
+++ b/pygmt/base_plotting.py
@@ -816,6 +816,189 @@ class BasePlotting:
                 arg_str = " ".join([fname, build_arg_string(kwargs)])
                 lib.call_module("plot", arg_str)
 
+    @fmt_docstring
+    @use_alias(
+        A="straight_line",
+        B="frame",
+        C="cmap",
+        D="offset",
+        G="color",
+        I="intensity",
+        J="projection",
+        Jz="zscale",
+        JZ="zsize",
+        L="close",
+        N="no_clip",
+        Q="no_sort",
+        R="region",
+        S="style",
+        V="verbose",
+        W="pen",
+        X="xshift",
+        Y="yshift",
+        Z="zvalue",
+        i="columns",
+        l="label",
+        p="perspective",
+        t="transparency",
+    )
+    @kwargs_to_strings(R="sequence", i="sequence_comma", p="sequence")
+    def plot3d(
+        self, x=None, y=None, z=None, data=None, sizes=None, direction=None, **kwargs
+    ):
+        """
+        Plot lines, polygons, and symbols in 3-D
+
+        Takes a matrix, (x,y,z) triplets, or a file name as input and plots
+        lines, polygons, or symbols at those locations in 3-D.
+
+        Must provide either *data* or *x*, *y* and *z*.
+
+        If providing data through *x*, *y* and *z*, *color* can be a 1d array
+        that will be mapped to a colormap.
+
+        If a symbol is selected and no symbol size given, then plot3d will
+        interpret the fourth column of the input data as symbol size. Symbols
+        whose size is <= 0 are skipped. If no symbols are specified then the
+        symbol code (see *style* below) must be present as last column in the
+        input. If *style* is not used, a line connecting the data points will
+        be drawn instead. To explicitly close polygons, use *close*. Select a
+        fill with *color*. If *color* is set, *pen* will control whether the
+        polygon outline is drawn or not. If a symbol is selected, *color* and
+        *pen* determines the fill and outline/no outline, respectively.
+
+        Full option list at :gmt-docs:`plot3d.html`
+
+        {aliases}
+
+        Parameters
+        ----------
+        x/y/z : float or 1d arrays
+            The x, y, and z coordinates, or arrays of x, y and z coordinates of
+            the data points
+        data : str or 2d array
+            Either a data file name or a 2d numpy array with the tabular data.
+            Use option *columns* (i) to choose which columns are x, y, z,
+            color, and size, respectively.
+        sizes : 1d array
+            The sizes of the data points in units specified in *style* (S).
+            Only valid if using *x*, *y* and *z*.
+        direction : list of two 1d arrays
+            If plotting vectors (using ``style='V'`` or ``style='v'``), then
+            should be a list of two 1d arrays with the vector directions. These
+            can be angle and length, azimuth and length, or x and y components,
+            depending on the style options chosen.
+        {J}
+        zscale/zsize : float or str
+            Set z-axis scaling or z-axis size.
+        {R}
+        straight_line : bool or str
+            ``[m|p|x|y]``.
+            By default, geographic line segments are drawn as great circle
+            arcs. To draw them as straight lines, use *straight_line*.
+            Alternatively, add **m** to draw the line by first following a
+            meridian, then a parallel. Or append **p** to start following a
+            parallel, then a meridian. (This can be practical to draw a line
+            along parallels, for example). For Cartesian data, points are
+            simply connected, unless you append **x** or **y** to draw
+            stair-case curves that whose first move is along *x* or *y*,
+            respectively. **Note**: The **straight_line** option requires
+            constant *z*-coordinates.
+        {B}
+        {CPT}
+        offset : str
+            ``dx/dy[/dz]``.
+            Offset the plot symbol or line locations by the given amounts
+            *dx/dy*[*dz*] [Default is no offset].
+        {G}
+        intensity : float or bool
+            Provide an *intens* value (nominally in the -1 to +1 range) to
+            modulate the fill color by simulating illumination [None]. If
+            using ``intensity=True``, we will instead read *intens* from the
+            first data column after the symbol parameters (if given).
+        close : str
+            ``[+b|d|D][+xl|r|x0][+yl|r|y0][+ppen]``.
+            Force closed polygons. Full documentation is at
+            :gmt-docs:`plot3d.html#l`.
+        no_clip : bool or str
+            ``[c|r]``.
+            Do NOT clip symbols that fall outside map border [Default plots
+            points whose coordinates are strictly inside the map border only].
+            The option does not apply to lines and polygons which are always
+            clipped to the map region. For periodic (360-longitude) maps we
+            must plot all symbols twice in case they are clipped by the
+            repeating boundary. ``no_clip=True`` will turn off clipping and not
+            plot repeating symbols. Use ``no_clip="r"`` to turn off clipping
+            but retain the plotting of such repeating symbols, or use
+            ``no_clip="c"`` to retain clipping but turn off plotting of
+            repeating symbols.
+        no_sort : bool
+            Turn off the automatic sorting of items based on their distance
+            from the viewer. The default is to sort the items so that items in
+            the foreground are plotted after items in the background.
+        style : str
+            Plot symbols. Full documentation is at :gmt-docs:`plot3d.html#s`.
+        {U}
+        {V}
+        {W}
+        {XY}
+        zvalue : str
+            ``value|file``.
+            Instead of specifying a symbol or polygon fill and outline color
+            via **color** and **pen**, give both a *value* via **zvalue** and a
+            color lookup table via **cmap**.  Alternatively, give the name of a
+            *file* with one z-value (read from the last column) for each
+            polygon in the input data. To apply it to the fill color, use
+            ``color='+z'``. To apply it to the pen color, append **+z** to
+            **pen**.
+        label : str
+            Add a legend entry for the symbol or line being plotted.
+        {p}
+        {t}
+            *transparency* can also be a 1d array to set varying transparency
+            for symbols.
+
+        """
+        kwargs = self._preprocess(**kwargs)
+
+        kind = data_kind(data, x, y, z)
+
+        extra_arrays = []
+        if "S" in kwargs and kwargs["S"][0] in "vV" and direction is not None:
+            extra_arrays.extend(direction)
+        if "G" in kwargs and not isinstance(kwargs["G"], str):
+            if kind != "vectors":
+                raise GMTInvalidInput(
+                    "Can't use arrays for color if data is matrix or file."
+                )
+            extra_arrays.append(kwargs["G"])
+            del kwargs["G"]
+        if sizes is not None:
+            if kind != "vectors":
+                raise GMTInvalidInput(
+                    "Can't use arrays for sizes if data is matrix or file."
+                )
+            extra_arrays.append(sizes)
+
+        if "t" in kwargs and is_nonstr_iter(kwargs["t"]):
+            extra_arrays.append(kwargs["t"])
+            kwargs["t"] = ""
+
+        with Session() as lib:
+            # Choose how data will be passed in to the module
+            if kind == "file":
+                file_context = dummy_context(data)
+            elif kind == "matrix":
+                file_context = lib.virtualfile_from_matrix(data)
+            elif kind == "vectors":
+                file_context = lib.virtualfile_from_vectors(
+                    np.atleast_1d(x), np.atleast_1d(y), np.atleast_1d(z), *extra_arrays
+                )
+
+            with file_context as fname:
+                arg_str = " ".join([fname, build_arg_string(kwargs)])
+                lib.call_module("plot3d", arg_str)
+
     @fmt_docstring
     @use_alias(
         R="region",
@@ -921,6 +1104,8 @@ class BasePlotting:
     @use_alias(
         R="region",
         J="projection",
+        Jz="zscale",
+        JZ="zsize",
         B="frame",
         L="map_scale",
         Td="rose",
@@ -935,12 +1120,12 @@ class BasePlotting:
     @kwargs_to_strings(R="sequence", p="sequence")
     def basemap(self, **kwargs):
         """
-        Produce a basemap for the figure.
+        Plot base maps and frames for the figure.
 
-        Several map projections are available, and the user may specify
-        separate tick-mark intervals for boundary annotation, ticking, and
-        [optionally] gridlines. A simple map scale or directional rose may also
-        be plotted.
+        Creates a basic or fancy basemap with axes, fill, and titles. Several
+        map projections are available, and the user may specify separate
+        tick-mark intervals for boundary annotation, ticking, and [optionally]
+        gridlines. A simple map scale or directional rose may also be plotted.
 
         At least one of the options *frame*, *map_scale*, *rose* or *compass*
         must be specified.
@@ -952,6 +1137,8 @@ class BasePlotting:
         Parameters
         ----------
         {J}
+        zscale/zsize : float or str
+            Set z-axis scaling or z-axis size.
         {R}
         {B}
         map_scale : str


Test patch:
diff --git a/pygmt/tests/test_plot.py b/pygmt/tests/test_plot.py
index 196cf460..cef4dae5 100644
--- a/pygmt/tests/test_plot.py
+++ b/pygmt/tests/test_plot.py
@@ -269,7 +269,7 @@ def test_plot_varying_transparency():
 
 @check_figures_equal()
 def test_plot_sizes_colors_transparencies():
-    "Plot the data using z as transparency"
+    "Plot the data with varying sizes and colors using z as transparency"
     x = np.arange(1.0, 10.0)
     y = np.arange(1.0, 10.0)
     color = np.arange(1, 10) * 0.15
diff --git a/pygmt/tests/test_plot3d.py b/pygmt/tests/test_plot3d.py
new file mode 100644
index 00000000..ba0590b1
--- /dev/null
+++ b/pygmt/tests/test_plot3d.py
@@ -0,0 +1,588 @@
+"""
+Tests plot3d.
+"""
+import os
+
+import numpy as np
+import pytest
+
+from .. import Figure
+from ..exceptions import GMTInvalidInput
+from ..helpers import GMTTempFile
+from ..helpers.testing import check_figures_equal
+
+
+TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
+POINTS_DATA = os.path.join(TEST_DATA_DIR, "points.txt")
+
+
+@pytest.fixture(scope="module", name="data")
+def fixture_data():
+    "Load the point data from the test file"
+    return np.loadtxt(POINTS_DATA)
+
+
+@pytest.fixture(scope="module", name="region")
+def fixture_region():
+    "The data region"
+    return [10, 70, -5, 10, 0, 1]
+
+
+@check_figures_equal()
+def test_plot3d_red_circles_zscale(data, region):
+    "Plot the 3D data in red circles passing in vectors and setting zscale = 5"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="X4i",
+        S="c0.2c",
+        G="red",
+        B=["afg", "zafg"],
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="X4i",
+        style="c0.2c",
+        color="red",
+        frame=["afg", "zafg"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_red_circles_zsize(data, region):
+    "Plot the 3D data in red circles passing in vectors and setting zsize = 3i"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        JZ="3i",
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="X4i",
+        S="c0.2c",
+        G="red",
+        B=["afg", "zafg"],
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zsize="3i",
+        perspective=[225, 30],
+        region=region,
+        projection="X4i",
+        style="c0.2c",
+        color="red",
+        frame=["afg", "zafg"],
+    )
+    return fig_ref, fig_test
+
+
+def test_plot3d_fail_no_data(data, region):
+    "Plot should raise an exception if no data is given"
+    fig = Figure()
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            region=region, projection="X4i", style="c0.2c", color="red", frame="afg"
+        )
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            x=data[:, 0],
+            region=region,
+            projection="X4i",
+            style="c0.2c",
+            color="red",
+            frame="afg",
+        )
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            y=data[:, 0],
+            region=region,
+            projection="X4i",
+            style="c0.2c",
+            color="red",
+            frame="afg",
+        )
+    # Should also fail if given too much data
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            x=data[:, 0],
+            y=data[:, 1],
+            z=data[:, 2],
+            data=data,
+            region=region,
+            projection="X4i",
+            style="c0.2c",
+            color="red",
+            frame="afg",
+        )
+
+
+def test_plot3d_fail_size_color(data, region):
+    "Should raise an exception if array sizes and color are used with matrix"
+    fig = Figure()
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            data=data,
+            region=region,
+            projection="X4i",
+            style="c0.2c",
+            color=data[:, 2],
+            frame="afg",
+        )
+    with pytest.raises(GMTInvalidInput):
+        fig.plot3d(
+            data=data,
+            region=region,
+            projection="X4i",
+            style="cc",
+            sizes=data[:, 2],
+            color="red",
+            frame="afg",
+        )
+
+
+@check_figures_equal()
+def test_plot3d_projection(data, region):
+    "Plot the data in green squares with a projection"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="R270/4i",
+        S="s1c",
+        G="green",
+        B=["ag", "zag"],
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="R270/4i",
+        style="s1c",
+        color="green",
+        frame=["ag", "zag"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_colors(data, region):
+    "Plot the data using z as colors"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        G="+z",
+        R="/".join(map(str, region)),
+        J="X3i",
+        S="c0.5c",
+        C="cubhelix",
+        B=["afg", "zafg"],
+        i="0,1,2,2",
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        color=data[:, 2],
+        region=region,
+        projection="X3i",
+        style="c0.5c",
+        cmap="cubhelix",
+        frame=["afg", "zafg"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_sizes(data, region):
+    "Plot the data using z as sizes"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        i="0,1,2,2+s0.5",
+        R="/".join(map(str, region)),
+        J="X4i",
+        S="ui",
+        G="blue",
+        B=["af", "zaf"],
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        sizes=0.5 * data[:, 2],
+        region=region,
+        projection="X4i",
+        # Using inches instead of cm because of upstream bug at
+        # https://github.com/GenericMappingTools/gmt/issues/4386
+        style="ui",
+        color="blue",
+        frame=["af", "zaf"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_colors_sizes(data, region):
+    "Plot the data using z as sizes and colors"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        i="0,1,2,2,2+s0.5",
+        R="/".join(map(str, region)),
+        J="X3i",
+        S="ui",
+        C="copper",
+        B=["af", "zaf"],
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        color=data[:, 2],
+        sizes=0.5 * data[:, 2],
+        region=region,
+        projection="X3i",
+        # Using inches instead of cm because of upstream bug at
+        # https://github.com/GenericMappingTools/gmt/issues/4386
+        style="ui",
+        cmap="copper",
+        frame=["af", "zaf"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_colors_sizes_proj(data, region):
+    "Plot the data using z as sizes and colors with a projection"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="M10i",
+        B=["af", "zaf"],
+        G="+z",
+        i="0,1,2,2,2+s1",
+        S="ui",
+        C="copper",
+    )
+    fig_test.plot3d(
+        x=data[:, 0],
+        y=data[:, 1],
+        z=data[:, 2],
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="M10i",
+        frame=["af", "zaf"],
+        color=data[:, 2],
+        sizes=data[:, 2],
+        # Using inches instead of cm because of upstream bug at
+        # https://github.com/GenericMappingTools/gmt/issues/4386
+        style="ui",
+        cmap="copper",
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_transparency():
+    "Plot the data with a constant transparency"
+    x = np.arange(1, 10)
+    y = np.arange(1, 10)
+    z = np.arange(1, 10) * 10
+
+    fig_ref, fig_test = Figure(), Figure()
+    # Use single-character arguments for the reference image
+    with GMTTempFile() as tmpfile:
+        np.savetxt(tmpfile.name, np.c_[x, y, z], fmt="%d")
+        fig_ref.plot3d(
+            data=tmpfile.name,
+            S="u0.2c",
+            G="blue",
+            R="0/10/0/10/10/90",
+            J="X4i",
+            Jz=0.1,
+            B="",
+            p="135/30",
+            t=80.0,
+        )
+
+    fig_test.plot3d(
+        x=x,
+        y=y,
+        z=z,
+        style="u0.2c",
+        color="blue",
+        region=[0, 10, 0, 10, 10, 90],
+        projection="X4i",
+        zscale=0.1,
+        frame=True,
+        perspective=[135, 30],
+        transparency=80.0,
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_varying_transparency():
+    "Plot the data using z as transparency using 3-D column symbols"
+    x = np.arange(1, 10)
+    y = np.arange(1, 10)
+    z = np.arange(1, 10) * 10
+
+    fig_ref, fig_test = Figure(), Figure()
+    # Use single-character arguments for the reference image
+    with GMTTempFile() as tmpfile:
+        np.savetxt(tmpfile.name, np.c_[x, y, z, z, z], fmt="%d")
+        fig_ref.plot3d(
+            data=tmpfile.name,
+            S="o0.2c+B5",
+            G="blue",
+            R="0/10/0/10/10/90",
+            J="X4i",
+            Jz=0.1,
+            B="",
+            p="135/30",
+            t="",
+        )
+    fig_test.plot3d(
+        x=x,
+        y=y,
+        z=z,
+        style="o0.2c+B5",
+        color="blue",
+        region=[0, 10, 0, 10, 10, 90],
+        projection="X4i",
+        zscale=0.1,
+        frame=True,
+        perspective=[135, 30],
+        transparency=z,
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_sizes_colors_transparencies():
+    "Plot the data with varying sizes and colors using z as transparency"
+    x = np.arange(1.0, 10.0)
+    y = np.arange(1.0, 10.0)
+    z = np.arange(1, 10) * 10
+    color = np.arange(1, 10) * 0.15
+    size = np.arange(1, 10) * 0.2
+    transparency = np.arange(1, 10) * 10
+
+    fig_ref, fig_test = Figure(), Figure()
+    # Use single-character arguments for the reference image
+    with GMTTempFile() as tmpfile:
+        np.savetxt(tmpfile.name, np.c_[x, y, z, color, size, transparency])
+        fig_ref.plot3d(
+            data=tmpfile.name,
+            R="0/10/0/10/10/90",
+            J="X4i",
+            Jz=0.1,
+            p="135/30",
+            B="",
+            S="uc",
+            C="gray",
+            t="",
+        )
+    fig_test.plot3d(
+        x=x,
+        y=y,
+        z=z,
+        region=[0, 10, 0, 10, 10, 90],
+        projection="X4i",
+        zscale=0.1,
+        perspective=[135, 30],
+        frame=True,
+        style="uc",
+        color=color,
+        sizes=size,
+        cmap="gray",
+        transparency=transparency,
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_matrix(data, region):
+    "Plot the data passing in a matrix and specifying columns"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="M10i",
+        S="c1c",
+        G="#aaaaaa",
+        B=["a", "za"],
+        i="0,1,2",
+    )
+    fig_test.plot3d(
+        data=data,
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="M10i",
+        style="c1c",
+        color="#aaaaaa",
+        frame=["a", "za"],
+        columns="0,1,2",
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_matrix_color(data, region):
+    "Plot the data passing in a matrix and using a colormap"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="X5i",
+        S="c0.5cc",
+        C="rainbow",
+        i="0,1,2,2",
+        B=["a", "za"],
+    )
+    fig_test.plot3d(
+        data=data,
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="X5i",
+        style="c0.5c",
+        cmap="rainbow",
+        columns=[0, 1, 2, 2],
+        frame=["a", "za"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_from_file(region):
+    "Plot using the data file name instead of loaded data"
+    fig_ref, fig_test = Figure(), Figure()
+    fig_ref.plot3d(
+        data=POINTS_DATA,
+        Jz=5,
+        p="225/30",
+        R="/".join(map(str, region)),
+        J="X10i",
+        S="d1c",
+        G="yellow",
+        B=["af", "zaf"],
+        i="0,1,2",
+    )
+    fig_test.plot3d(
+        data=POINTS_DATA,
+        zscale=5,
+        perspective=[225, 30],
+        region=region,
+        projection="X10i",
+        style="d1c",
+        color="yellow",
+        frame=["af", "zaf"],
+        columns=[0, 1, 2],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_vectors():
+    "Plot vectors"
+    azimuth = np.array([0, 45, 90, 135, 180, 225, 270, 310])
+    lengths = np.linspace(0.1, 1, len(azimuth))
+    lon = np.sin(np.deg2rad(azimuth))
+    lat = np.cos(np.deg2rad(azimuth))
+    elev = np.tan(np.deg2rad(azimuth))
+    fig_ref, fig_test = Figure(), Figure()
+    with GMTTempFile() as tmpfile:
+        np.savetxt(tmpfile.name, np.c_[lon, lat, elev, azimuth, lengths])
+        fig_ref.plot3d(
+            data=tmpfile.name,
+            Jz=2,
+            p="225/30",
+            R="-2/2/-2/2/-2/2",
+            J="X4i",
+            S="V1c+e",
+            G="black",
+            B=["af", "zaf"],
+        )
+    fig_test.plot3d(
+        x=lon,
+        y=lat,
+        z=elev,
+        zscale=2,
+        perspective=[225, 30],
+        direction=(azimuth, lengths),
+        region=[-2, 2, -2, 2, -2, 2],
+        projection="X4i",
+        style="V1c+e",
+        color="black",
+        frame=["af", "zaf"],
+    )
+    return fig_ref, fig_test
+
+
+@check_figures_equal()
+def test_plot3d_scalar_xyz():
+    "Plot symbols given scalar x, y, z coordinates"
+    fig_ref, fig_test = Figure(), Figure()
+    with GMTTempFile() as tmpfile:
+        np.savetxt(tmpfile.name, np.c_[[-1.5, 0, 1.5], [1.5, 0, -1.5], [-1.5, 0, 1.5]])
+        fig_ref.basemap(
+            R="-2/2/-2/2/-2/2", B=["xaf+lx", "yaf+ly", "zaf+lz"], Jz=2, p="225/30"
+        )
+        fig_ref.plot3d(data=tmpfile.name, S="c1c", G="red", Jz="", p="", qi=0)
+        fig_ref.plot3d(data=tmpfile.name, S="t1c", G="green", Jz="", p="", qi=1)
+        fig_ref.plot3d(data=tmpfile.name, S="s1c", G="blue", Jz="", p="", qi=2)
+
+    fig_test.basemap(
+        region=[-2, 2, -2, 2, -2, 2],
+        frame=["xaf+lx", "yaf+ly", "zaf+lz"],
+        zscale=2,
+        perspective=[225, 30],
+    )
+    fig_test.plot3d(
+        x=-1.5, y=1.5, z=-1.5, style="c1c", color="red", zscale=True, perspective=True
+    )
+    fig_test.plot3d(
+        x=0, y=0, z=0, style="t1c", color="green", zscale=True, perspective=True
+    )
+    fig_test.plot3d(
+        x=1.5, y=-1.5, z=1.5, style="s1c", color="blue", zscale=True, perspective=True
+    )
+    return fig_ref, fig_test