#!/usr/bin/python3
#+
# Generate various types of curves using various graphics systems.
# Invoke this script as follows:
#
#     curve [options] curvespec [curvespec...]
#
# where the valid options are
#
#     --draw=drawspec (required)
#         specifies how to do the drawing. The drawspec looks like
#             drawtype[:drawoption[:drawoption..]]
#        where drawtype is one of
#             cairo      -- use Cairo graphics to output a PNG image file.
#             postscript -- output PostScript drawing commands.
#             turtle     -- draw onscreen using Turtle graphics.
#        and the drawoptions are drawtype-specific.
#
#        For the cairo drawtype, the drawoptions are:
#            out=filename
#                specifies a filename to write the PNG image data to.
#                If not specified, the image is displayed on-screen.
#            scale=f
#                an overall scale factor to apply to the drawing.
#
#        For the postscript drawtype, the drawoptions are:
#            out=filename
#                specifies a filename to write the PostScript to. If not specified,
#                it goes to standard output.
#            scale=f
#                an overall scale factor to apply to the drawing.
#
#        For the turtle drawtype, the drawoptions are:
#            hideturtle
#                hide the turtle to speed up drawing.
#            speed=ratio
#                set relative speed of turtle movement.
#            trackheading
#                causes the turtle to always face in the direction it is moving.
#                Slows down the drawing, which is why it’s not the default.
#
#     --bgcolor/bgcolour=colourspec
#            specifies the background colour to set before doing drawing.
#     --nrsteps=nrsteps
#            optionally specify a default nrsteps value for curvespecs that
#            do not specify their own.
#     --size=w:h
#            specifies the size of the drawing area. Note this mandatory
#            for the Cairo drawtype.
#
# Each curvespec specifies the parameters for a curve to draw, and takes the form
#
#     curvetype:keyword=value:keyword=value...
#
# where the curvetype specifies the general class of curve to draw, and the
# keyword-value pairs specify parameters defining the exact curve to draw. Some
# keywords are specific to the curve type, whereas others are common across
# curve types. If curvetype is the empty string, then it defaults to the same
# curvetype and same settings as the previous curvespec (this is not allowed for
# the first curvespec).
#
# Keywords that are valid across all curve types are:
#
#     pencolor/pencolour
#         specifies the pen colour to use to draw the curve. Defaults to the
#         same as the previous curve, or black for the first curve.
#     pensize
#         specifies the width of the curve. Defaults to the same as the previous
#         curve, or 1 for the first curve.
#     nrsteps (required unless --nrsteps is specified)
#         the integer number of straight-line segments that make up the complete
#         curve. A larger number makes for a more accurate (smoother) curve, at
#         the expense of greater drawing time.
#
# The currently-valid curve types are:
#     lissa -- Lissajous curve
#     maurer -- Maurer rose
#     rose -- rose curve
#     spirolat -- spirolateral curve
#     troch -- trochoid curve
#     whirl -- nested polygons
#
# Valid keywords specific to the lissa curve type are:
#     x_amp (required)
#        amplitude of curve along x-axis
#     x_freq (required)
#        frequency of curve along x-axis
#     x_phase
#        phase of curve along x-axis, defaults to 0
#     y_amp (required)
#        amplitude of curve along y-axis
#     y_freq (required)
#        frequency of curve along y-axis
#     y_phase
#        phase of curve along y-axis, defaults to 0
#
# Valid keywords specific to the maurer curve type are:
#     amplitude (required)
#        amplitude of the sine wave, the main parameter governing the size of the curve
#     delta (required)
#        an integer specifying the number of steps around the circle for successive curve
#        points
#     freq (required)
#        an integer specifying the frequency multiplier
#     mod (required)
#        an integer defining the number of steps into which the circle is divided
#     offset
#        an optional value to offset the centre of the wave from the centre of the
#        pattern. Defaults to 0.
#     phase
#        a phase angle for rotating the curve. Defaults to 0.
#
# Note that nrsteps is not applicable to maurer curves; the mod value is used instead.
#
# Valid keywords specific to the rose curve type are:
#     amplitude (required)
#        amplitude of the sine wave, the main parameter governing the size of the curve
#     freq
#        an integer or a ratio of two integers a/b that define the frequency of the sine wave.
#     offset
#        an optional value to offset the centre of the wave from the centre of the
#        pattern. Defaults to 0.
#     phase
#        a phase angle for rotating the curve. Defaults to 0.
#
# Valid keywords specific to the spirolat curve type are:
#     angle (required)
#         the angle between segments, expressed as a ratio of two integers that define the
#         rational fraction of the circle.
#     n (required)
#        the maximum integer multiplier factor for the lengths of lines. Line lengths
#        go from 1 * n to n * step.
#     phase
#         optional rotation angle in degrees for this pattern. If omitted,
#         defaults to 0.
#     reversed
#         a comma-separated list of one or more length multiplier factors for
#         which to reverse the direction. If omitted, no reversals happen.
#     start
#         the starting angle, expressed as a ratio of two integers that define the
#         rational fraction of the circle. If omitted, defaults to zero.
#     step (required)
#         lengths of lines are integer multiples of this.
#
# Valid keywords specific to the troch curve type are:
#     ring (required)
#         specifies the relative radius of the stationary circle. Must be an
#         integer
#     wheel (required)
#         specifies the relative radius of the moving circle. Must be an integer
#     scale
#         a scale factor applied to the ring and wheel values to determine the
#         actual radii. Separating this out allows small numbers to be used
#         for the other two, making their relative factor relationships easier
#         to see.
#     frac (required)
#         specifies the fraction of the wheel radius from the centre of the wheel
#         at which the point is located that traces out the curve.
#     phase
#         optional starting phase angle in degrees for this pattern. If omitted,
#         defaults to 0.
#
# Valid keywords specific to the whirl curve type are:
#     nr_polys (required)
#         the number of nested polygons to draw.
#     nr_sides (required)
#         the number of sides each polygon is to have.
#     phase
#         optional rotation angle in degrees for this pattern. If omitted,
#         defaults to 0.
#     poly_shrink
#         the relative shrink amount for each successive polygon, in [-1, +1].
#         Positive values cause each successive polygon to be rotated anticlockwise;
#         negative values cause them to be rotated clockwise. Zero means all the
#         polygons end up the same size on top of each other.
#     radius
#         the starting radius for the outermost polygon.
#
# A colour specification can take the following forms:
#     * a colour name from the list in /usr/share/X11/rgb.txt
#     * three colour components, each in the range [0, 1] separated by commas,
#       optionally preceded by a colour space specification and another comma:
#           <r>,<g>,<b>
#           rgb,<r>,<g>,<b>
#           hsv,<h>,<s>,<v>
#       Currently-recognized colour spaces are "rgb" and "hsv", with "rgb" being
#       the default if not specified.
#
# Examples:
#
#     curve --draw=turtle:hideturtle:speed=2 \
#         lissa:x_amp=200:y_amp=200:x_freq=3:y_freq=2:nrsteps=200:x_phase=-.2:pensize=4
#
# draws a Lissajous curve on-screen, animating at twice normal speed.
#
#     curve --draw=cairo:out=try.png --size=700:700 \
#         rose:amplitude=300:freq=7/2:pensize=8:nrsteps=300:offset=30
#
# draws a rose curve and saves the image into the file try.png.
#
#     curve --draw=postscript:out=try.ps --bgcolour=lavender \
#         troch:ring=17:wheel=-6:scale=10:frac=3:nrsteps=500:pensize=12:pencolour=orange
#
# generates PostScript code for filling the background with a lavender colour, and drawing
# a trochoid curve on top of it, into the file try.ps.
#
#     curve --draw=turtle --bgcolour=hsv,0.67,.08,.98 \
#         troch:ring=17:wheel=-6:scale=10:frac=3:nrsteps=500:pensize=12:pencolour=hsv,.11,1,1
#
# does the same trochoid curve as the previous example, specifying the colours in HSV
# form.
#
# Further example curvespecs:
#
#     maurer:amplitude=200:freq=5:delta=97:mod=360
#
# recreates figure 2 from the Maurer paper.
#
# External program needed: ImageMagick for on-screen display of Cairo-generated PNG images.
#
# Copyright 2014, 2016, 2019 by Lawrence D'Oliveiro <ldo@geek-central.gen.nz>.
# Licensed under CC-BY-SA <http://creativecommons.org/licenses/by-sa/4.0/>.
#-

import sys
import math
from fractions import \
    Fraction
try :
    # Python 3.5 and later
    from math import \
        gcd
except ImportError :
    # pre-Python 3.5
    from fractions import \
        gcd
#end try
import subprocess
import colorsys
import qahirah as qah
from qahirah import \
    CAIRO, \
    Colour, \
    Vector
import getopt

#+
# Useful stuff
#-

def make_parse_int_range(lo, hi = None) :

    def parse_int_atleast(s) :
        val = int(s)
        if val < lo :
            raise ValueError("value must be at least %d, not %d" % (lo, val))
        #end if
        return \
            val
    #end parse_int_atleast

    def parse_int_range(s) :
        val = int(s)
        if val < lo or val > hi :
            raise ValueError("value must be in [%d .. %d], not %d" % (lo, hi, val))
        #end if
        return \
            val
    #end parse_int_range

#begin make_parse_int_range
    return \
        (parse_int_atleast, parse_int_range)[hi != None]
#end make_parse_int_range

def parse_ratio(ratiostr) :
    "parses ratiostr as a single integer, or as two integers separated by a" \
    " slash: a/b representing a ratio. returns a Fraction object representing" \
    " the value in either case."
    splitpos = ratiostr.find("/")
    if splitpos >= 0 :
        numer, denom = int(ratiostr[:splitpos]), int(ratiostr[splitpos + 1:])
    else :
        numer = int(ratiostr)
        denom = 1
    #end if
    return \
        Fraction(numer, denom)
#end parse_ratio

def parse_natural_set(sstr) :
    "parses sstr as a comma-separated list of positive integers, and" \
    " returns the set of values found."
    result = set()
    for item in sstr.split(",") :
        item = int(item)
        if item <= 0 :
            raise ValueError("naturals must be positive: %d" % item)
        #end if
        result.add(item)
    #end for
    return \
        result
#end parse_natural_set

def parse_colour_opt(colourstr) :
    if colourstr.find(",") >= 0 :
        items = colourstr.split(",")
        if len(items) == 4 :
            space = items[0].lower()
            items = items[1:]
        elif len(items) == 3 :
            space = "rgb"
        else :
            raise ValueError("colour spec must be [space,]comp1,comp2,comp3")
        #end if
        pen_colour = \
            (
                {
                    "hsv" : colorsys.hsv_to_rgb,
                    "rgb" : lambda r, g, b : (r, g, b),
                }[space](*tuple(float(i) for i in items))
            )
    elif colourstr.startswith("#") :
        assert (len(colourstr) - 1) % 3 == 0, "invalid HTML colour spec"
        seglen = (len(colourstr) - 1) // 3
        pen_colour = tuple \
          (
            int(colourstr[i * seglen + 1 : (i + 1) * seglen + 1], 16) / ((1 << seglen * 4) - 1)
            for i in (0, 1, 2)
          )
    else :
        pen_colour = colourstr # assume it’s a colour name
    #end if
    return pen_colour
#end parse_colour_opt

def parse_colour_args(args) :
    if len(args) == 1 and type(args[0]) == str :
        newcolour = tuple(Colour.x11[args[0]])[:3]
    elif len(args) == 1 and type(args[0]) == tuple and len(args[0]) == 3 :
        newcolour = args[0]
    elif len(args) == 3 :
        newcolour = args
    else :
        raise TypeError("invalid colour specification")
    #end if
    return newcolour
#end parse_colour_args

#+
# Drawing classes
#-

class Drawer :
    "abstract base class for implementations of different types of drawing output."
#+
# Subclasses should define the following methods:
#
# opt_xxx
#     defines a command-line option xxx (for whatever xxx you like) to be specified
#     following the drawtype. Can take one or no arguments.
#
# setsize(w, h)
#     sets the dimensions of the drawing area.
#
# bgcolour(c)
#     clears the background to the specified colour. See pencolour, below, for valid
#     values for c.
#
# donesetup()
#     will be called exactly once. Indicates that no further calls to setsize or
#     bgcolour will be done.
#
# pensize(w)
#     sets the pensize for subsequent drawing to w.
#
# pencolour(c)
#     sets the pen colour for subsequent to c, which can be an X11 colour name, or a tuple
#     of 3 values (r, g, b) in [0 .. 1], or 3 separate args r, g, b in [0 .. 1].
#
# penup()
#     stops subsequent pen movements from drawing any lines.
#
# pendown()
#     resumes drawing lines on subsequent pen movements.
#
# setpos(p)
#     moves the pen to the specified position, which can be a Vector, or 2 separate x and y
#     arguments.
#
# close()
#     closes the current curve by adding a segment from the end point back to the
#     start point.
#
# done()
#     called at the end of drawing.
#-
    pass

#end Drawer

class CairoDrawer(Drawer) :
    "does drawing using Cairo graphics <http://cairographics.org/> via the" \
    " Qahirah wrapper <https://github.com/ldo/qahirah>."

    def __init__(self) :
        self.size = None
        self.bg_colour = Colour.grey(1)
        self.pen_colour = Colour.grey(0)
        self.pen_size = 1
        self.scale_factor = 1.0
        self.outfilename = None
    #end __init__

    def opt_out(self, filename) :
        self.outfilename = filename
    #end opt_out

    def opt_scale(self, factor) :
        self.scale_factor = float(factor)
    #end opt_scale

    def setsize(self, newsize) :
        self.size = Vector.from_tuple(newsize)
    #end setsize

    def bgcolour(self, *args) :
        self.bg_colour = Colour.from_rgba(parse_colour_args(args))
    #end bgcolour

    def donesetup(self) :
        assert self.size != None, "forgot to set drawing size"
        self.pix = qah.ImageSurface.create \
          (
            format = CAIRO.FORMAT_RGB24,
            dimensions = self.size
          )
        self.g = \
            (qah.Context.create(self.pix)
                .set_operator(CAIRO.OPERATOR_SOURCE)
                .translate(self.size / 2)
                .scale((self.scale_factor, self.scale_factor))
                .set_source_colour(self.bg_colour)
                .paint()
                .new_path()
                .move_to((0, 0))
            )
        self.inpath = True
        self.drawing = True
    #end donesetup

    def pensize(self, newsize) :
        self.pen_size = newsize
    #end pensize

    def pencolour(self, *args) :
        self.pen_colour = Colour.from_rgba(parse_colour_args(args))
    #end pencolour

    def penup(self) :
        self.finishpath()
        self.drawing = False
    #end penup

    def pendown(self) :
        self.drawing = True
    #end pendown

    def setpos(self, newpos) :
        self.startpath()
        (self.g.move_to, self.g.line_to)[self.drawing](newpos)
    #end setpos

    def close(self) :
        self.g.close_path()
    #end close

    def startpath(self) :
        if not self.inpath :
            (self.g
                .new_path()
                .set_source_colour(Colour.from_rgba(self.pen_colour))
                .set_line_width(self.pen_size)
            )
            self.inpath = True
        #end if
    #end startpath

    def finishpath(self) :
        if self.inpath :
            self.g.stroke()
        #end if
        self.inpath = False
    #end finishpath

    def done(self) :
        self.finishpath()
        self.pix.flush()
        self.g = None
        if self.outfilename != None :
            self.pix.write_to_png(self.outfilename)
        else :
            proc_display = subprocess.Popen \
              (
                args = ("display",),
                stdin = subprocess.PIPE
              )
            self.pix.write_to_png_file(proc_display.stdin)
            proc_display.stdin.close()
            proc_display.wait()
        #end if
        self.pix = None
    #end done

#end CairoDrawer

class PostScriptDrawer(Drawer) :
    "outputs PostScript <https://en.wikipedia.org/wiki/PostScript> drawing commands."

    def __init__(self) :
        self.w = sys.stdout.write
        self.out = None
        self.nrpoints = 0
        self.drawing = True
        self.currentpoint = None
    #end __init__

    def opt_scale(self, factor) :
        factor = float(factor)
        self.w("%.3f %.3f scale\n" % (factor, factor))
    #end opt_scale

    def opt_out(self, filename) :
        self.out = open(filename, "w")
        self.w = self.out.write
    #end opt_out

    def setsize(self, newsize) :
        self.w \
          (
                "<<\n"
                "    /ImageBBox 0 0 %(width).3f %(height).3f\n"
                "    /PageSize %(width).3f %(height).3f\n"
                ">> setpagedevice\n"
            %
                {"width" : newsize[0], "height" : newsize[1]}
          )
    #end setsize

    def bgcolour(self, *args) :
        self.w("currentcolor currentcolorspace\n")
        self.w("%.3f %.3f %.3f setrgbcolor\n" % parse_colour_args(args))
        self.w("clippath fill\nsetcolorspace setcolor\n")
    #end bgcolour

    def donesetup(self) :
        self.w \
          (
            "clippath pathbbox\n"
            "exch 4 3 roll sub 2 div\n" # x offset
            "3 1 roll exch sub 2 div\n" # y offset
            "translate\n" # move origin to centre
          )
    #end donesetup

    def pensize(self, newsize) :
        self.finishpath()
        self.w("%.1f setlinewidth\n" % newsize)
    #end pensize

    def pencolour(self, *args) :
        self.finishpath()
        self.w("%.3f %.3f %.3f setrgbcolor\n" % parse_colour_args(args))
    #end pencolour

    def penup(self) :
        self.finishpath()
        self.drawing = False
    #end penup

    def pendown(self) :
        self.drawing = True
    #end pendown

    def setpos(self, newpos) :
        x, y = newpos[0], newpos[1]
        if self.nrpoints == 500 : # actually level 2 and later have no limit?
            self.finishpath()
        #end if
        if self.nrpoints == 0 :
            self.w("newpath\n")
            if self.drawing :
                if self.currentpoint == None :
                    self.w("0 0 moveto\n")
                else :
                    self.w("%.1f %.1f moveto\n" % (self.currentpoint[0], self.currentpoint[1]))
                #end if
            #end if
        #end if
        self.w("%.1f %.1f %s\n" % (x, y, ("moveto", "lineto")[self.drawing]))
        self.nrpoints += 1
        self.currentpoint = Vector(x, y)
    #end setpos

    def close(self) :
        self.w("closepath\n")
    #end close

    def finishpath(self) :
        if self.nrpoints != 0 :
            self.w("stroke\n")
            self.nrpoints = 0
        #end if
    #end finishpath

    def done(self) :
        self.finishpath()
        self.w("showpage\n")
        if self.out != None :
            self.out.flush()
            self.out.close()
        #end if
    #end done

#end PostScriptDrawer

class TurtleDrawer(Drawer) :
    "does the drawing on-screen using the Turtle Graphics" \
    " <https://docs.python.org/3/library/turtle.html> module."

    def __init__(self) :
        import turtle
        self.t = turtle
        for \
            mymethod, theirmethod \
        in \
            (
                ("bgcolour", "bgcolor"),
                ("pensize", None),
                ("pencolour", "pencolor"),
                ("pendown", None),
            ) \
        :
            if theirmethod == None :
                theirmethod = mymethod
            #end if
            setattr(self, mymethod, getattr(self.t, theirmethod))
        #end for
        self.track_heading = False
        self.first_point = None
        self.default_delay = self.t.delay()
        self.t.title("curve")
    #end __init__

    def opt_speed(self, newspeed) :
        self.t.tracer(1, round(self.default_delay / float(newspeed)))
          # setting the first arg to anything other than 1 causes the whole
          # drawing to update instantly, so I change only the second arg
          # for nice speed control
    #end opt_speed

    def opt_hideturtle(self) :
        self.t.hideturtle()
    #end opt_hideturtle

    def opt_trackheading(self) :
        self.track_heading = True
    #end opt_trackheading

    def setsize(self, newsize) :
        w, h = newsize[0], newsize[1]
        self.t.screensize(w, h)
        self.t.setup(width = w, height = h)
    #end setsize

    def donesetup(self) :
        pass
    #end donesetup

    def penup(self) :
        self.t.penup()
        self.first_point = None
    #end penup

    def setpos(self, newpos) :
        if self.first_point == None and self.t.isdown() :
            self.first_point = self.t.position()
        #end if
        if self.track_heading and self.t.isvisible() :
            prevpos = self.t.position()
            self.t.setheading \
              (
                math.degrees(math.atan2(newpos[1] - prevpos[1], newpos[0] - prevpos[0]))
              )
        #end if
        self.t.setpos(newpos)
    #end setpos

    def close(self) :
        if self.first_point != None and self.t.isdown() :
            self.t.setpos(self.first_point)
        #end if
    #end close

    def done(self) :
        self.t.hideturtle()
        self.t.title("curve -- click to close")
        self.t.exitonclick()
    #end done

#end TurtleDrawer

drawtypes = \
    {
        "cairo" : CairoDrawer,
        "postscript" : PostScriptDrawer,
        "turtle" : TurtleDrawer,
    }

def draw_curve(d, c, nr_steps) :
    "d is a Drawer instance, c is a Curve instance, and nr_steps is the" \
    " number of straight-line segments to approximate the curve."
    if hasattr(c, "subcurve_func") :
        subcurve_func = c.subcurve_func
    else :
        subcurve_func = lambda t : 0
    #end if
    last_subcurve = None
    for i in range(nr_steps) :
        if hasattr(c, "nr_steps") :
            subcurve = subcurve_func(i)
        else :
            t = i / nr_steps
            subcurve = subcurve_func(t)
        #end if
        if subcurve != last_subcurve :
            if c.closed and last_subcurve != None :
                d.close()
            #end if
            d.penup()
            last_subcurve = subcurve
        #end if
        if hasattr(c, "nr_steps") :
            d.setpos(c.curve_func(i))
        else :
            d.setpos(c.curve_func(t))
        #end if
        d.pendown() # for subsequent points
    #end for
    if c.closed :
        d.close()
    #end if
#end draw_curve

#+
# Curve classes
#-

class Curve :
    "abstract base class for all the different kinds of geometrical objects" \
    " I know how to draw."
#+
# Subclasses should define the following members:
#
# params
#     a sequence of 3-tuples («paramname», «convertfunc», «description»)
#     where «paramname» is the name of an attribute of the Curve instance,
#     «convertfunc» is a function for converting a string to a suitable value
#     for the attribute, and «description» is a description of the attribute.
#     If an attribute is initialized to some value in the __init__
#     method, then specifying a value for that attribute is optional;
#     otherwise, it is mandatory.
#
# closed
#     whether the curve is closed.
#
# nr_steps
#     optional, indicates the curve is not really a curve, but is made of the
#     specified number of straight-line segments. Typically will be a property
#     that needs to be computed from the given curve parameters.
#
# setup()
#     optional, if present, will be called after setting of all options and before
#     accessing nr_steps and first call to curve_func.
#
# curve_func(t)
#     maps a parameter value t onto a corresponding (x, y) coordinate tuple
#     for defining the curve. If nr_steps is present, t is an integer ∈ [0, nr_steps),
#     otherwise it is a real ∈ [0, 1).
#
# subcurve_func(t)
#     optional, returns an integer which, when it changes from the previous value,
#     indicates that a new subcurve is starting which should not be joined to the
#     previous one. t has the same meaning as for curve_func.
#-

    pass
#end Curve

class LissaCurve(Curve) :
    "draws a Lissajous curve. For background, see <https://en.wikipedia.org/wiki/Lissajous_curve>."

    closed = True
    params = \
        (
            ("x_amp", float, "amplitude of curve along x-axis"),
            ("x_freq", make_parse_int_range(1), "frequency of curve along x-axis"),
            ("x_phase", float, "phase of curve along x-axis"),
            ("y_amp", float, "amplitude of curve along y-axis"),
            ("y_freq", make_parse_int_range(1), "frequency of curve along y-axis"),
            ("y_phase", float, "phase of curve along y-axis"),
        )

    def __init__(self) :
        self.x_phase = 0
        self.y_phase = 0
    #end __init__

    def setup(self) :
        # reduce relative frequencies to lowest terms
        freq_ratio = Fraction(self.x_freq, self.y_freq)
        self.x_freq, self.y_freq = freq_ratio.numerator, freq_ratio.denominator
    #end setup

    def curve_func(self, t) :
        return \
            (
                math.sin((t + self.x_phase) * qah.circle * self.x_freq) * self.x_amp,
                math.sin((t + self.y_phase) * qah.circle * self.y_freq) * self.y_amp,
            )
    #end curve_func

#end LissaCurve

class MaurerCurve(Curve) :
    "draws a Maurer rose curve. For background, see" \
    " Peter M Maurer, “A Rose is a Rose...”" \
    " <http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.97.8141&rep=rep1&type=pdf>."

    closed = True
    params = \
        (
            ("amplitude", float, "amplitude of sine wave"),
            ("delta", make_parse_int_range(1),
                "the number of steps between successive curve points"
                " (the “d” parameter from the Maurer paper)"
            ),
            ("mod", make_parse_int_range(1),
                "modulus (total number of steps around curve)"
                " (the “z” parameter from the Maurer paper)"
            ),
            ("freq", make_parse_int_range(1), "the “n” parameter from the Maurer paper"),
            ("offset", float, "offset of curve, defaults to 0"),
            ("phase", float, "phase angle for rotating curve"),
        )

    def __init__(self) :
        self.offset = 0
        self.phase = 0
    #end __init__

    def setup(self) :
        self.k = gcd(self.delta, self.mod) # number of points per subcurve
    #end setup

    @property
    def nr_steps(self) :
        return \
            self.mod
    #end nr_steps

    def subcurve_func(self, n) :
        return \
            n // self.mod // self.k
    #end subcurve_func

    def curve_func(self, n) :
        subcurve = n // self.mod // self.k
        step = n * self.delta % self.mod
        phi = qah.circle * (step + subcurve) / self.mod
        theta = qah.circle * ((step + subcurve) * self.freq / self.mod + self.phase)
        r = self.offset + math.sin(theta) * self.amplitude
        return \
            (r * math.cos(phi), r * math.sin(phi))
    #end curve_func

#end MaurerCurve

class RoseCurve(Curve) :
    "draws a rose curve. For background, see <https://en.wikipedia.org/wiki/Rose_curve>."

    closed = True
    params = \
        (
            ("amplitude", float, "amplitude of sine wave"),
            ("freq", parse_ratio, "frequency"),
            ("offset", float, "offset of curve, defaults to 0"),
            ("phase", float, "phase angle for rotating curve"),
        )

    def __init__(self) :
        self.offset = 0
        self.phase = 0
    #end __init__

    def curve_func(self, t) :
        # Note that the curve can still be traced twice in some situations, namely where
        # the frequency numerator and denominator are both odd, and the offset is zero.
        # But if the offset is set to nonzero, the two halves no longer overlap.
        phi = qah.circle * t * self.freq.denominator
        theta = qah.circle * (t + self.phase) * self.freq.numerator
        r = self.offset + math.sin(theta) * self.amplitude
        return \
            (r * math.cos(phi), r * math.sin(phi))
    #end curve_func

#end RoseCurve

class SpirolateralCurve(Curve) :
    "draws a spirolateral curve. For background, see" \
    " <http://www.mi.sanu.ac.rs/vismath/krawczyk/spdesc00.htm>."

    # closed attribute depends on curve
    params = \
        (
            ("n", make_parse_int_range(1), "lengths of lines are 1 * step ..  n * step"),
            ("phase", float, "phase angle for rotating curve"),
            ("reversed", parse_natural_set, "steps for which to reverse direction"),
            ("start", parse_ratio, "starting angle in rational fractions of a circle"),
            ("step", float, "lengths of lines are integer multiples of this"),
            ("angle", parse_ratio, "angle between segments as a rational fraction of a circle."),
        )

    def __init__(self) :
        self.phase = 0
        self.reversed = set()
        self.start = 0
        self.closed = True # to begin with
    #end __init__

    def setup(self) :
        start_point = Vector(0, 0)
        seg_points = []
        point = start_point
        dirn = self.start
        for i in range(1, self.n + 1) :
            seg_points.append(point)
            point += Vector(i * self.step, 0).rotate(float(dirn) * qah.circle)
            dirn += (Fraction(1, 2) - self.angle) * (1, -1)[i in self.reversed]
        #end for
        end_point = point
        point_delta = end_point - start_point
        seg_rotate = dirn - self.start
        # at this point, seg_rotate is the fraction of a circle turned through by one curve segment.
        point = start_point
        origin = Vector(0, 0)
        dirn = Fraction(0, 1)
        for i in range(seg_rotate.denominator) :
            origin += point
            point += point_delta.rotate(float(dirn) * qah.circle)
            dirn += seg_rotate
        #end for
        self.closed = abs(start_point - point) < 1e-7
        if not self.closed :
            seg_points.append(point)
        #end if
        origin /= seg_rotate.denominator
        self.seg_points = list(point - origin for point in seg_points)
        self.seg_rotate = seg_rotate
    #end setup

    @property
    def nr_steps(self) :
        return \
            self.seg_rotate.denominator * len(self.seg_points)
    #end nr_steps

    def curve_func(self, step) :
        return \
            self.seg_points[step % len(self.seg_points)].rotate \
              (
                    float(self.seg_rotate) * (step // len(self.seg_points)) * qah.circle
                +
                    self.phase
              )
    #end curve_func

#end SpirolateralCurve

class TrochCurve(Curve) :
    "draws a hypotrochoid or epitrochoid curve. For background, see" \
    " <https://en.wikipedia.org/wiki/Spirograph>, <https://en.wikipedia.org/wiki/Epitrochoid>" \
    " and <https://en.wikipedia.org/wiki/Hypotrochoid>."

    closed = True
    params = \
        (
            ("ring", int, "radius of stationary ring"),
            ("wheel", int, "radius of moving wheel"),
            ("scale", float,
                "scale factor applied to ring and wheel radius, allowing"
                " small integers to be used for above to make their relative"
                " factors more obvious"
            ),
            ("frac", float, "fraction of wheel radius from centre for location of curve point"),
            ("phase", float, "phase angle for rotating entire curve"),
        )

    def __init__(self) :
        self.scale = 1
        self.phase = 0
    #end __init__

    def setup(self) :
        ratio = Fraction(self.ring, self.wheel)
        self.nr_cycles = ratio.denominator # to produce one complete traversal of curve
    #end setup

    def curve_func(self, t) :
        theta_ring = 360 * self.nr_cycles * t
        theta_wheel = theta_ring * (self.ring / self.wheel + 1)
        wheel_pos = Vector((self.ring + self.wheel) * self.scale, 0).rotate((theta_ring + self.phase) * qah.deg)
        curve_pos = wheel_pos + Vector(self.wheel * self.scale * self.frac, 0).rotate(theta_wheel * qah.deg)
        return \
            tuple(curve_pos)
    #end curve_func

#end TrochCurve

class WhirlCurve(Curve) :
    "draws a whirl. For background, see <http://mathworld.wolfram.com/Whirl.html>." \
    " Also, nested polygons <http://mathworld.wolfram.com/NestedPolygon.html> are" \
    " a variation on this."

    closed = True
    params = \
        (
            ("nr_polys", make_parse_int_range(1), "how many polygons to draw"),
            ("nr_sides", make_parse_int_range(3), "how many sides per polygon"),
            ("phase", float, "phase angle for rotating entire curve"),
            ("poly_shrink", float, "shrink factor for each successive step, [-1, +1]"),
            ("radius", float, "radius of the curve"),
        )

    def __init__(self) :
        self.phase = 0
    #end __init__

    @property
    def nr_steps(self) :
        return \
            self.nr_polys * self.nr_sides
    #end nr_steps

    def subcurve_func(self, step) :
        return \
            step // self.nr_sides
    #end subcurve_func

    def curve_func(self, step) :
        subcurve_idx = step // self.nr_sides
        side_idx = step % self.nr_sides
        # distance from centre of polygons to centre of one side of outermost polygon =
        #    self.radius * math.sin(corner_angle / 2)
        # therefore, distance from centre of polygons to corner of next-inner polygon =
        #    self.radius * step_scale_factor
        # such that
        #     step_scale_factor = math.sin(corner_angle / 2) / math.cos(math.pi / self.nr_sides - abs(step_rotate))
        # where
        #     step_rotate = math.pi / self.nr_sides * self.poly_shrink
        step_rotate = math.pi / self.nr_sides * self.poly_shrink
        corner_angle = (0.5 - 1 / self.nr_sides) * qah.circle
        step_scale_factor = math.sin(corner_angle / 2) / math.cos(math.pi / self.nr_sides - abs(step_rotate))
        scale = step_scale_factor ** subcurve_idx
        rotate = \
            (
                self.phase
            +
                step_rotate * subcurve_idx
            +
                qah.circle / self.nr_sides * side_idx
            )
        return \
            Vector(self.radius * scale, 0).rotate(rotate)
    #end curve_func

#end WhirlCurve

curvetypes = \
    {
        "lissa" : LissaCurve,
        "maurer" : MaurerCurve,
        "rose" : RoseCurve,
        "spirolat" : SpirolateralCurve,
        "troch" : TrochCurve,
        "whirl" : WhirlCurve,
    }

#+
# Mainline
#-

(opts, args) = getopt.getopt \
  (
    sys.argv[1:],
    "",
    ["bgcolor=", "bgcolour=", "draw=", "nrsteps=", "size="]
  )
drawtype = None
bgcolour = None
drawsize = None
default_nrsteps = None
for keyword, value in opts :
    if keyword == "--bgcolor" or keyword == "--bgcolour" :
        bgcolour = parse_colour_opt(value)
    elif keyword == "--draw" :
        items = value.split(":")
        if drawtype != None :
            raise getopt.GetoptError("only one --draw opt allowed")
        #end if
        drawtype = drawtypes[items[0]]
        d = drawtype()
        for item in items[1:] :
            value = item.split("=", 1)
            if len(value) == 2 :
                keyword, optargs = value[0], (value[1],)
            else :
                keyword, optargs = value[0], ()
            #end if
            opt_method = "opt_" + keyword
            assert hasattr(d, opt_method), "%s drawtype has no option %s" % (items[0], keyword)
            getattr(d, opt_method)(*optargs)
        #end for
    elif keyword == "--nrsteps" :
        default_nrsteps = int(value)
    elif keyword == "--size" :
        items = value.split(":", 1)
        assert len(items) == 2, "--size must be h:w"
        drawsize = Vector(int(items[0]), int(items[1]))
    #end if
#end for
if len(args) >= 1 and args[0] == "help" :
    if len(args) > 1 :
        first = True
        for curvetype in args[1:] :
            if not first :
                sys.stdout.write("\n")
            #end if
            if curvetype in curvetypes :
                curve = curvetypes[curvetype]
                sys.stdout.write("%s: %s\n" % (curvetype, curve.__doc__))
                sys.stdout.write("parameters:\n")
                for param in curve.params :
                    sys.stdout.write("  %s -- %s\n" % (param[0], param[2]))
                #end for
                first = False
            else :
                sys.stdout.write("no such curve type “%s”\n" % curvetype)
            #end if
        #end for
    else :
        sys.stdout.write \
          (
                "Valid curve types are %s. Type\n"
                "\n"
                "    %s help «curvetype»\n"
                "\n"
                "for help on «curvetype».\n"
            %
                (", ".join(sorted(curvetypes)), sys.argv[0])
          )
    #end if
elif len(args) == 0 :
    sys.stdout.write \
      (
            "Try\n"
            "\n"
            "    %s help\n"
            "\n"
            "for help.\n"
        %
            sys.argv[0]
      )
else :
    if drawtype == None :
        raise getopt.GetoptError("missing --draw opt")
    #end if
    if drawsize != None :
        d.setsize(drawsize)
    #end if
    if bgcolour != None :
        d.bgcolour(bgcolour)
    #end if
    d.donesetup()
    curvename = None
    for arg in args :
        # note pen_colour and pen_size default to settings for previous curve, if any
        pen_colour = None
        pen_size = None
        items = arg.split(":")
        if len(items) == 0 :
            raise getopt.GetoptError("missing curve type")
        #end if
        if curvename == None or len(items[0]) != 0 :
            curvename = items[0]
            curve = curvetypes[curvename]()
            curve_params = dict((param[0], param[1]) for param in curve.params)
            nr_steps = default_nrsteps
        #end if
        items = items[1:]
        for item in items :
            parts = item.split("=", 1)
            assert len(parts) == 2, "param must have form keyword=value: %s" % item
            keyword, value = parts
            if keyword == "nrsteps" :
                assert not hasattr(curve, "nr_steps"), "curve provides its own value for nrsteps"
                nr_steps = int(value)
            elif keyword == "pencolor" or keyword == "pencolour" :
                pen_colour = parse_colour_opt(value)
            elif keyword == "pensize" :
                pen_size = float(value)
            elif keyword in curve_params :
                fail = None
                try :
                    val = curve_params[keyword](value)
                except ValueError as exc :
                    fail = ValueError("bad value for %s parameter: %s" % (keyword, str(exc)))
                #end try
                if fail != None :
                    raise fail
                #end if
                setattr(curve, keyword, val)
            else :
                raise getopt.GetoptError("no such curve param %s" % keyword)
            #end if
        #end for
        missing = set \
          (
            keyword for keyword in curve_params if not hasattr(curve, keyword)
          )
        if len(missing) != 0 :
            raise getopt.GetoptError \
              (
                "missing parameters for %s curve: %s" % (curvename, ", ".join(tuple(missing)))
              )
        #end if
        if hasattr(curve, "setup") :
            curve.setup()
        #end if
        if hasattr(curve, "nr_steps") :
            nr_steps = curve.nr_steps
        #end if
        if nr_steps == None :
            raise getopt.GetoptError("forgot to specify nrsteps")
        #end if
        if pen_colour != None :
            d.pencolour(pen_colour)
        #end if
        if pen_size != None :
            d.pensize(pen_size)
        #end if
        draw_curve(d, curve, nr_steps)
    #end for
    d.done()
#end if
