summaryrefslogtreecommitdiff
path: root/lib/stitches/guided_fill.py
blob: bc7a3ab23829796375ea67fc427b5e773ee59d09 (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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
from math import atan2, copysign
from random import random

import numpy as np
import shapely.prepared
from networkx import is_empty
from shapely import geometry as shgeo
from shapely.affinity import translate
from shapely.ops import linemerge, nearest_points, unary_union

from ..debug import debug
from ..stitch_plan import Stitch
from ..utils.geometry import Point as InkstitchPoint
from ..utils.geometry import (ensure_geometry_collection,
                              ensure_multi_line_string, reverse_line_string)
from ..utils.threading import check_stop_flag
from .auto_fill import (auto_fill, build_fill_stitch_graph, build_travel_graph,
                        collapse_sequential_outline_edges, find_stitch_path,
                        graph_make_valid, travel)


def guided_fill(shape,
                guideline,
                angle,
                row_spacing,
                num_staggers,
                max_stitch_length,
                running_stitch_length,
                running_stitch_tolerance,
                skip_last,
                starting_point,
                ending_point,
                underpath,
                strategy
                ):
    segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing, num_staggers, max_stitch_length, strategy)
    if not segments:
        return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
                        num_staggers, skip_last, starting_point, ending_point, underpath)

    fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)

    if is_empty(fill_stitch_graph):
        return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
                        num_staggers, skip_last, starting_point, ending_point, underpath)
    graph_make_valid(fill_stitch_graph)

    travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
    path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
    result = path_to_stitches(shape, path, travel_graph, fill_stitch_graph,
                              max_stitch_length, running_stitch_length, running_stitch_tolerance, skip_last,
                              underpath)

    return result


def fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
             num_staggers, skip_last, starting_point, ending_point, underpath):
    # fall back to normal auto-fill with an angle that matches the guideline (sorta)
    guide_start, guide_end = [guideline.coords[0], guideline.coords[-1]]
    angle = atan2(guide_end[1] - guide_start[1], guide_end[0] - guide_start[0]) * -1
    return auto_fill(shape, angle, row_spacing, None, max_stitch_length, running_stitch_length, running_stitch_tolerance,
                     num_staggers, skip_last, starting_point, ending_point, underpath)


def path_to_stitches(shape, path, travel_graph, fill_stitch_graph,
                     stitch_length, running_stitch_length, running_stitch_tolerance, skip_last,
                     underpath):
    path = collapse_sequential_outline_edges(path, fill_stitch_graph)

    stitches = []

    # If the very first stitch is travel, we'll omit it in travel(), so add it here.
    if not path[0].is_segment():
        stitches.append(Stitch(*path[0].nodes[0]))

    for edge in path:
        check_stop_flag()

        if edge.is_segment():
            current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment']
            path_geometry = current_edge['geometry']

            if edge[0] != path_geometry.coords[0]:
                path_geometry = reverse_line_string(path_geometry)

            new_stitches = [Stitch(*point) for point in path_geometry.coords]

            # need to tag stitches

            if skip_last:
                del new_stitches[-1]

            stitches.extend(new_stitches)

            travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
        else:
            stitches.extend(travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tolerance, skip_last, underpath))

    return stitches


def extend_line(line, shape):
    (minx, miny, maxx, maxy) = shape.bounds

    upper_left = InkstitchPoint(minx, miny)
    lower_right = InkstitchPoint(maxx, maxy)
    length = (upper_left - lower_right).length()

    start_point = InkstitchPoint.from_tuple(line.coords[0])
    end_point = InkstitchPoint.from_tuple(line.coords[-1])
    direction = (end_point - start_point).unit()

    new_start_point = start_point - direction * length
    new_end_point = end_point + direction * length

    # without this, we seem especially likely to run into this libgeos bug:
    #   https://github.com/shapely/shapely/issues/820
    new_start_point += InkstitchPoint(random() * 0.01, random() * 0.01)
    new_end_point += InkstitchPoint(random() * 0.01, random() * 0.01)

    return shgeo.LineString((new_start_point, *line.coords, new_end_point))


def repair_multiple_parallel_offset_curves(multi_line):
    lines = ensure_multi_line_string(linemerge(multi_line))
    longest_line = max(lines.geoms, key=lambda line: line.length)

    # need simplify to avoid doubled points caused by linemerge
    return longest_line.simplify(0.01, False)


def repair_non_simple_line(line):
    repaired = unary_union(line)
    counter = 0
    # Do several iterations since we might have several concatenated selfcrossings
    while repaired.geom_type != 'LineString' and counter < 4:
        line_segments = []
        for line_seg in repaired.geoms:
            if not line_seg.is_ring:
                line_segments.append(line_seg)

        repaired = unary_union(linemerge(line_segments))
        counter += 1
    if repaired.geom_type != 'LineString':
        # They gave us a line with complicated self-intersections.  Use a fallback.
        return shgeo.LineString((line.coords[0], line.coords[-1]))
    else:
        return repaired


def take_only_line_strings(thing):
    things = ensure_geometry_collection(thing)
    line_strings = [line for line in things.geoms if isinstance(line, shgeo.LineString)]

    return shgeo.MultiLineString(line_strings)


def apply_stitches(line, max_stitch_length, num_staggers, row_spacing, row_num, threshold=None) -> shgeo.LineString:
    if num_staggers == 0:
        num_staggers = 1  # sanity check to avoid division by zero.
    start = ((row_num / num_staggers) % 1) * max_stitch_length
    projections = np.arange(start, line.length, max_stitch_length)
    points = np.array([line.interpolate(projection).coords[0] for projection in projections])

    if len(points) < 2:
        return line

    stitched_line = shgeo.LineString(points)

    # stitched_line may round corners, which will look terrible.  This finds the
    # corners.
    if not threshold:
        threshold = row_spacing / 2.0
    simplified_line = line.simplify(threshold, False)
    simplified_points = [shgeo.Point(x, y) for x, y in simplified_line.coords]

    extra_points = []
    extra_point_projections = []
    for point in simplified_points:
        if point.distance(stitched_line) > threshold:
            extra_points.append(point.coords[0])
            extra_point_projections.append(line.project(point))

    # Now we need to insert the new points into their correct spots in the line.
    indices = np.searchsorted(projections, extra_point_projections)
    if len(indices) > 0:
        points = np.insert(points, indices, extra_points, axis=0)

    return shgeo.LineString(points)


def prepare_guide_line(line, shape):
    if line.geom_type != 'LineString' or not line.is_simple:
        line = repair_non_simple_line(line)

    if line.is_ring:
        # If they pass us a ring, break it to avoid dividing by zero when
        # calculating a unit vector from start to end.
        line = shgeo.LineString(line.coords[:-2])

    # extend the end points away from each other
    line = extend_line(line, shape)

    return line


def clean_offset_line(offset_line):
    offset_line = take_only_line_strings(offset_line)

    if isinstance(offset_line, shgeo.MultiLineString):
        offset_line = repair_multiple_parallel_offset_curves(offset_line)

    if not offset_line.is_simple:
        offset_line = repair_non_simple_line(offset_line)

    return offset_line


def _get_start_row(line, shape, row_spacing, line_direction):
    if line.intersects(shape):
        return 0

    point1, point2 = nearest_points(line, shape.centroid)
    distance = point1.distance(point2)
    row = int(distance / row_spacing)

    # This flips the sign of the starting row if the shape is on the other side
    # of the guide line
    shape_direction = InkstitchPoint.from_shapely_point(point2) - InkstitchPoint.from_shapely_point(point1)
    return copysign(row, shape_direction * line_direction)


def intersect_region_with_grating_guideline(shape, line, row_spacing, num_staggers, max_stitch_length, strategy):
    line = prepare_guide_line(line, shape)

    debug.log_line_string(shape.exterior, "guided fill shape")

    translate_direction = InkstitchPoint(*line.coords[-1]) - InkstitchPoint(*line.coords[0])
    translate_direction = translate_direction.unit().rotate_left()

    shape_envelope = shapely.prepared.prep(shape.convex_hull)

    start_row = _get_start_row(line, shape, row_spacing, translate_direction)
    row = start_row
    direction = 1
    offset_line = None
    rows = []
    while True:
        check_stop_flag()

        if strategy == 0:
            translate_amount = translate_direction * row * row_spacing
            offset_line = translate(line, xoff=translate_amount.x, yoff=translate_amount.y)
        elif strategy == 1:
            offset = row * row_spacing
            if offset == 0:
                # this is needed for macOS builds
                offset = 0.0001
            offset_line = line.parallel_offset(offset, 'left', join_style=shgeo.JOIN_STYLE.round)

        offset_line = clean_offset_line(offset_line)

        if strategy == 1 and direction == -1:
            # negative parallel offsets are reversed, so we need to compensate
            offset_line = reverse_line_string(offset_line)

        debug.log_line_string(offset_line, f"offset {row}")

        stitched_line = apply_stitches(offset_line, max_stitch_length, num_staggers, row_spacing, row)
        intersection = shape.intersection(stitched_line)

        if not intersection.is_empty and shape_envelope.intersects(stitched_line):
            for segment in take_only_line_strings(intersection).geoms:
                rows.append(segment.coords[:])
            row += direction
        else:
            if direction == 1:
                direction = -1
                row = start_row - 1
            else:
                break

    return rows