#!/usr/bin/env python """This plots a sun-like design with alternating curves The intention is for the curves to alternate being over and under as in Celtic knots """ import matplotlib.pyplot as plt from matplotlib import colors import numpy as np import argparse class WeaveError(ValueError): pass class WeaveCalculator(object): def __init__(self, points, curves, debug=False): self.points = points self.curves = curves self.debug = debug def debug_log(self, message): if self.debug: print(message) def calc_intersection(self, point, curve): return (self.curves*3-1 - point - curve) % self.curves def calc_ordering(self): in_front = {} at_back = {} order_segments = self.points * self.curves * 2 for c in range(0, self.curves): n_int = c polarity_switch = None for tc in range(order_segments): existing_order = in_front.get(tc, []) + at_back.get(tc, []) iw = self.calc_intersection(tc, c) if (iw != c): n_int = n_int + 1 polarity_adjusted_int = n_int + (polarity_switch or 0) ordering, actual_ordering = bool(polarity_adjusted_int % 2), None if c in existing_order or iw in existing_order: if c not in existing_order or iw not in existing_order: raise ValueError("Found partial ordering half way through") actual_ordering = existing_order.index(c) < existing_order.index(iw) if polarity_switch is None and actual_ordering is not None: polarity_switch = 1 - int(bool(actual_ordering == ordering)) if polarity_switch: self.debug_log(f"Color {c} needed a polarity switch for " \ f"ordering {'<>'[ordering]}, actual ordering {'<>'[actual_ordering]}") else: self.debug_log(f"Color {c} didn't need a polarity switch for " \ f"ordering {'<>'[ordering]}, actual ordering {'<>'[actual_ordering]}") ordering = (not ordering) if polarity_switch else ordering if actual_ordering is not None: self.debug_log(f"priority({c:2},{tc:2}) is {'<>'[actual_ordering]} priority({iw:2},{tc:2}): " \ f"existing order {', '.join(str(o) for o in existing_order)}") if actual_ordering != ordering: raise WeaveError("Actual ordering doesn't match expected") continue if ordering: in_front.setdefault(tc, []).append(c) at_back.setdefault(tc, []).append(iw) else: at_back.setdefault(tc, []).append(c) in_front.setdefault(tc, []).append(iw) self.debug_log(f"priority({c:2},{tc:2}) should be {'<>'[ordering]} priority({iw:2},{tc:2})") return {tc: in_front.get(tc, []) + at_back.get(tc, []) for tc in range(order_segments)} can_order = {} for p in range(2, 24): for c in range(2, 12): try: WeaveCalculator(p, c).calc_ordering() can_order[p, c] = True except WeaveError: can_order[p, c] = False class SunCurve(WeaveCalculator): points = 4 # how many peaks each curve has curves = 3 peak = 2 trough = 1.1 line_width = 2 colors = [] bg_colors = [] bg_alphas = [] accuracy = 1440 # points per peak weave = True debug = False def make_default_colors(self, alpha=1.0): hues = np.linspace(54, 36, self.curves)/360 sats = np.linspace(93, 93, self.curves)/100 values = np.linspace(100, 100, self.curves)/100 hsv = np.array([hues, sats, values]).transpose() return [colors.to_rgba(c, 1.0) for c in colors.hsv_to_rgb(hsv)] def make_default_bg_colors(self): return self.make_default_colors() def __init__(self, args): # args can be set to match any named class attribute for arg in dir(args): if arg.startswith('_'): continue if hasattr(type(self), arg): arg_value = getattr(args, arg) setattr(self, arg, arg_value) if not self.colors: self.colors = self.make_default_colors() if not self.bg_colors: self.bg_colors = self.make_default_bg_colors() if not self.bg_alphas: self.bg_alphas = [0.5] @property def theta(self): if getattr(self, "_theta", None) is None: offset = np.pi/self.points/self.curves/2 self._theta = np.arange(offset, 2*np.pi + offset, 2*np.pi/(self.accuracy*self.points)) return self._theta # The peak function is graphed at https://www.geogebra.org/graphing/d7ezedpd # This is repeated semicircles. They are then calculated in polar co-ordinates def calc_curve(self, theta_points, c): mod_func = np.mod(theta_points*self.curves*self.points/(2*np.pi) + c, self.curves) return self.peak - np.sqrt(1 - (mod_func * 2/self.curves - 1)**2)*(self.peak - self.trough) def setup_plot(self): fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) ax.set_rmax(self.peak) ax.set_rticks([]) # No radial ticks ax.set_axis_off() ax.grid(False) # ax.set_title(f"Sun with two alternating plots of {points} points", va='bottom') return ax def get_color(self, c): return self.colors[c % len(self.colors)] def get_bg_color(self, c): return self.bg_colors[c % len(self.bg_colors)] def get_bg_alpha(self, c): return self.bg_alphas[c % len(self.bg_alphas)] def plot_background(self, ax): # colouring in areas full_curves = np.array([self.calc_curve(self.theta, c) for c in range(self.curves)]) full_curves.sort(axis=0) bg_curves = [0] for c in range(self.curves): bg_curve = full_curves[c] bg_curves.append(bg_curve) for c in range(self.curves): ax.fill_between(self.theta, bg_curves[c], bg_curves[c+1], color=self.get_bg_color(c), alpha=self.get_bg_alpha(c), linewidth=0, zorder=-2) def plot_weave(self, ax): full_ordering = self.calc_ordering() for tc, tc_order in full_ordering.items(): self.debug_log(f"ordering at {tc:2}: {', '.join(str(o) for o in tc_order)}") # these segments are separated to allow different orderings to create a weave theta_parts = np.split(self.theta, len(full_ordering)) for c in range(0, self.curves): color = self.get_color(c) for tc, theta_c_segment in enumerate(theta_parts): tc_order = full_ordering[tc] rc = self.calc_curve(theta_c_segment, c) priority_c = tc_order.index(c) if c in tc_order else -1 ax.plot(theta_c_segment, rc, color=color, linewidth=self.line_width, zorder=priority_c) if self.debug: mp = int(len(theta_c_segment)/2) iw = self.calc_intersection(tc, c) debug_text = f"{tc%self.curves},{"/" if priority_c == -1 else priority_c},{"/" if iw == c else iw}" text_rot = theta_c_segment[mp]*180/np.pi-90 ax.text(theta_c_segment[mp], self.trough-0.1*(c+1), debug_text, color=c, ha='center', va='center', rotation=text_rot, rotation_mode='anchor') def plot_non_weave(self, ax): for c in range(self.curves): ax.plot(self.theta, self.calc_curve(self.theta, c), color=self.get_color(c), linewidth=self.line_width) def plot_foreground(self, ax): if self.weave: try: self.plot_weave(ax) except WeaveError as e: print(f"Can't calculate a weave for {self.points} points and {self.curves} curves: plotting unwoven") self.plot_non_weave(ax) else: self.plot_non_weave(ax) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--debug', action='store_true', default=False, help="Add debug text") parser.add_argument('-p', '--points', type=int, default=SunCurve.points, help="Number of points to each curve") parser.add_argument('-c', '--curves', type=int, default=SunCurve.curves, help="Number of curves") parser.add_argument('--peak', type=float, default=SunCurve.peak, help="Radius of peaks") parser.add_argument('--trough', type=float, default=SunCurve.trough, help="Radius of troughs") parser.add_argument('--accuracy', type=int, default=SunCurve.accuracy, help="Points between two peaks") parser.add_argument('-l', '--line-width', type=float, default=SunCurve.line_width, help="Width of lines") parser.add_argument('--no-weave', action='store_false', dest='weave', default=SunCurve.weave, help="Don't try to weave curves") parser.add_argument('-C', '--color', action='append', dest='colors', default=[], help="Custom color to use for foreground plot") parser.add_argument('-B', '--bg-color', action='append', dest='bg_colors', default=[], help="Custom color to use for background plot") parser.add_argument('-A', '--bg-alpha', action='append', dest='bg_alphas', default=[], type=float, help="Custom alpha to use for background plot") parser.add_argument('filename', nargs='?', help="Filename to save to instead of plotting to screen") args = parser.parse_args() sun_curve = SunCurve(args) ax = sun_curve.setup_plot() sun_curve.plot_background(ax) sun_curve.plot_foreground(ax) if args.filename: plt.savefig("polar_sun_plot.svg") else: plt.show()