79793445

Date: 2025-10-17 21:46:25
Score: 1
Natty:
Report link

There may be a bug when trying to transform FancyArrowPatch to 3D for an curved path (connectionstyle arc3). Instead I'm using a workaround much like this one: https://stackoverflow.com/a/79787179/31611660, which is to just draw a very short FancyArrowPatch to render the arrow by itself. Not my favorite, but its the only thing I've seen, so far, that works.

I used this with matplotlib 3.9.2 and numpy 1.26.4

import mpl_toolkits.mplot3d.art3d as art3d
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.patches import Arc, FancyArrowPatch
from mpl_toolkits.mplot3d import proj3d

ARCSTYLE = dict(linestyle="--", color="black", lw=1)
ANGSTYLE = dict(radius=0.6, linestyle="-", color="black", lw=1, mutation_scale=15)
ARROWOPTS = {
    "mutation_scale": 20,
    "arrowstyle": "-|>",
    "color": "k",
    "shrinkA": 0,
    "shrinkB": 0,
}
TEXTOPTS = {
    "horizontalalignment": "center",
    "verticalalignment": "center",
    "fontsize": 14,
}


class Arrow3D(FancyArrowPatch):
    def __init__(self, xs, ys, zs, *args, **kwargs):
        super().__init__((0, 0), (0, 0), *args, **kwargs)
        self._verts3d = xs, ys, zs

    def do_3d_projection(self, renderer=None):
        xs3d, ys3d, zs3d = self._verts3d
        xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
        self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
        return np.min(zs)


class Arc3D(Arc):
    """
    Arc patch transformed to 3D, draws circular arc
       xdir: starting position of arc (3x1)
       ydir: ending position of arc (3x1)
       xy: origin as tuple (x,y)
       radius: radius of arc
       zs: Z-direction offset applied after rotation to 3D
       z: Z-direction offset applied before rotation to 3D
    """

    def __init__(self, xdir, ydir, xy=(0, 0), radius=1, *args, zs=0, z=0, **kwargs):
        xdir = np.array(xdir) / np.linalg.norm(xdir)
        ydir = np.array(ydir) / np.linalg.norm(ydir)
        super().__init__(
            xy,
            radius * 2.0,
            radius * 2.0,
            theta1=0,
            theta2=np.degrees(np.arccos(xdir.dot(ydir))),
            *args,
            **kwargs,
        )
        transform_2d_to_3d(self, rotation_matrix(xdir, ydir), zs=zs, z=z)


def arrowarc3d(
    ax, xdir, ydir, xy=(0, 0), radius=1, *args, zs=0, z=0, arrowfactor=100.0, **kwargs
):
    """
    draws an Arc3D and an arrowhead of Arrow3D
    """

    def radlen(q):
        return q * radius / np.linalg.norm(q)

    kw_arrow = {}
    # which kwargs are for *only* Arrow3D?
    for kw in ("mutation_scale",):
        if kw in kwargs:
            kw_arrow[kw] = kwargs.pop(kw)
    # settings in common
    color = kwargs.get("color", "blue")
    kwargs["color"] = color
    # compute begin & end of arrow
    xdir = np.array(xdir)
    ydir = np.array(ydir)
    # distance between points for arrow is (1/arrowfactor) * |ydir-xdir|
    ym = ydir - (ydir - xdir) / arrowfactor
    ax.add_artist(Arc3D(xdir, ydir, xy, radius, *args, zs=zs, z=z, **kwargs))
    ax.add_artist(Arrow3D(*zip(radlen(ym), radlen(ydir)), color=color, **kw_arrow))


def rotation_matrix(x, y):
    """
    Rotation matrix: [ X  ((X cross Y) cross X)  (X cross Y) ]
       x: X-axis of new coordinate system
       y: orients new coordinate system, new Y-axis is cross(cross(x,y),x)
    rotation_matrix(x,y).dot((1,0,0)) || x
    rotation_matrix(x,y).dot((0,0,1)) || cross(x,y)
    """
    x = np.array(x) / np.linalg.norm(x)
    z = np.cross(x, y)
    z /= np.linalg.norm(z)
    y = np.cross(np.array(z), x)
    return np.array([x, y, z]).T


def transform_2d_to_3d(pathpatch, rotation, zs=0, z=0):
    """
    Transforms a 2D Patch to a 3D patch.
      rotation: a 3x3 rotation matrix from 2D to 3D.
      zs: Z-direction offset applied after rotation
      z: Z-direction offset applied before rotation
    """
    # https://stackoverflow.com/a/18228967/31611660
    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
    # apply the rotation matrix and any "z" or "zs" offsets
    pathpatch._segment3d = np.array(
        [np.dot(rotation, (x, y, z)) + (0, 0, zs) for x, y in verts]
    )


def Rx(phi):
    return np.array(
        [[1, 0, 0], [0, np.cos(phi), -np.sin(phi)], [0, np.sin(phi), np.cos(phi)]]
    )


def Ry(theta):
    return np.array(
        [
            [np.cos(theta), 0, np.sin(theta)],
            [0, 1, 0],
            [-np.sin(theta), 0, np.cos(theta)],
        ]
    )


def Rz(psi):
    return np.array(
        [[np.cos(psi), -np.sin(psi), 0], [np.sin(psi), np.cos(psi), 0], [0, 0, 1]]
    )


def vecs(key, arc):
    """input is in degrees"""
    return vecsr(key, np.array(arc) * np.pi / 180.0)


def vecsr(key, arc):
    """input is in radians"""
    arc = np.array(arc)
    if key == "z":
        return np.array([np.cos(arc), np.sin(arc), arc * 0])
    elif key == "-z":
        return np.array([-np.sin(arc), np.cos(arc), arc * 0])
    elif key == "y":
        return np.array([np.cos(arc), arc * 0, -np.sin(arc)])
    elif key == "-y":
        return np.array([np.sin(arc), arc * 0, np.cos(arc)])
    elif key == "x":
        return np.array([arc * 0, np.cos(arc), np.sin(arc)])
    elif key == "-x":
        return np.array([arc * 0, -np.sin(arc), np.cos(arc)])
    else:
        raise ValueError(f"unknown key {key}")


# define origin
o = np.array([0, 0, 0])

# define ox0y0z0 axes
x0 = np.array([1, 0, 0])
y0 = np.array([0, 1, 0])
z0 = np.array([0, 0, 1])

# define ox1y1z1 axes
psi = 20 * np.pi / 180
x1 = Rz(psi).dot(x0)
y1 = Rz(psi).dot(y0)
z1 = Rz(psi).dot(z0)

# define ox2y2z2 axes
theta = 10 * np.pi / 180
x2 = Rz(psi).dot(Ry(theta)).dot(x0)
y2 = Rz(psi).dot(Ry(theta)).dot(y0)
z2 = Rz(psi).dot(Ry(theta)).dot(z0)

# define ox3y3z3 axes
phi = 30 * np.pi / 180
x3 = Rz(psi).dot(Ry(theta)).dot(Rx(phi)).dot(x0)
y3 = Rz(psi).dot(Ry(theta)).dot(Rx(phi)).dot(y0)
z3 = Rz(psi).dot(Ry(theta)).dot(Rx(phi)).dot(z0)

ARROWOPTS = {
    "mutation_scale": 20,
    "arrowstyle": "-|>",
    "color": "k",
    "shrinkA": 0,
    "shrinkB": 0,
}

# produce figure
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")

# plot ox0y0z0 axes
a = Arrow3D([o[0], x0[0]], [o[1], x0[1]], [o[2], x0[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], y0[0]], [o[1], y0[1]], [o[2], y0[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], z0[0]], [o[1], z0[1]], [o[2], z0[2]], **ARROWOPTS)
ax.add_artist(a)

# plot ox1y1z1 axes
a = Arrow3D([o[0], x1[0]], [o[1], x1[1]], [o[2], x1[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], y1[0]], [o[1], y1[1]], [o[2], y1[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], z1[0]], [o[1], z1[1]], [o[2], z1[2]], **ARROWOPTS)
ax.add_artist(a)

# draw dotted arc in x0y0 plane
ax.add_artist(Arc3D(*vecs("z", [-5, 116]).T, **ARCSTYLE))

# mark z0 rotation angles (psi)
arrowarc3d(ax, *vecsr("z", [0, psi]).T, **ANGSTYLE)
arrowarc3d(ax, *vecsr("-z", [0, psi]).T, **ANGSTYLE)

# plot ox2y2z2 axes
a = Arrow3D([o[0], x2[0]], [o[1], x2[1]], [o[2], x2[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], y2[0]], [o[1], y2[1]], [o[2], y2[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], z2[0]], [o[1], z2[1]], [o[2], z2[2]], **ARROWOPTS)
ax.add_artist(a)

# draw dotted arc in x1z1 plane
ax.add_artist(Arc3D(*Rz(psi).dot(vecs("-y", [-5, 105])).T, **ARCSTYLE))

# mark y1 rotation angles (theta)
arrowarc3d(ax, *Rz(psi).dot(vecsr("y", [0, theta])).T, **ANGSTYLE)
arrowarc3d(ax, *Rz(psi).dot(vecsr("-y", [0, theta])).T, **ANGSTYLE)

# plot ox3y3z3 axes
a = Arrow3D([o[0], x3[0]], [o[1], x3[1]], [o[2], x3[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], y3[0]], [o[1], y3[1]], [o[2], y3[2]], **ARROWOPTS)
ax.add_artist(a)
a = Arrow3D([o[0], z3[0]], [o[1], z3[1]], [o[2], z3[2]], **ARROWOPTS)
ax.add_artist(a)

# draw dotted arc in y2z2 plane
ax.add_artist(Arc3D(*Rz(psi).dot(Ry(theta).dot(vecs("x", [-5, 125]))).T, **ARCSTYLE))

# mark x2 rotation angles (phi)
arrowarc3d(ax, *Rz(psi).dot(Ry(theta).dot(vecsr("x", [0, phi]))).T, **ANGSTYLE)
arrowarc3d(ax, *Rz(psi).dot(Ry(theta).dot(vecsr("-x", [0, phi]))).T, **ANGSTYLE)

# add label for origin
ax.text(0.0, 0.0, -0.05, r"$o$", **TEXTOPTS)

# add labels for x axes
ax.text(1.1 * x0[0], 1.1 * x0[1], 1.1 * x0[2], r"$x_0$", **TEXTOPTS)
ax.text(1.1 * x1[0], 1.1 * x1[1], 1.1 * x1[2], r"$x_1$", **TEXTOPTS)
ax.text(1.1 * x2[0], 1.1 * x2[1], 1.1 * x2[2], r"$x_2, x_3$", **TEXTOPTS)

# add labels for y axes
ax.text(1.1 * y0[0], 1.1 * y0[1], 1.1 * y0[2], r"$y_0$", **TEXTOPTS)
ax.text(1.1 * y1[0], 1.1 * y1[1], 1.1 * y1[2], r"$y_1, y_2$", **TEXTOPTS)
ax.text(1.1 * y3[0], 1.1 * y3[1], 1.1 * y3[2], r"$y_3$", **TEXTOPTS)

# add labels for z axes
ax.text(1.1 * z0[0], 1.1 * z0[1], 1.1 * z0[2], r"$z_0, z_1$", **TEXTOPTS)
ax.text(1.1 * z2[0], 1.1 * z2[1], 1.1 * z2[2], r"$z_2$", **TEXTOPTS)
ax.text(1.1 * z3[0], 1.1 * z3[1], 1.1 * z3[2], r"$z_3$", **TEXTOPTS)

# add psi angle labels
m = 0.55 * ((x0 + x1) / 2.0)
ax.text(m[0], m[1], m[2], r"$\psi$", **TEXTOPTS)
m = 0.55 * ((y0 + y1) / 2.0)
ax.text(m[0], m[1], m[2], r"$\psi$", **TEXTOPTS)

# add theta angle labels
m = 0.55 * ((x1 + x2) / 2.0)
ax.text(m[0], m[1], m[2], r"$\theta$", **TEXTOPTS)
m = 0.55 * ((z1 + z2) / 2.0)
ax.text(m[0], m[1], m[2], r"$\theta$", **TEXTOPTS)

# add phi angle labels
m = 0.55 * ((y2 + y3) / 2.0)
ax.text(m[0], m[1], m[2], r"$\phi$", **TEXTOPTS)
m = 0.55 * ((z2 + z3) / 2.0)
ax.text(m[0], m[1], m[2], r"$\phi$", **TEXTOPTS)

# show figure
ax.view_init(elev=-150, azim=60, roll=0)
ax.set(xlim=[-0.46, 1.06], ylim=[-0.59, 1.06], zlim=[-0.11, 1.03])
ax.set_axis_off()
plt.ion()
plt.show()

result: https://s3nd.pics/post/68f2b73c8b58d466e5f5998b

more info

Reasons:
  • Blacklisted phrase (1): stackoverflow
  • Probably link only (1):
  • Long answer (-1):
  • Has code block (-0.5):
  • Low reputation (0.5):
Posted by: troy