Skip to content

Instantly share code, notes, and snippets.

@thomasaarholt
Last active February 2, 2023 02:01
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomasaarholt/aab2d5bfc515d407ebb1abd4f81bae04 to your computer and use it in GitHub Desktop.
Save thomasaarholt/aab2d5bfc515d407ebb1abd4f81bae04 to your computer and use it in GitHub Desktop.
Create a matplotlib Arc patch to show the angle between two lines
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
Arc = matplotlib.patches.Arc
def halfangle(a, b):
"Gets the middle angle between a and b, when increasing from a to b"
if b < a:
b += 360
return (a + b)/2 % 360
def get_arc_patch(lines, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8):
"""For two sets of two points, create a matplotlib Arc patch drawing
an arc between the two lines.
lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
radius: None, float or tuple of floats. If None, is set to half the length
of the shortest line
orgio: If True, draws the arc around the point (0,0). If False, estimates
the intersection of the lines and uses that point.
flip: If True, flips the arc to the opposite side by 180 degrees
obtuse: If True, uses the other set of angles. Often used with reverse=True.
reverse: If True, reverses the two angles so that the arc is drawn
"the opposite way around the circle"
dec: The number of decimals to round to
fontsize: fontsize of the angle label
"""
import numpy as np
from matplotlib.patches import Arc
linedata = [np.array(line.T) for line in lines]
scales = [np.diff(line).T[0] for line in linedata]
scales = [s[1] / s[0] for s in scales]
# Get angle to horizontal
angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
if obtuse:
angles[1] = angles[1] + 180
if flip:
angles += 180
if reverse:
angles = angles[::-1]
angle = abs(angles[1]-angles[0])
if radius is None:
lengths = np.linalg.norm(lines, axis=(0,1))
radius = min(lengths)/2
# Solve the point of intersection between the lines:
t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
intersection = np.array((1-t)*line1[0] + t*line1[1])
# Check if radius is a single value or a tuple
try:
r1, r2 = radius
except:
r1 = r2 = radius
arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
half = halfangle(*angles[::-1])
sin = np.sin(np.deg2rad(half))
cos = np.cos(np.deg2rad(half))
r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
xy = np.array((r*cos, r*sin))
xy = intersection + xy/2
textangle = half if half > 270 or half < 90 else 180 + half
textkwargs = {
'x':xy[0],
'y':xy[1],
's':str(round(angle, dec)) + "°",
'ha':'center',
'va':'center',
'fontsize':fontsize,
'rotation':textangle
}
return arc, textkwargs
# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
lines = [line1, line2]
fig, AX = plt.subplots(nrows=2, ncols=2)
for ax in AX.flatten():
for line in lines:
x,y = line.T
ax.plot(x,y)
ax.axis('equal')
ax1, ax2, ax3, ax4 = AX.flatten()
arc, angle_text = get_arc_patch(lines)
ax1.add_artist(arc)
ax1.set(title='Default')
ax1.text(**angle_text)
arc, angle_text = get_arc_patch(lines, flip=True)
ax2.add_artist(arc)
ax2.set(title='flip=True')
ax2.text(**angle_text)
arc, angle_text = get_arc_patch(lines, obtuse=True, reverse=True)
ax3.add_artist(arc)
ax3.set(title='obtuse=True, reverse=True')
ax3.text(**angle_text)
arc, angle_text = get_arc_patch(lines, radius=(2,1))
ax4.add_artist(arc)
ax4.set(title='radius=(2,1)')
ax4.text(**angle_text)
plt.tight_layout()
plt.show()
@SeanDS
Copy link

SeanDS commented Feb 28, 2022

For reference, this is what your code produces:

Figure_1

@thomasaarholt
Copy link
Author

Thanks! Pasting screenshots in the comments like that is very useful! Good idea!

Hope you found the code helpful!

@owen2t
Copy link

owen2t commented May 24, 2022

In lines 51 and 52, you're referencing variables that are declared outside of the function

@salogranada
Copy link

salogranada commented Aug 28, 2022

In lines 51 and 52, you're referencing variables that are declared outside of the function

@owen2t I tested the code and made some quick fixes for even more applicability by creating a class. That also fixes this error.

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.patches import Arc

class LinesAngles:
    def __init__(self, line1, line2, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8, title=""):
        """
        line1: list of two points, of shape [[x0, y0], [x1, y1]]
        line2: list of two points, of shape [[x0, y0], [x1, y1]]
        radius: None, float or tuple of floats. If None, is set to half the length
            of the shortest line orgio: If True, draws the arc around the point (0,0). If False, estimates 
            the intersection of the lines and uses that point.
        flip: If True, flips the arc to the opposite side by 180 degrees
        obtuse: If True, uses the other set of angles. Often used with reverse=True.
        reverse: If True, reverses the two angles so that the arc is drawn "the opposite way around the circle"
        dec: The number of decimals to round to
        fontsize: fontsize of the angle label
        title: Title of the plot
        """
        self.line1 = line1
        self.line2 = line2
        self.lines = [line1, line2]
        self.radius = radius
        self.flip = flip
        self.obtuse = obtuse
        self.reverse = reverse
        self.dec = dec
        self.fontsize = fontsize
        self.title = title

    def halfangle(self,a, b) -> float:
        """
        Gets the middle angle between a and b, when increasing from a to b
        a: float, angle in degrees
        b: float, angle in degrees
        returns: float, angle in degrees
        """
        if b < a:
            b += 360
        return (a + b)/2 % 360

    def get_arc_patch(self, lines: list):
        """
        For two sets of two points, create a matplotlib Arc patch drawing 
        an arc between the two lines. 
        lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
        returns: Arc patch, and text for the angle label
        """
      
        linedata = [np.array(line.T) for line in lines]
        scales = [np.diff(line).T[0] for line in linedata]
        scales = [s[1] / s[0] for s in scales]
        
        # Get angle to horizontal
        angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
        if self.obtuse:
            angles[1] = angles[1] + 180
        if self.flip:
            angles += 180
        if self.reverse:
            angles = angles[::-1]
            
        angle = abs(angles[1]-angles[0])
        
        if self.radius is None:
            lengths = np.linalg.norm(lines, axis=(0,1))
            self.radius = min(lengths)/2
        
        # Solve the point of intersection between the lines:
        t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
        intersection = np.array((1-t)*line1[0] + t*line1[1])
        # Check if radius is a single value or a tuple
        try:
            r1, r2 = self.radius
        except:
            r1 = r2 = self.radius
        arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
        
        half = self.halfangle(*angles[::-1])
        sin = np.sin(np.deg2rad(half))
        cos = np.cos(np.deg2rad(half))

        r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
        xy = np.array((r*cos, r*sin))
        xy =  intersection + xy/2
        
        textangle = half if half > 270 or half < 90 else 180 + half 
        textkwargs = {
            'x':xy[0],
            'y':xy[1],
            's':str(round(angle, self.dec)) + "°",
            'ha':'center',
            'va':'center',
            'fontsize':self.fontsize,
            'rotation':textangle
        }
        return arc, textkwargs

    def plot(self) -> None:
        """!
        Plot the lines and the arc
        """

        fig = plt.figure()
        ax = fig.add_subplot(1,1,1)

        for line in self.lines:
            x,y = line.T
            ax.plot(x,y)
            ax.axis('equal')

        arc, angle_text = self.get_arc_patch(self.lines)
        ax.add_artist(arc)
        ax.set(title=self.title)
        ax.text(**angle_text)
        plt.savefig("angles.png")
        plt.show()
        


def multiple_plot(*plots, num_subplots: int):
    """
    Plot multiple lines and arcs
    plots: list of LinesAngles objects
    num_subplots: number of subplots
    """
    if num_subplots % 2 != 0:
        num_subplots += 1
    
    fig, AX = plt.subplots(nrows=int(num_subplots/2), ncols=int(num_subplots/2))
    for ax in AX.flatten():
        for i, plot in enumerate(plots):
            ax = fig.add_subplot(int(num_subplots/2), int(num_subplots/2), i+1)
            for line in plot.lines:
                x,y = line.T
                ax.plot(x,y)
                ax.axis('equal')
                arc, angle_text = plot.get_arc_patch(plot.lines)
                ax.add_artist(arc)
                ax.set(title=plot.title)
                ax.text(**angle_text)
    fig.tight_layout()
    plt.savefig("multiple_plot.png")
    plt.show()

For using it you just create the instance and the plot function.

# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
default = LinesAngles(line1, line2, title="Default")
flip = LinesAngles(line1, line2, title='flip=True', flip=True)
obtuse = LinesAngles(line1, line2, title='obtuse=True, reverse=True', obtuse=True, reverse=True)
radius = LinesAngles(line1, line2, title='radius=(2,1)', radius=(2,1))

#Plot single pair of lines
default.plot()
#Plot multiple line pairs
multiple_plot(default, flip, obtuse, radius, num_subplots=4)

Thanks to @thomasaarholt for his contribution, all credit to him I only made some modifications.

@shankarsivarajan
Copy link

It might be worth adding **kwargs to the function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment