16

Short question

How can matplotlib 2D patches be transformed to 3D with arbitrary normals?

Long question

I would like to plot Patches in axes with 3d projection. However, the methods provided by mpl_toolkits.mplot3d.art3d only provide methods to have patches with normals along the principal axes. How can I add patches to 3d axes that have arbitrary normals?

Trenton McKinney
  • 56,955
  • 33
  • 144
  • 158
Till Hoffmann
  • 9,479
  • 6
  • 46
  • 64

5 Answers5

17

Short answer

Copy the code below into your project and use the method

def pathpatch_2d_to_3d(pathpatch, z = 0, normal = 'z'):
    """
    Transforms a 2D Patch to a 3D patch using the given normal vector.

    The patch is projected into they XY plane, rotated about the origin
    and finally translated by z.
    """

to transform your 2D patches to 3D patches with arbitrary normals.

from mpl_toolkits.mplot3d import art3d

def rotation_matrix(d):
    """
    Calculates a rotation matrix given a vector d. The direction of d
    corresponds to the rotation axis. The length of d corresponds to 
    the sin of the angle of rotation.

    Variant of: http://mail.scipy.org/pipermail/numpy-discussion/2009-March/040806.html
    """
    sin_angle = np.linalg.norm(d)

    if sin_angle == 0:
        return np.identity(3)

    d /= sin_angle

    eye = np.eye(3)
    ddt = np.outer(d, d)
    skew = np.array([[    0,  d[2],  -d[1]],
                  [-d[2],     0,  d[0]],
                  [d[1], -d[0],    0]], dtype=np.float64)

    M = ddt + np.sqrt(1 - sin_angle**2) * (eye - ddt) + sin_angle * skew
    return M

def pathpatch_2d_to_3d(pathpatch, z = 0, normal = 'z'):
    """
    Transforms a 2D Patch to a 3D patch using the given normal vector.

    The patch is projected into they XY plane, rotated about the origin
    and finally translated by z.
    """
    if type(normal) is str: #Translate strings to normal vectors
        index = "xyz".index(normal)
        normal = np.roll((1.0,0,0), index)

    normal /= np.linalg.norm(normal) #Make sure the vector is normalised

    path = pathpatch.get_path() #Get the path and the associated transform
    trans = pathpatch.get_patch_transform()

    path = trans.transform_path(path) #Apply the transform

    pathpatch.__class__ = art3d.PathPatch3D #Change the class
    pathpatch._code3d = path.codes #Copy the codes
    pathpatch._facecolor3d = pathpatch.get_facecolor #Get the face color    

    verts = path.vertices #Get the vertices in 2D

    d = np.cross(normal, (0, 0, 1)) #Obtain the rotation vector    
    M = rotation_matrix(d) #Get the rotation matrix

    pathpatch._segment3d = np.array([np.dot(M, (x, y, 0)) + (0, 0, z) for x, y in verts])

def pathpatch_translate(pathpatch, delta):
    """
    Translates the 3D pathpatch by the amount delta.
    """
    pathpatch._segment3d += delta

Long answer

Looking at the source code of art3d.pathpatch_2d_to_3d gives the following call hierarchy

  1. art3d.pathpatch_2d_to_3d
  2. art3d.PathPatch3D.set_3d_properties
  3. art3d.Patch3D.set_3d_properties
  4. art3d.juggle_axes

The transformation from 2D to 3D happens in the last call to art3d.juggle_axes. Modifying this last step, we can obtain patches in 3D with arbitrary normals.

We proceed in four steps

  1. Project the vertices of the patch into the XY plane (pathpatch_2d_to_3d)
  2. Calculate a rotation matrix R that rotates the z direction to the direction of the normal (rotation_matrix)
  3. Apply the rotation matrix to all vertices (pathpatch_2d_to_3d)
  4. Translate the resulting object in the z-direction (pathpatch_2d_to_3d)

Sample source code and the resulting plot are shown below.

from mpl_toolkits.mplot3d import proj3d
from matplotlib.patches import Circle
from itertools import product

ax = axes(projection = '3d') #Create axes

p = Circle((0,0), .2) #Add a circle in the yz plane
ax.add_patch(p)
pathpatch_2d_to_3d(p, z = 0.5, normal = 'x')
pathpatch_translate(p, (0, 0.5, 0))

p = Circle((0,0), .2, facecolor = 'r') #Add a circle in the xz plane
ax.add_patch(p)
pathpatch_2d_to_3d(p, z = 0.5, normal = 'y')
pathpatch_translate(p, (0.5, 1, 0))

p = Circle((0,0), .2, facecolor = 'g') #Add a circle in the xy plane
ax.add_patch(p)
pathpatch_2d_to_3d(p, z = 0, normal = 'z')
pathpatch_translate(p, (0.5, 0.5, 0))

for normal in product((-1, 1), repeat = 3):
    p = Circle((0,0), .2, facecolor = 'y', alpha = .2)
    ax.add_patch(p)
    pathpatch_2d_to_3d(p, z = 0, normal = normal)
    pathpatch_translate(p, 0.5)

Resulting plot

Ben Jackson
  • 90,079
  • 9
  • 98
  • 150
Till Hoffmann
  • 9,479
  • 6
  • 46
  • 64
  • 1
    How were you able to answer your own question this fast? – sodd Aug 14 '13 at 11:27
  • 1
    I just wanted to share my solution (http://blog.stackoverflow.com/2011/07/its-ok-to-ask-and-answer-your-own-questions/). There is a little button at the bottom each time you ask a question; you can tick it if you want to answer your question immediately. – Till Hoffmann Aug 15 '13 at 08:22
  • @TillHoffmann Could you turn the green cricle to a cylinder with no fill using this method? I want to do something similar where i draw 3d patch shapes on a surface plot. – TheCodeNovice Nov 20 '14 at 00:53
  • @TheCodeNovice: Yes, that is possible in principle but tricky in practice. You would have to draw two circles separated by the height of your cylinder and then connect the two end caps with lines. The position of the lines depends on your point of view however and would have to be updated continuously in an interactive setting. – Till Hoffmann Nov 20 '14 at 10:19
5

Very useful piece of code, but there is a small caveat: it cannot handle normals pointing downwards because it uses only the sine of the angle.

You need to use also the cosine:

from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d import art3d
from mpl_toolkits.mplot3d import proj3d
import numpy as np

def rotation_matrix(v1,v2):
    """
    Calculates the rotation matrix that changes v1 into v2.
    """
    v1/=np.linalg.norm(v1)
    v2/=np.linalg.norm(v2)

    cos_angle=np.dot(v1,v2)
    d=np.cross(v1,v2)
    sin_angle=np.linalg.norm(d)

    if sin_angle == 0:
        M = np.identity(3) if cos_angle>0. else -np.identity(3)
    else:
        d/=sin_angle

        eye = np.eye(3)
        ddt = np.outer(d, d)
        skew = np.array([[    0,  d[2],  -d[1]],
                      [-d[2],     0,  d[0]],
                      [d[1], -d[0],    0]], dtype=np.float64)

        M = ddt + cos_angle * (eye - ddt) + sin_angle * skew

    return M

def pathpatch_2d_to_3d(pathpatch, z = 0, normal = 'z'):
    """
    Transforms a 2D Patch to a 3D patch using the given normal vector.

    The patch is projected into they XY plane, rotated about the origin
    and finally translated by z.
    """
    if type(normal) is str: #Translate strings to normal vectors
        index = "xyz".index(normal)
        normal = np.roll((1,0,0), index)

    path = pathpatch.get_path() #Get the path and the associated transform
    trans = pathpatch.get_patch_transform()

    path = trans.transform_path(path) #Apply the transform

    pathpatch.__class__ = art3d.PathPatch3D #Change the class
    pathpatch._code3d = path.codes #Copy the codes
    pathpatch._facecolor3d = pathpatch.get_facecolor #Get the face color    

    verts = path.vertices #Get the vertices in 2D

    M = rotation_matrix(normal,(0, 0, 1)) #Get the rotation matrix

    pathpatch._segment3d = np.array([np.dot(M, (x, y, 0)) + (0, 0, z) for x, y in verts])

def pathpatch_translate(pathpatch, delta):
    """
    Translates the 3D pathpatch by the amount delta.
    """
    pathpatch._segment3d += delta
T. Lepage
  • 123
  • 1
  • 6
1

Here's a more generalmethod that allows embedding in more complex ways than along a normal:

class EmbeddedPatch2D(art3d.PathPatch3D):
    def __init__(self, patch, transform):
        assert transform.shape == (4, 3)

        self._patch2d = patch
        self.transform = transform

        self._path2d = patch.get_path()
        self._facecolor2d = patch.get_facecolor()

        self.set_3d_properties()

    def set_3d_properties(self, *args, **kwargs):
        # get the fully-transformed path
        path = self._patch2d.get_path()
        trans = self._patch2d.get_patch_transform()
        path = trans.transform_path(path)

        # copy across the relevant properties
        self._code3d = path.codes
        self._facecolor3d = self._patch2d.get_facecolor()

        # calculate the transformed vertices
        verts = np.empty(path.vertices.shape + np.array([0, 1]))
        verts[:,:-1] = path.vertices
        verts[:,-1] = 1
        self._segment3d = verts.dot(self.transform.T)[:,:-1]

    def __getattr__(self, key):
        return getattr(self._patch2d, key)

To use this as desired in the question, we need a helper function

def matrix_from_normal(normal):
    """
    given a normal vector, builds a homogeneous rotation matrix such that M.dot([1, 0, 0]) == normal
    """ 
    normal = normal / np.linalg.norm(normal)
    res = np.eye(normal.ndim+1)
    res[:-1,0] = normal
    if normal [0] == 0:
        perp = [0, -normal[2], normal[1]]
    else:
        perp = np.cross(normal, [1, 0, 0])
        perp /= np.linalg.norm(perp)
    res[:-1,1] = perp
    res[:-1,2] = np.cross(self.dir, perp)
    return res

All together:

circ = Circle((0,0), .2, facecolor = 'y', alpha = .2)
# the matrix here turns (x, y, 1) into (0, x, y, 1)
mat = matrix_from_normal([1, 1, 0]).dot([
    [0, 0, 0],
    [1, 0, 0],
    [0, 1, 0],
    [0, 0, 1]
])
circ3d = EmbeddedPatch2D(circ, mat)
Eric
  • 95,302
  • 53
  • 242
  • 374
0

I want to share my solution that extends the former proposals. It enables both 3d elements and text to be added to a Axes3D presentation.

    # creation of a rotation matrix that preserves the x-axis in an xy-plane of the original coordinate system
    def rotationMatrix(normal):
        norm = np.linalg.norm(normal)
        if norm ==0: return Rotation.identity(None)
        zDir = normal/norm
        if np.abs(zDir[2])==1:
            yDir = np.array([0,zDir[2],0])
        else:
            yDir = (np.array([0,0,1]) - zDir[2]*zDir)/math.sqrt(1-zDir[2]**2)
        rotMat = np.empty((3,3))
        rotMat[:,0] = np.cross(zDir,yDir)
        rotMat[:,1] = yDir
        rotMat[:,2] = -zDir
        return Rotation.from_matrix(rotMat)

    def toVector(vec):
        if vec is None or not isinstance(vec,np.ndarray) : vec="z"
        if isinstance(vec,str):
            zdir = vec[0] if len(vec)>0 else "z"
            if not zdir in "xyz": zdir="z"
            index = "xyz".index(vec)
            return np.roll((1.0,0,0), index)
        else:
            return vec

    # Transforms a 2D Patch to a 3D patch using a pivot point and a the given normal vector.
    def pathpatch_2d_to_3d(pathpatch, pivot=np.zeros(3), zDir='z'):

        path = pathpatch.get_path() #Get the path and the associated transform
        trans = pathpatch.get_patch_transform()
        path = trans.transform_path(path) #Apply the transform

        pathpatch.__class__ =  mplot3d.art3d.PathPatch3D #Change the class
        pathpatch._path2d = path       #Copy the 2d path
        pathpatch._code3d = path.codes #Copy the codes
        pathpatch._facecolor3d = pathpatch.get_facecolor #Get the face color

        # Get the 2D vertices and add the third dimension
        verts3d = np.empty((path.vertices.shape[0],3))
        verts3d[:,0:2] = path.vertices
        verts3d[:,2] = pivot[2]
        R = rotationMatrix(toVector(zDir))
        pathpatch._segment3d = R.apply(verts3d - pivot) + pivot
        return pathpatch

    # places a 3D text element in axes with 3d projection. 
    def text3d(xyz, text, zDir="z", scalefactor=1.0, fp=FontProperties(), **kwargs):
        pt = PathPatch(TextPath(xyz[0:2], text, size=scalefactor*fp.get_size(), prop=fp , usetex=False),**kwargs)
        ax3D.add_patch(pathpatch_2d_to_3d(pt, xyz, zDir))

    # places a 3D circle in axes with 3d projection. 
    def circle3d(center, radius, zDir='z', **kwargs):
        pc = Circle(center[0:2], radius, **kwargs)
        ax3D.add_patch(pathpatch_2d_to_3d(pc, center, zDir))
Wolf
  • 1
  • 1
0

As of matplotlib 3.7, you can just monkey-patch Patch3D.set_3d_properties, and art3d.pathpatch_2d_to_3d would just work.

import mpl_toolkits.mplot3d.art3d
import numpy as np

def _transform_zdir(zdir):
    zdir = mpl_toolkits.mplot3d.art3d.get_dir_vector(zdir)
    zn = zdir / np.linalg.norm(zdir)

    cos_angle = zn[2]
    sin_angle = np.linalg.norm(zn[:2])
    if sin_angle == 0:
        return np.sign(cos_angle) * np.eye(3)

    d = np.array((zn[1], -zn[0], 0))
    d /= sin_angle
    ddt = np.outer(d, d)
    skew = np.array([[0, 0, -d[1]], [0, 0, d[0]], [d[1], -d[0], 0]], dtype=np.float64)
    return ddt + cos_angle * (np.eye(3) - ddt) + sin_angle * skew


def set_3d_properties(self, verts, zs=0, zdir="z"):
    zs = np.broadcast_to(zs, len(verts))
    self._segment3d = np.asarray(
        [
            np.dot(_transform_zdir(zdir), (x, y, 0)) + (0, 0, z)
            for ((x, y), z) in zip(verts, zs)
        ]
    )


def pathpatch_translate(pathpatch, delta):
    pathpatch._segment3d += np.asarray(delta)


mpl_toolkits.mplot3d.art3d.Patch3D.set_3d_properties = set_3d_properties
mpl_toolkits.mplot3d.art3d.Patch3D.translate = pathpatch_translate

Here, _transform_zdir is a simplified version of rotation_matrix in T. Lepage's answer. Then, the example in the accepted answer will be reduced to

import itertools

import matplotlib.pyplot as plt
from matplotlib.patches import Circle
from mpl_toolkits.mplot3d import art3d

ax = plt.axes(projection="3d")  # Create axes

p = ax.add_patch(Circle((0, 0), 0.2, facecolor="r"))
art3d.pathpatch_2d_to_3d(p, z=0.5, zdir="y")
p.translate((0.5, 1, 0))


for normal in itertools.product((-1, 1), repeat=3):
    p = ax.add_patch(Circle((0, 0), 0.2, facecolor="y", alpha=0.2))
    art3d.pathpatch_2d_to_3d(p, z=0, zdir=normal)
    p.translate(0.5)

example

Even more conveniently, you can do a one-liner with

def to_3d(pathpatch, z=0.0, zdir="z", delta=(0, 0, 0)):
    if not hasattr(pathpatch.axes, "get_zlim"):
        raise ValueError("Axes projection must be 3D")
    mpl_toolkits.mplot3d.art3d.pathpatch_2d_to_3d(pathpatch, z=z, zdir=zdir)
    pathpatch.translate(delta)
    return pathpatch

matplotlib.patches.Patch.to_3d = to_3d

then

ax.add_patch(Circle((0, 0), 0.2, facecolor="r")).to_3d(z=0.5, zdir='y', delta=(0.5, 1, 0))
khan
  • 41
  • 3