summaryrefslogtreecommitdiff
path: root/lib/extensions/convert_to_satin.py
blob: 8af45b3b02df45fe0ea36d51c6c3a8a7a8c15dae (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import inkex
from shapely import geometry as shgeo
from itertools import chain
import numpy
from numpy import diff, sign, setdiff1d
from scipy.signal import argrelmin
import math

from .base import InkstitchExtension
from ..svg.tags import SVG_PATH_TAG
from ..svg import get_correction_transform, PIXELS_PER_MM
from ..elements import Stroke
from ..utils import Point


class ConvertToSatin(InkstitchExtension):
    """Convert a line to a satin column of the same width."""

    def effect(self):
        if not self.get_elements():
            return

        if not self.selected:
            inkex.errormsg(_("Please select at least one line to convert to a satin column."))
            return

        if not all(isinstance(item, Stroke) for item in self.elements):
            # L10N: Convert To Satin extension, user selected one or more objects that were not lines.
            inkex.errormsg(_("Only simple lines may be converted to satin columns."))
            return

        for element in self.elements:
            parent = element.node.getparent()
            index = parent.index(element.node)
            correction_transform = get_correction_transform(element.node)

            for path in element.paths:
                try:
                    rails, rungs = self.path_to_satin(path, element.stroke_width)
                except ValueError:
                    inkex.errormsg(_("Cannot convert %s to a satin column because it intersects itself.  Try breaking it up into multiple paths.") % element.node.get('id'))

                parent.insert(index, self.satin_to_svg_node(rails, rungs, correction_transform))

            parent.remove(element.node)

    def path_to_satin(self, path, stroke_width):
        path = shgeo.LineString(path)

        left_rail = path.parallel_offset(stroke_width / 2.0, 'left')
        right_rail = path.parallel_offset(stroke_width / 2.0, 'right')

        if not isinstance(left_rail, shgeo.LineString) or \
           not isinstance(right_rail, shgeo.LineString):
                # If the parallel offsets come out as anything but a LineString, that means the
                # path intersects itself, when taking its stroke width into consideration.  See
                # the last example for parallel_offset() in the Shapely documentation:
                #   https://shapely.readthedocs.io/en/latest/manual.html#object.parallel_offset
                raise ValueError()

        # for whatever reason, shapely returns a right-side offset's coordinates in reverse
        left_rail = list(left_rail.coords)
        right_rail = list(reversed(right_rail.coords))

        rungs = self.generate_rungs(path, stroke_width)

        return (left_rail, right_rail), rungs

    def get_scores(self, path):
        scores = numpy.zeros(101, numpy.int32)
        path_length = path.length

        prev_point = None
        prev_direction = None
        length_so_far = 0
        for point in path.coords:
            point = Point(*point)

            if prev_point is None:
                prev_point = point
                continue

            direction = (point - prev_point).unit()

            if prev_direction is not None:
                cos_angle_between = prev_direction * direction
                angle = abs(math.degrees(math.acos(cos_angle_between)))

                scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2

            length_so_far += (point - prev_point).length()
            prev_direction = direction
            prev_point = point

        return scores

    def local_minima(self, array):
        # from: https://stackoverflow.com/a/9667121/4249120
        # finds spots where the curvature (second derivative) is > 0
        return (diff(sign(diff(array))) > 0).nonzero()[0] + 1

    def generate_rungs(self, path, stroke_width):
        scores = self.get_scores(path)
        scores = numpy.convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same')
        rung_locations = self.local_minima(scores)
        rung_locations = setdiff1d(rung_locations, [0, 100])

        if len(rung_locations) == 0:
            rung_locations = [50]

        rungs = []
        last_rung_center = None

        for location in rung_locations:
            location = location / 100.0
            rung_center = path.interpolate(location, normalized=True)
            rung_center = Point(rung_center.x, rung_center.y)

            if last_rung_center is not None and \
               (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM:
                    continue
            else:
                last_rung_center = rung_center

            tangent_end = path.interpolate(location + 0.001, normalized=True)
            tangent_end = Point(tangent_end.x, tangent_end.y)
            tangent = (tangent_end - rung_center).unit()
            normal = tangent.rotate_left()
            offset = normal * stroke_width * 0.75

            rung_start = rung_center + offset
            rung_end = rung_center - offset
            rungs.append((rung_start.as_tuple(), rung_end.as_tuple()))

        return rungs


    def satin_to_svg_node(self, rails, rungs, correction_transform):
        d = ""
        for path in chain(rails, rungs):
            d += "M"
            for x, y in path:
                d += "%s,%s " % (x, y)
            d += " "

        return inkex.etree.Element(SVG_PATH_TAG,
            {
                "id": self.uniqueId("path"),
                "style": "stroke:#000000;stroke-width:1px;fill:none",
                "transform": correction_transform,
                "d": d,
                "embroider_satin_column": "true",
            }
        )