Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

matplotlib

3d

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?

like image 263
Till Hoffmann Avatar asked Aug 14 '13 10:08

Till Hoffmann


3 Answers

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

like image 101
Till Hoffmann Avatar answered Oct 18 '22 17:10

Till Hoffmann


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
like image 29
T. Lepage Avatar answered Oct 18 '22 17:10

T. Lepage


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)
like image 1
Eric Avatar answered Oct 18 '22 16:10

Eric