Source code for catacycle.drawing_helpers

from matplotlib.path import Path
import math
import matplotlib.patches as patches
import numpy as np
import matplotlib.path as mpath


[docs]def curved_arrow_single(theta1, theta2, radius, width, origin=(0,0), rel_head_width=1.5, rel_head_len=0.1, abs_head_len=None, reverse=False): """Construct the path for an irreversible curved arrow""" # set the angle swept by the arrowhead if abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length f_angle_offset = math.radians((theta2 - theta1) * rel_head_len) else: f_angle_offset = abs_head_len # Define the radii of the inside and outside of the head and tail head_width = width * rel_head_width tail_out_radius = radius + width / 2.0 tail_in_radius = radius - width / 2.0 if not reverse: theta_tip = theta1 theta_tail = theta2 else: theta_tip = theta2 theta_tail = theta1 f_angle_offset = -f_angle_offset # head_in_point, arrowhead_point, head_out_point = get_perp_arrowhead(radius, theta_tip, f_angle_offset, width, rel_head_width) head_in_point, arrowhead_point, head_out_point = get_isosceles_arrowhead(radius, math.radians(theta_tip), math.radians(theta_tip) + f_angle_offset, head_width) int_outer, ix_pts_outer = get_intersect_segment_circle(head_in_point, head_out_point, tail_out_radius) int_inner, ix_pts_inner = get_intersect_segment_circle(head_in_point, head_out_point, tail_in_radius) if int_outer: start = math.degrees(cart2pol(*ix_pts_outer[0])[1]) else: start = theta_tip + math.degrees(f_angle_offset) # make head wider if it doesn't intersect both sides of tail # return curved_arrow_single(theta1, theta2, radius, width, origin, rel_head_width + 0.1, rel_head_len, # abs_head_len, reverse) if int_inner: end = math.degrees(cart2pol(*ix_pts_inner[0])[1]) else: end = theta_tip + math.degrees(f_angle_offset) # make head wider if it doesn't intersect both sides of tail # return curved_arrow_single(theta1, theta2, radius, width, origin, rel_head_width + 0.1, rel_head_len, # abs_head_len, reverse) if not reverse: outer_arc = scale_arc(Path.arc(start, theta_tail), tail_out_radius) inner_arc = scale_arc(path_arc_cw(theta_tail, end), tail_in_radius) else: outer_arc = scale_arc(path_arc_cw(start, theta_tail), tail_out_radius) inner_arc = scale_arc(Path.arc(theta_tail, end), tail_in_radius) arrowhead = join_points([head_in_point, arrowhead_point, head_out_point]) return shift_path_by_vec(concatenate_paths([outer_arc, inner_arc, arrowhead]), np.array(origin))
[docs]def curved_arrow_double(theta1, theta2, radius, width_outer, width_inner, origin=(0,0), rel_head_width=1.5, f_abs_head_len=None, r_abs_head_len=None, rel_head_len=0.1, reverse=False): """Construct the paths a double-sided reversible curved arrow. Returns the paths for both the outer and inner arrows. Radius is the distance from the origin to the inside of the outer arrow""" if not reverse: angle_tip_out = math.radians(theta1) angle_tip_in = math.radians(theta2) # set the angle swept by the arrowhead if f_abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length f_angle_offset = math.radians((theta2-theta1) * rel_head_len) else: f_angle_offset = f_abs_head_len # set the angle swept by the arrowhead if r_abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length r_angle_offset = math.radians((theta2 - theta1) * rel_head_len) else: r_angle_offset = r_abs_head_len # Define the radii of the inside and outside of the head and tail head_out_width = width_outer * (rel_head_width + 1) head_in_width = width_inner * (rel_head_width + 1) tail_out_radius = radius + width_outer tail_in_radius = radius - width_inner head_out_in_xy, arrowtip_out_xy, head_out_out_xy = get_isosceles_arrowhead(radius, angle_tip_out, angle_tip_out + f_angle_offset, head_out_width) head_in_in_xy, arrowtip_in_xy, head_in_out_xy = get_isosceles_arrowhead(radius, angle_tip_in, angle_tip_in - r_angle_offset, head_in_width) int_outer, ix_pts_outer = get_intersect_segment_circle(head_out_in_xy, head_out_out_xy, tail_out_radius) int_inner, ix_pts_inner = get_intersect_segment_circle(head_in_in_xy, head_in_out_xy, tail_in_radius) if int_outer: start_outer_arc = math.degrees(cart2pol(*ix_pts_outer[0])[1]) else: start_outer_arc = theta1 + math.degrees(f_angle_offset) if int_inner: end_inner_arc = math.degrees(cart2pol(*ix_pts_inner[0])[1]) else: end_inner_arc = theta2 - math.degrees(r_angle_offset) outer_arc = scale_arc(Path.arc(start_outer_arc, theta2), tail_out_radius) middle_arc = scale_arc(path_arc_cw(theta2, theta1), radius) inner_arc = scale_arc(Path.arc(theta1, end_inner_arc), tail_in_radius) outer_arrowhead = join_points([head_out_out_xy]) inner_arrowhead = join_points([head_in_in_xy]) outer_path = shift_path_by_vec(concatenate_paths([outer_arc, middle_arc, outer_arrowhead]), np.array(origin)) inner_path = shift_path_by_vec(concatenate_paths([middle_arc, inner_arc, inner_arrowhead]), np.array(origin)) return outer_path, inner_path else: pass
[docs]def straight_arrow_single(length, width, origin=(0,0), rel_head_width=0.5, abs_head_len=None, rel_head_len=0.2, reverse=False): """Construct the path for an irreversible straight arrow""" # set the width of the arrowhead if abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length f_offset = length * rel_head_len else: f_offset = abs_head_len width_head_part = width * rel_head_width if reverse: length = -length f_offset = -f_offset tip = (length / 2, 0) start = (length / 2 - f_offset, width / 2) tail_top = (-length / 2, width / 2) tail_bottom = (-length / 2, -width / 2) head_bottom = (length / 2 - f_offset, -width / 2) head_bottom_point = (length / 2 - f_offset, - width_head_part / 2) head_top_point = (length / 2 - f_offset, width_head_part / 2) points = [start, tail_top, tail_bottom, head_bottom, head_bottom_point, tip, head_top_point] path = patches.Polygon(np.array(points)).get_path() return shift_path_by_vec(path, np.array(origin))
[docs]def straight_arrow_double(length, width_top, width_bottom, origin=(0,0), rel_head_width=0.5, f_abs_head_len=None, r_abs_head_len=None, rel_head_len=0.2, reverse=False): """Construct the path for an irreversible straight arrow""" # set the width of the arrowhead if f_abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length f_offset = length * rel_head_len else: f_offset = f_abs_head_len if r_abs_head_len is None: # compute arrow head length (angle swept) as a fraction of total length r_offset = length * rel_head_len else: r_offset = r_abs_head_len width_head_top = width_top * rel_head_width width_head_bottom = width_bottom * rel_head_width if reverse: length = -length f_offset = -f_offset r_offset = -r_offset tip_top = (length / 2, 0) start = (length / 2 - f_offset, width_top) tail_top_top = (-length / 2, width_top) tail_bottom_top = (-length / 2, 0) head_top_point = (length / 2 - f_offset, width_head_top / 2 + width_top / 2) points_top = [start, tail_top_top, tail_bottom_top, tip_top, head_top_point] path_top = patches.Polygon(np.array(points_top)).get_path() tail_bottom_bottom = (length / 2, -width_bottom) head_bottom_bottom = (-length / 2 + r_offset, -width_bottom) head_bottom_point = (-length / 2 + r_offset, -width_bottom / 2 - width_head_bottom / 2) points_bottom = [tail_bottom_top, head_bottom_point, head_bottom_bottom, tail_bottom_bottom, tip_top] path_bottom = patches.Polygon(np.array(points_bottom)).get_path() return shift_path_by_vec(path_top, np.array(origin)), shift_path_by_vec(path_bottom, np.array(origin))
[docs]def filled_circular_arc(theta1, theta2, radius, width, origin=(0,0)): """Construct the path for a circular arc""" # Define the radii of the inside and outside of the arc out_radius = radius + width / 2.0 in_radius = radius - width / 2.0 outer_arc = scale_arc(Path.arc(theta1, theta2), out_radius) inner_arc = scale_arc(path_arc_cw(theta2, theta1), in_radius) return shift_path_by_vec(concatenate_paths([outer_arc, inner_arc]), np.array(origin))
[docs]def path_arc_cw(theta1, theta2): """used if theta1 >= theta2""" # construct the normal arc ccw arc1 = Path.arc(theta2, theta1) # flip the vertices and control points verts = list(arc1.vertices[0::2]) verts.reverse() controls = list(arc1.vertices[1::2]) controls.reverse() new_verts = [] for i in range(len(controls)): new_verts.append(verts[i]) new_verts.append(controls[i]) new_verts.append(verts[-1]) return Path(np.array(new_verts), arc1.codes)
[docs]def path_arc_smart(theta1, theta2): if theta1 >= theta2: return path_arc_cw(theta1, theta2) else: return Path.arc(theta1, theta2)
[docs]def scale_arc(arc_path, scale): return Path(arc_path.vertices * scale, arc_path.codes)
[docs]def join_points(points): vertices = [] codes = [Path.MOVETO] for i, (x,y) in enumerate(points): if i > 0: codes.append(Path.LINETO) vertices.append(np.array([x,y], float)) return Path(np.array(vertices), codes)
[docs]def concatenate_paths(paths, connect=True): start_path = paths.pop(0) overall_verts = list(start_path.vertices) start_vert = tuple(overall_verts[0]) overall_codes = list(start_path.codes) for path in paths: verts = list(path.vertices) codes = list(path.codes) if connect: codes[0] = Path.LINETO else: codes[0] = Path.MOVETO overall_codes += codes overall_verts += verts # close the path if connect: overall_verts.append(start_vert) overall_codes.append(Path.CLOSEPOLY) return Path(np.array(overall_verts), overall_codes)
[docs]def shift_path_by_vec(path, vec): shifted_vertices = np.array([vert + vec for vert in path.vertices]) return Path(shifted_vertices, path.codes)
[docs]def set_ax_lims(ax, paths): bbox_pts = mpath.get_paths_extents(paths).get_points() max_x_dim = np.max(np.abs(bbox_pts[:, 0])) max_y_dim = np.max(np.abs(bbox_pts[:, 1])) xlim = (-max_x_dim - 0.1, max_x_dim + 0.1) ylim = (-max_y_dim - 0.5, max_y_dim + 0.1) ax.set_xlim(xlim) ax.set_ylim(ylim) ax.set_aspect(1)
[docs]def ensure_valid_gap(delta, gap, precision=1): theta1 = 90 - delta + (gap / 2.0) theta2 = 90 - (gap / 2.0) while theta2 <= theta1: gap -= precision theta1 = 90 - delta + (gap / 2.0) theta2 = 90 - (gap / 2.0) return gap
[docs]def ensure_valid_gaps(delta, gap1, gap0, precision=1): theta1 = 90 - delta + (gap1 / 2.0) theta2 = 90 - (gap0 / 2.0) while theta2 <= theta1: gap1 -= precision / 2 gap0 -= precision / 2 theta1 = 90 - delta + (gap1 / 2.0) theta2 = 90 - (gap0 / 2.0) return gap1, gap0
[docs]def ensure_all_valid_gaps(delta, gaps, precision=1): for i in range(len(gaps)): theta1 = 90 - delta + (gaps[i] / 2.0) theta2 = 90 - (gaps[i-1] / 2.0) while theta2 <= theta1: gaps[i] -= precision / 2 gaps[i-1] -= precision / 2 theta1 = 90 - delta + (gaps[i] / 2.0) theta2 = 90 - (gaps[i-1] / 2.0) return gaps
[docs]def get_isosceles_arrowhead(radius, theta1, theta2, base_width): point1 = radius * np.array([math.cos(theta1), math.sin(theta1)]) point2 = radius * np.array([math.cos(theta2), math.sin(theta2)]) v_12 = point2 - point1 if v_12[0] == 0: # vertical line. We are at the right or left of the circle u_cross = np.array([1,0]) elif v_12[1] == 0: u_cross = np.array([0, 1]) else: u_cross = np.array([v_12[1], -v_12[0]]) / np.sqrt(v_12[1]**2 + v_12[0]**2) # m expressed as unit vector point4 = point2 + base_width / 2 * u_cross point5 = point2 - base_width / 2 * u_cross if np.linalg.norm(point4) > np.linalg.norm(point5): return [point5, point1, point4] # inside, tip, outside else: return [point4, point1, point5] # inside, tip, outside
[docs]def get_isosceles_arrowhead_old(radius, theta1, theta2, base_width): point1 = radius * np.array([math.cos(theta1), math.sin(theta1)]) point2 = radius * np.array([math.cos(theta2), math.sin(theta2)]) if point1[0] == 0: # vertical line. We are at the top or bottom of the circle delx = point2[0] - point1[0] point3 = point1 + np.array([delx, 0]) u_m = np.array([0,1]) elif point1[1] == 0: dely = point2[1] - point1[1] point3 = point1 + np.array([0, dely]) u_m = np.array([1, 0]) else: m = math.sin(theta1)/math.cos(theta1) line1 = np.array([m, 0]) # m, b in y=mx+b line2 = np.array([m, point2[1] - m * point2[0]]) # || to line 1 but through point 2 line3 = np.array([-1 / m, point1[1] + 1 / m * point1[0]]) # normal to lines 1 and 2 and through point 1 point3_x = (line3[1] - line2[1]) / (m + 1 / m) point3 = np.array([point3_x, m * point3_x + line2[1]]) # intersection of line 3 and line 2 u_m = np.array([1, m]) / np.sqrt(1**2 + m**2) # m expressed as unit vector point4 = point3 + base_width / 2 * u_m point5 = point3 - base_width / 2 * u_m return [point5, point1, point4] # inside, tip, outside
[docs]def get_perp_arrowhead(radius, theta_tip, f_angle_offset, width, rel_head_width, reverse): head_out_radius = radius + width / 2.0 + width * rel_head_width head_in_radius = radius - width / 2.0 - width * rel_head_width angle_tip = math.radians(theta_tip) arrowhead_point = (radius * math.cos(angle_tip), radius * math.sin(angle_tip)) head_out_point = (head_out_radius * math.cos(angle_tip + f_angle_offset), head_out_radius * math.sin(angle_tip + f_angle_offset)) head_in_point = (head_in_radius * math.cos(angle_tip + f_angle_offset), head_in_radius * math.sin(angle_tip + f_angle_offset)) return [head_in_point, arrowhead_point, head_out_point]
[docs]def get_intersect_segment_circle(p1, p2, r, p_cent=np.array([0,0])): """https://codereview.stackexchange.com/questions/86421/line-segment-to-circle-collision-algorithm""" V = p2 - p1 a = V.dot(V) b = 2 * V.dot(p1 - p_cent) c = p1.dot(p1) + p_cent.dot(p_cent) - 2 * p1.dot(p_cent) - r**2 disc = b**2 - 4 * a * c if disc < 0: # line misses circle return False, [] else: t1 = (-b + np.sqrt(disc)) / (2 * a) t2 = (-b - np.sqrt(disc)) / (2 * a) if not (0 <= t1 <= 1 or 0 <= t2 <= 1): # line segment doesn't extend far enough to intersect circle return False, None else: pts = [] for mult in [t1, t2]: if 0 <= mult <= 1: pts.append(p1 + mult * V) return True, pts
[docs]def cart2pol(x, y): rho = np.sqrt(x**2 + y**2) phi = np.arctan2(y, x) return rho, phi