#!/usr/bin/env python3

# eggbot_sineandlace.py
#
# Generate sinusoidal and "lace" curves.  The curves are described in SVG
# along with the data necessary to regenerate them.  The same data can be
# used to generate new curves which are bounded by a pair of previously
# generated curves.

# Written by Daniel C. Newman for the Eggbot Project
# dan newman @ mtbaldy us
# Last updated 28 November 2010
# 15 October 2010

# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

from math import pi, cos, sin

from lxml import etree
import inkex
from inkex.paths import Path

VERSION = 1


def parseDesc(str):
    """
    Create a dictionary from string description
    """

    if str is None:
        return {}
    else:
        return dict([tok.split(':') for tok in str.split(';') if len(tok)])


def formatDesc(d):
    """
    Format an inline name1:value1;name2:value2;... style attribute value
    from a dictionary
    """

    return ';'.join([atr + ':' + str(val) for atr, val in d.iteritems()])


def drawSine(cycles=8, rn=0, rm=0, nPoints=50, offset=None,
             height=200, width=3200, rescale=0.98, bound1='', bound2='', fun='sine', spline=True):
    """
    cycles
    Number of periods to plot within the rectangle of width 'width'

    rn, rm
    Start the function (on the left edge) at the value x = 2 * pi * rn / rm.
    When rm = 0, function is started at x = 0.

    nPoints
    The number of points to sample the function at.  Since the function is
    approximated using Bezier cubic splines, this isn't the number of points
    to plot.

    offset
    (x, y) coordinate of the lower left corner of the bounding rectangle
    in which to plot the function.

    height, width
    The height and width of the rectangle in which to plot the function.
    Ignored when bounding functions, bound1 and bound2, are supplied.

    rescale
    Multiplicative Y-scaling factor by which to rescale the plotted function
    so that it does not fully reach the vertical limits of its bounds.  This
    aids in Eggbot plots by preventing lines from touching and overlapping.

    bound1, bound2
    Descriptions of upper and lower bounding functions by which to limit the
    vertical range of the function to be plotted.

    fun
    May be either 'sine' or 'lace'.
    """

    """
    A complicated way of plotting y = sin(x)

    Complicated because we wish to generate the sine wave using a
    parametric representation.  For plotting a single sine wave in
    Cartesian coordinates, this is overkill.  However, it's useful
    for when we want to compress and expand the amplitude of the
    sine wave in accord with upper and lower boundaries which themselves
    are functions.  By parameterizing everything in sight with the
    same parameter s and restricting s to the range [0, 1], our life
    is made much easier.
    """
    if offset is None:
        offset = [0, 0]

    bounded = False

    if bound1 and bound2:

        func = parseDesc(bound1)
        if len(func) == 0:
            return None, None
        m1 = int(func['rm'])
        if m1 == 0:
            x_min1 = 0.0
        else:
            x_min1 = 2 * pi * float(func['rn']) / float(m1)
        x_max1 = x_min1 + 2 * pi * float(func['cycles'])
        y_min1 = -1.0
        y_max1 = 1.0
        y_scale1 = float(func['height']) / (y_max1 - y_min1)
        y_offset1 = float(func['y'])
        Y1s = lambda s: y_offset1 - y_scale1 * sin(x_min1 + (x_max1 - x_min1) * s)

        func = parseDesc(bound2)
        if len(func) == 0:
            return None, None
        m2 = int(func['rm'])
        if m2 == 0:
            x_min2 = 0.0
        else:
            x_min2 = 2 * pi * float(func['rn']) / float(m2)
        x_max2 = x_min2 + 2 * pi * float(func['cycles'])
        y_min2 = -1.0
        y_max2 = 1.0
        y_scale2 = float(func['height']) / (y_max2 - y_min2)
        y_offset2 = float(func['y'])
        Y2s = lambda s: y_offset2 - y_scale2 * sin(x_min2 + (x_max2 - x_min2) * s)

        bounded = True

    rescale = float(rescale)
    x_offset = float(offset[0])
    y_offset = float(offset[1])

    # Each cycle is 2pi
    r = 2 * pi * float(cycles)
    if (int(rm) == 0) or (int(rn) == 0):
        x_min = 0.0
    else:
        x_min = 2 * pi * float(rn) / float(rm)
    x_max = x_min + r
    x_scale = float(width) / r  # width / ( x_max - x_min )

    y_min = -1.0
    y_max = 1.0
    y_scale = float(height) / (y_max - y_min)

    # Our parametric equations which map the results to our drawing window
    # Note the "-y_scale" that's because in SVG, the y-axis runs "backwards"
    if not fun:
        fun = 'sine'
    fun = fun.lower()
    if fun == 'sine':
        Xs = lambda s: x_offset + x_scale * (x_max - x_min) * s
        Ys = lambda s: y_offset - y_scale * sin(x_min + (x_max - x_min) * s)
        dYdXs = lambda s: -y_scale * cos(x_min + (x_max - x_min) * s) / x_scale
    elif fun == 'lace':
        Xs = lambda s: x_offset + x_scale * ((x_max - x_min) * s + 2 * sin(2 * s * (x_max - x_min) + pi))
        dXs = lambda s: x_scale * (x_max - x_min) * (1.0 + 4.0 * cos(2 * s * (x_max - x_min) + pi))
        Ys = lambda s: y_offset - y_scale * sin(x_min + (x_max - x_min) * s)
        dYs = lambda s: -y_scale * cos(x_min + (x_max - x_min) * s) * (x_max - x_min)
        dYdXs = lambda s: dYs(s) / dXs(s)
    else:
        inkex.errormsg('Unknown function {0} specified'.format(fun))
        return

    # Derivatives: remember the chain rule....
    # dXs = lambda s: x_scale * ( x_max - x_min )
    # dYs = lambda s: -y_scale * cos( x_min + ( x_max - x_min ) * s ) * ( x_max - x_min )

    # x_third is 1/3 the step size
    nPoints = int(nPoints)

    # x_third is 1/3 the step size; note that Xs(1) - Xs(0) = x_scale * ( x_max - x_min )
    x_third = (Xs(1.0) - Xs(0.0)) / float(3 * (nPoints - 1))

    if bounded:
        y_upper = Y2s(0.0)
        y_lower = Y1s(0.0)
        y_offset = 0.5 * (y_upper + y_lower)
        y_upper = y_offset + rescale * (y_upper - y_offset)
        y_lower = y_offset + rescale * (y_lower - y_offset)
        y_scale = (y_upper - y_lower) / (y_max - y_min)

    x1 = Xs(0.0)
    y1 = Ys(0.0)
    dx1 = 1.0
    dy1 = dYdXs(0.0)

    # Starting point in the path is ( x, sin(x) )
    path_data = []
    path_data.append(['M', [x1, y1]])

    for i in range(1, nPoints):

        s = float(i) / float(nPoints - 1)
        if bounded:
            y_upper = Y2s(s)
            y_lower = Y1s(s)
            y_offset = 0.5 * (y_upper + y_lower)
            y_upper = y_offset + rescale * (y_upper - y_offset)
            y_lower = y_offset + rescale * (y_lower - y_offset)
            y_scale = (y_upper - y_lower) / (y_max - y_min)

        x2 = Xs(s)
        y2 = Ys(s)
        dx2 = 1.0
        dy2 = dYdXs(s)
        if dy2 > 10.0:
            dy2 = 10.0
        elif dy2 < -10.0:
            dy2 = -10.0

        # Add another segment to the plot
        if spline:
            path_data.append(['C',
                             [x1 + (dx1 * x_third),
                              y1 + (dy1 * x_third),
                              x2 - (dx2 * x_third),
                              y2 - (dy2 * x_third),
                              x2, y2]])
        else:
            path_data.append(['L', [x1, y1]])
            path_data.append(['L', [x2, y2]])
        x1 = x2
        y1 = y2
        dx1 = dx2
        dy1 = dy2

    path_desc = \
        'version:{0:d};style:linear;function:sin(x);'.format(VERSION) + \
        'cycles:{0:f};rn:{1:d};rm:{2:d};points:{3:d};'.format(cycles, rn, rm, nPoints) + \
        'width:{0:d};height:{1:d};x:{2:d};y:{3:d}'.format(width, height, offset[0], offset[1])

    return path_data, path_desc


class SineAndLace(inkex.EffectExtension):
    nsURI = 'http://sample.com/ns'
    nsPrefix = 'doof'

    def add_arguments(self, pars):
        pars.add_argument("--tab", help="The active tab when Apply was pressed")
        pars.add_argument('--fCycles', type=float, default=10.0, help='Number of cycles (periods)')
        pars.add_argument('--nrN', type=int, default=0, help='Start x at 2 * pi * n / m')
        pars.add_argument('--nrM', type=int, default=0,  help='Start x at 2 * pi * n / m')
        pars.add_argument('--fRecess', type=float, default=2.0, help='Recede from envelope by factor')
        pars.add_argument("--nSamples", type=int, default=50.0, help="Number of points to sample")
        pars.add_argument("--nWidth", type=int, default=3200, help="Width in pixels")
        pars.add_argument("--nHeight", type=int, default=100, help="Height in pixels")
        pars.add_argument("--nOffsetX", type=int, default=0, help="Starting x coordinate (pixels)")
        pars.add_argument("--nOffsetY", type=int, default=400, help="Starting y coordinate (pixels)")
        pars.add_argument('--bLace', type=inkex.Boolean, default=False, help='Lace')
        pars.add_argument('--bSpline', type=inkex.Boolean, default=True, help='Spline')

        self.recess = 0.95

    def effect(self):

        inkex.NSS[self.nsPrefix] = self.nsURI

        if self.options.bLace:
            func = 'lace'
        else:
            func = 'sine'

        f_recess = 1.0
        if self.options.fRecess > 0.0:
            f_recess = 1.0 - self.options.fRecess / 100.0
            if f_recess <= 0.0:
                f_recess = 0.0

        if self.options.ids:
            if len(self.options.ids) == 2:
                desc1 = self.selected[self.options.ids[0]].get(inkex.addNS('desc', self.nsPrefix))
                desc2 = self.selected[self.options.ids[1]].get(inkex.addNS('desc', self.nsPrefix))
                if (not desc1) or (not desc2):
                    inkex.errormsg('Selected objects do not smell right')
                    return
                path_data, path_desc = drawSine(self.options.fCycles,
                                                self.options.nrN,
                                                self.options.nrM,
                                                self.options.nSamples,
                                                [self.options.nOffsetX, self.options.nOffsetY],
                                                self.options.nHeight,
                                                self.options.nWidth,
                                                f_recess,
                                                desc1, desc2, func, self.options.bSpline)
            else:
                inkex.errormsg('Exactly two objects must be selected')
                return
        else:
            self.document.getroot().set(inkex.addNS(self.nsPrefix, 'xmlns'), self.nsURI)

            path_data, path_desc = drawSine(self.options.fCycles,
                                            self.options.nrN,
                                            self.options.nrM,
                                            self.options.nSamples,
                                            [self.options.nOffsetX, self.options.nOffsetY],
                                            self.options.nHeight,
                                            self.options.nWidth,
                                            f_recess,
                                            '',
                                            '',
                                            func,
                                            self.options.bSpline)

        style = {'stroke': 'black', 'stroke-width': '1', 'fill': 'none'}
        path_attrs = {
            'style': str(inkex.Style(style)),
            'd': str(Path(path_data)),
            inkex.addNS('desc', self.nsPrefix): path_desc}
        newpath = etree.SubElement(self.document.getroot(),
                                         inkex.addNS('path', 'svg'), path_attrs, nsmap=inkex.NSS)


if __name__ == '__main__':
    SineAndLace().run()