Skip to content

[ENH]: fill_between extended to 3D #28225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api/toolkits/mplot3d/axes3d.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Plotting
plot_surface
plot_wireframe
plot_trisurf
fill_between

clabel
contour
Expand Down
25 changes: 25 additions & 0 deletions doc/users/next_whats_new/fill_between_3d.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Fill between 3D lines
---------------------

The new method `.Axes3D.fill_between` allows to fill the surface between two
3D lines with polygons.

.. plot::
:include-source:
:alt: Example of 3D fill_between

N = 50
theta = np.linspace(0, 2*np.pi, N)

x1 = np.cos(theta)
y1 = np.sin(theta)
z1 = 0.1 * np.sin(6 * theta)

x2 = 0.6 * np.cos(theta)
y2 = 0.6 * np.sin(theta)
z2 = 2 # Note that scalar values work in addition to length N arrays

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.fill_between(x1, y1, z1, x2, y2, z2,
alpha=0.5, edgecolor='k')
28 changes: 28 additions & 0 deletions galleries/examples/mplot3d/fillbetween3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
=====================
Fill between 3D lines
=====================

Demonstrate how to fill the space between 3D lines with surfaces. Here we
create a sort of "lampshade" shape.
"""

import matplotlib.pyplot as plt
import numpy as np

N = 50
theta = np.linspace(0, 2*np.pi, N)

x1 = np.cos(theta)
y1 = np.sin(theta)
z1 = 0.1 * np.sin(6 * theta)

x2 = 0.6 * np.cos(theta)
y2 = 0.6 * np.sin(theta)
z2 = 2 # Note that scalar values work in addition to length N arrays

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5, edgecolor='k')

plt.show()
34 changes: 34 additions & 0 deletions galleries/examples/mplot3d/fillunder3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
=========================
Fill under 3D line graphs
=========================

Demonstrate how to create polygons which fill the space under a line
graph. In this example polygons are semi-transparent, creating a sort
of 'jagged stained glass' effect.
"""

import math

import matplotlib.pyplot as plt
import numpy as np

gamma = np.vectorize(math.gamma)
N = 31
x = np.linspace(0., 10., N)
lambdas = range(1, 9)

ax = plt.figure().add_subplot(projection='3d')

facecolors = plt.colormaps['viridis_r'](np.linspace(0, 1, len(lambdas)))

for i, l in enumerate(lambdas):
# Note fill_between can take coordinates as length N vectors, or scalars
ax.fill_between(x, l, l**x * np.exp(-l) / gamma(x + 1),
x, l, 0,
facecolors=facecolors[i], alpha=.7)

ax.set(xlim=(0, 10), ylim=(1, 9), zlim=(0, 0.35),
xlabel='x', ylabel=r'$\lambda$', zlabel='probability')

plt.show()
53 changes: 21 additions & 32 deletions galleries/examples/mplot3d/polys3d.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,36 @@
"""
=============================================
Generate polygons to fill under 3D line graph
=============================================
====================
Generate 3D polygons
====================

Demonstrate how to create polygons which fill the space under a line
graph. In this example polygons are semi-transparent, creating a sort
of 'jagged stained glass' effect.
Demonstrate how to create polygons in 3D. Here we stack 3 hexagons.
"""

import math

import matplotlib.pyplot as plt
import numpy as np

from matplotlib.collections import PolyCollection

# Fixing random state for reproducibility
np.random.seed(19680801)
from mpl_toolkits.mplot3d.art3d import Poly3DCollection

# Coordinates of a hexagon
angles = np.linspace(0, 2 * np.pi, 6, endpoint=False)
x = np.cos(angles)
y = np.sin(angles)
zs = [-3, -2, -1]

def polygon_under_graph(x, y):
"""
Construct the vertex list which defines the polygon filling the space under
the (x, y) line graph. This assumes x is in ascending order.
"""
return [(x[0], 0.), *zip(x, y), (x[-1], 0.)]
# Close the hexagon by repeating the first vertex
x = np.append(x, x[0])
y = np.append(y, y[0])

verts = []
for z in zs:
verts.append(list(zip(x*z, y*z, np.full_like(x, z))))
verts = np.array(verts)

ax = plt.figure().add_subplot(projection='3d')

x = np.linspace(0., 10., 31)
lambdas = range(1, 9)

# verts[i] is a list of (x, y) pairs defining polygon i.
gamma = np.vectorize(math.gamma)
verts = [polygon_under_graph(x, l**x * np.exp(-l) / gamma(x + 1))
for l in lambdas]
facecolors = plt.colormaps['viridis_r'](np.linspace(0, 1, len(verts)))

poly = PolyCollection(verts, facecolors=facecolors, alpha=.7)
ax.add_collection3d(poly, zs=lambdas, zdir='y')

ax.set(xlim=(0, 10), ylim=(1, 9), zlim=(0, 0.35),
xlabel='x', ylabel=r'$\lambda$', zlabel='probability')
poly = Poly3DCollection(verts, alpha=.7)
ax.add_collection3d(poly)
ax.auto_scale_xyz(verts[:, :, 0], verts[:, :, 1], verts[:, :, 2])
ax.set_aspect('equalxy')

plt.show()
33 changes: 33 additions & 0 deletions galleries/plot_types/3D/fill_between3d_simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
====================================
fill_between(x1, y1, z1, x2, y2, z2)
====================================

See `~mpl_toolkits.mplot3d.axes3d.Axes3D.fill_between`.
"""
import matplotlib.pyplot as plt
import numpy as np

plt.style.use('_mpl-gallery')

# Make data for a double helix
n = 50
theta = np.linspace(0, 2*np.pi, n)
x1 = np.cos(theta)
y1 = np.sin(theta)
z1 = np.linspace(0, 1, n)
x2 = np.cos(theta + np.pi)
y2 = np.sin(theta + np.pi)
z2 = z1

# Plot
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5)
ax.plot(x1, y1, z1, linewidth=2, color='C0')
ax.plot(x2, y2, z2, linewidth=2, color='C0')

ax.set(xticklabels=[],
yticklabels=[],
zticklabels=[])

plt.show()
10 changes: 10 additions & 0 deletions galleries/users_explain/toolkits/mplot3d.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ See `.Axes3D.contourf` for API documentation.
The feature demoed in the second contourf3d example was enabled as a
result of a bugfix for version 1.1.0.

.. _fillbetween3d:

Fill between 3D lines
=====================
See `.Axes3D.fill_between` for API documentation.

.. figure:: /gallery/mplot3d/images/sphx_glr_fillbetween3d_001.png
:target: /gallery/mplot3d/fillbetween3d.html
:align: center

.. _polygon3d:

Polygon plots
Expand Down
41 changes: 41 additions & 0 deletions lib/mpl_toolkits/mplot3d/art3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,47 @@
return np.column_stack([rgba[:, :3], rgba[:, 3] * sats])


def _all_points_on_plane(xs, ys, zs, atol=1e-8):
"""
Check if all points are on the same plane. Note that NaN values are
ignored.

Parameters
----------
xs, ys, zs : array-like
The x, y, and z coordinates of the points.
atol : float, default: 1e-8
The tolerance for the equality check.
"""
xs, ys, zs = np.asarray(xs), np.asarray(ys), np.asarray(zs)
points = np.column_stack([xs, ys, zs])
points = points[~np.isnan(points).any(axis=1)]
# Check for the case where we have less than 3 unique points
points = np.unique(points, axis=0)
if len(points) <= 3:
return True
# Calculate the vectors from the first point to all other points
vs = (points - points[0])[1:]
vs = vs / np.linalg.norm(vs, axis=1)[:, np.newaxis]
# Filter out parallel vectors
vs = np.unique(vs, axis=0)
if len(vs) <= 2:
return True
# Filter out parallel and antiparallel vectors to the first vector
cross_norms = np.linalg.norm(np.cross(vs[0], vs[1:]), axis=1)
zero_cross_norms = np.where(np.isclose(cross_norms, 0, atol=atol))[0] + 1
vs = np.delete(vs, zero_cross_norms, axis=0)
if len(vs) <= 2:
return True

Check warning on line 1219 in lib/mpl_toolkits/mplot3d/art3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/art3d.py#L1219

Added line #L1219 was not covered by tests
# Calculate the normal vector from the first three points
n = np.cross(vs[0], vs[1])
n = n / np.linalg.norm(n)
# If the dot product of the normal vector and all other vectors is zero,
# all points are on the same plane
dots = np.dot(n, vs.transpose())
return np.allclose(dots, 0, atol=atol)


def _generate_normals(polygons):
"""
Compute the normals of a list of polygons, one normal per polygon.
Expand Down
124 changes: 124 additions & 0 deletions lib/mpl_toolkits/mplot3d/axes3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -1957,6 +1957,130 @@

plot3D = plot

def fill_between(self, x1, y1, z1, x2, y2, z2, *,
where=None, mode='auto', facecolors=None, shade=None,
**kwargs):
"""
Fill the area between two 3D curves.

The curves are defined by the points (*x1*, *y1*, *z1*) and
(*x2*, *y2*, *z2*). This creates one or multiple quadrangle
polygons that are filled. All points must be the same length N, or a
single value to be used for all points.

Parameters
----------
x1, y1, z1 : float or 1D array-like
x, y, and z coordinates of vertices for 1st line.

x2, y2, z2 : float or 1D array-like
x, y, and z coordinates of vertices for 2nd line.

where : array of bool (length N), optional
Define *where* to exclude some regions from being filled. The
filled regions are defined by the coordinates ``pts[where]``,
for all x, y, and z pts. More precisely, fill between ``pts[i]``
and ``pts[i+1]`` if ``where[i] and where[i+1]``. Note that this
definition implies that an isolated *True* value between two
*False* values in *where* will not result in filling. Both sides of
the *True* position remain unfilled due to the adjacent *False*
values.

mode : {'quad', 'polygon', 'auto'}, default: 'auto'
The fill mode. One of:

- 'quad': A separate quadrilateral polygon is created for each
pair of subsequent points in the two lines.
- 'polygon': The two lines are connected to form a single polygon.
This is faster and can render more cleanly for simple shapes
(e.g. for filling between two lines that lie within a plane).
- 'auto': If the points all lie on the same 3D plane, 'polygon' is
used. Otherwise, 'quad' is used.

facecolors : list of :mpltype:`color`, default: None
Colors of each individual patch, or a single color to be used for
all patches.

shade : bool, default: None
Whether to shade the facecolors. If *None*, then defaults to *True*
for 'quad' mode and *False* for 'polygon' mode.

**kwargs
All other keyword arguments are passed on to `.Poly3DCollection`.

Returns
-------
`.Poly3DCollection`
A `.Poly3DCollection` containing the plotted polygons.

"""
_api.check_in_list(['auto', 'quad', 'polygon'], mode=mode)

had_data = self.has_data()
x1, y1, z1, x2, y2, z2 = cbook._broadcast_with_masks(x1, y1, z1, x2, y2, z2)

if facecolors is None:
facecolors = [self._get_patches_for_fill.get_next_color()]
facecolors = list(mcolors.to_rgba_array(facecolors))

if where is None:
where = True

Check warning on line 2027 in lib/mpl_toolkits/mplot3d/axes3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/axes3d.py#L2027

Added line #L2027 was not covered by tests
else:
where = np.asarray(where, dtype=bool)
if where.size != x1.size:
raise ValueError(f"where size ({where.size}) does not match "

Check warning on line 2031 in lib/mpl_toolkits/mplot3d/axes3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/axes3d.py#L2031

Added line #L2031 was not covered by tests
f"size ({x1.size})")
where = where & ~np.isnan(x1) # NaNs were broadcast in _broadcast_with_masks

if mode == 'auto':
if art3d._all_points_on_plane(np.concatenate((x1[where], x2[where])),
np.concatenate((y1[where], y2[where])),
np.concatenate((z1[where], z2[where])),
atol=1e-12):
mode = 'polygon'
else:
mode = 'quad'

if shade is None:
if mode == 'quad':
shade = True
else:
shade = False

polys = []
for idx0, idx1 in cbook.contiguous_regions(where):
x1i = x1[idx0:idx1]
y1i = y1[idx0:idx1]
z1i = z1[idx0:idx1]
x2i = x2[idx0:idx1]
y2i = y2[idx0:idx1]
z2i = z2[idx0:idx1]

if not len(x1i):
continue

Check warning on line 2060 in lib/mpl_toolkits/mplot3d/axes3d.py

View check run for this annotation

Codecov / codecov/patch

lib/mpl_toolkits/mplot3d/axes3d.py#L2060

Added line #L2060 was not covered by tests

if mode == 'quad':
# Preallocate the array for the region's vertices, and fill it in
n_polys_i = len(x1i) - 1
polys_i = np.empty((n_polys_i, 4, 3))
polys_i[:, 0, :] = np.column_stack((x1i[:-1], y1i[:-1], z1i[:-1]))
polys_i[:, 1, :] = np.column_stack((x1i[1:], y1i[1:], z1i[1:]))
polys_i[:, 2, :] = np.column_stack((x2i[1:], y2i[1:], z2i[1:]))
polys_i[:, 3, :] = np.column_stack((x2i[:-1], y2i[:-1], z2i[:-1]))
polys = polys + [*polys_i]
elif mode == 'polygon':
line1 = np.column_stack((x1i, y1i, z1i))
line2 = np.column_stack((x2i[::-1], y2i[::-1], z2i[::-1]))
poly = np.concatenate((line1, line2), axis=0)
polys.append(poly)

polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade,
**kwargs)
self.add_collection(polyc)

self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data)
return polyc

def plot_surface(self, X, Y, Z, *, norm=None, vmin=None,
vmax=None, lightsource=None, **kwargs):
"""
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading