Skip to content

Commit 4dc0be3

Browse files
authored
Ripple: allow clipping (#4082)
1 parent b82cc27 commit 4dc0be3

File tree

3 files changed

+221
-33
lines changed

3 files changed

+221
-33
lines changed

lib/elements/stroke.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -464,17 +464,28 @@ def random_seed(self) -> str:
464464
# letting each instance without a specified seed get a different default.
465465
return seed
466466

467-
@property
468-
@cache
469-
def is_closed(self):
467+
def _is_closed(self, clipped=True):
470468
# returns true if the outline of a single line stroke is a closed shape
471469
# (with a small tolerance)
472-
lines = self.as_multi_line_string().geoms
470+
if clipped:
471+
lines = self.as_multi_line_string().geoms
472+
else:
473+
lines = self.as_multi_line_string(False).geoms
473474
if len(lines) == 1:
474475
coords = lines[0].coords
475476
return Point(*coords[0]).distance(Point(*coords[-1])) < 0.05
476477
return False
477478

479+
@property
480+
@cache
481+
def is_closed_unclipped(self):
482+
return self._is_closed(False)
483+
484+
@property
485+
@cache
486+
def is_closed_clipped(self):
487+
return self._is_closed()
488+
478489
@property
479490
def paths(self):
480491
return self._get_paths()
@@ -507,9 +518,18 @@ def _get_paths(self, clipped=True):
507518
def shape(self):
508519
return self.as_multi_line_string().convex_hull
509520

521+
@property
522+
@cache
523+
def unclipped_shape(self):
524+
return self.as_multi_line_string(False).convex_hull
525+
510526
@cache
511-
def as_multi_line_string(self):
512-
line_strings = [shgeo.LineString(path) for path in self.paths if len(path) > 1]
527+
def as_multi_line_string(self, clipped=True):
528+
if clipped:
529+
paths = self.paths
530+
else:
531+
paths = self.unclipped_paths
532+
line_strings = [shgeo.LineString(path) for path in paths if len(path) > 1]
513533
return shgeo.MultiLineString(line_strings)
514534

515535
@property
@@ -544,7 +564,7 @@ def get_ripple_target(self):
544564
if command:
545565
return shgeo.Point(*command.target_point)
546566
else:
547-
return self.shape.centroid
567+
return self.unclipped_shape.centroid
548568

549569
def simple_satin(self, path, zigzag_spacing, stroke_width, pull_compensation):
550570
"zig-zag along the path at the specified spacing and wdith"
@@ -596,26 +616,31 @@ def apply_max_stitch_length(self, path):
596616
return max_len_path
597617

598618
def ripple_stitch(self):
599-
return StitchGroup(
600-
color=self.color,
601-
tags=["ripple_stitch"],
602-
stitches=ripple_stitch(self),
603-
lock_stitches=self.lock_stitches,
604-
force_lock_stitches=self.force_lock_stitches)
619+
ripple_stitches = ripple_stitch(self)
620+
stitch_groups = []
621+
for stitches in ripple_stitches:
622+
stitch_group = StitchGroup(
623+
color=self.color,
624+
tags=["ripple_stitch"],
625+
stitches=stitches,
626+
lock_stitches=self.lock_stitches,
627+
force_lock_stitches=self.force_lock_stitches
628+
)
629+
stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches)
630+
stitch_groups.append(stitch_group)
631+
return stitch_groups
605632

606633
def do_bean_repeats(self, stitches):
607-
return bean_stitch(stitches, self.bean_stitch_repeats)
634+
if any(self.bean_stitch_repeats):
635+
return bean_stitch(stitches, self.bean_stitch_repeats)
636+
return stitches
608637

609638
def to_stitch_groups(self, last_stitch_group, next_element=None): # noqa: C901
610639
stitch_groups = []
611640

612641
# ripple stitch
613642
if self.stroke_method == 'ripple_stitch':
614-
stitch_group = self.ripple_stitch()
615-
if stitch_group:
616-
if any(self.bean_stitch_repeats):
617-
stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches)
618-
stitch_groups.append(stitch_group)
643+
stitch_groups.extend(self.ripple_stitch())
619644
else:
620645
for path in self.paths:
621646
path = [Point(x, y) for x, y in path]
@@ -636,8 +661,7 @@ def to_stitch_groups(self, last_stitch_group, next_element=None): # noqa: C901
636661
force_lock_stitches=self.force_lock_stitches
637662
)
638663
# apply bean stitch settings
639-
if any(self.bean_stitch_repeats):
640-
stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches)
664+
stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches)
641665
# simple satin
642666
elif self.stroke_method == 'zigzag_stitch':
643667
stitch_group = self.simple_satin(path, self.zigzag_spacing, self.stroke_width, self.pull_compensation)

lib/stitches/auto_fill.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ def collapse_sequential_outline_edges(path, graph):
878878
return new_path
879879

880880

881-
def travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tolerance, skip_last, underpath):
881+
def travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tolerance, skip_last, underpath, clamp=True):
882882
"""Create stitches to get from one point on an outline of the shape to another."""
883883

884884
start, end = edge
@@ -892,7 +892,7 @@ def travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tole
892892
path = smooth_path(path, 2)
893893
else:
894894
path = [InkstitchPoint.from_tuple(point) for point in path]
895-
if len(path) > 1:
895+
if len(path) > 1 and clamp:
896896
path = clamp_path_to_polygon(path, shape)
897897
elif not path:
898898
# This may happen on very small shapes.

lib/stitches/ripple_stitch.py

Lines changed: 174 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
from math import atan2, ceil
33

44
import numpy as np
5+
from networkx import connected_components, is_empty
56
from shapely import prepare
67
from shapely.affinity import rotate, scale, translate
78
from shapely.geometry import LineString, Point, Polygon
89
from shapely.ops import substring
910

1011
from ..elements import SatinColumn
12+
from ..stitch_plan import Stitch
1113
from ..utils import Point as InkstitchPoint
1214
from ..utils import prng
13-
from ..utils.geometry import line_string_to_point_list
15+
from ..utils.geometry import (ensure_multi_line_string,
16+
line_string_to_point_list, reverse_line_string)
1417
from ..utils.threading import check_stop_flag
18+
from .auto_fill import (build_fill_stitch_graph, build_travel_graph,
19+
collapse_sequential_outline_edges, find_stitch_path,
20+
graph_make_valid, travel)
1521
from .guided_fill import apply_stitches
1622
from .running_stitch import even_running_stitch, running_stitch
1723

@@ -26,10 +32,11 @@ def ripple_stitch(stroke):
2632
If more sublines are present interpolation will take place between the first two.
2733
'''
2834

29-
if stroke.as_multi_line_string().length < 0.1:
35+
if stroke.as_multi_line_string(False).length < 0.1:
3036
return []
3137

3238
is_linear, helper_lines, grid_lines = _get_helper_lines(stroke)
39+
3340
if not helper_lines:
3441
return []
3542

@@ -43,12 +50,169 @@ def ripple_stitch(stroke):
4350
if stroke.reverse:
4451
stitches.reverse()
4552

46-
if stitches and stroke.grid_size != 0:
47-
stitches.extend(_do_grid(stroke, grid_lines, skip_start, skip_end, is_linear, stitches[-1]))
48-
if stroke.grid_first:
49-
stitches = stitches[::-1]
53+
remove_end_travel = True
54+
if stroke.grid_size != 0:
55+
remove_end_travel = False
56+
stitch_groups = _route_clipped_stitches(stroke, stitches, True, remove_end_travel)
57+
58+
if stroke.repeats > 1:
59+
repeated_groups = []
60+
for stitch_group in stitch_groups:
61+
repeated_groups.append(_repeat_coords(stitch_group, stroke.repeats))
62+
stitch_groups = repeated_groups
63+
64+
if stitch_groups and stroke.grid_size != 0:
65+
grid_sitches = _do_grid(stroke, grid_lines, skip_start, skip_end, is_linear, stitches[-1])
66+
stitch_groups.extend(_route_clipped_stitches(stroke, grid_sitches, False, True, InkstitchPoint(*stitch_groups[-1][-1])))
67+
68+
if stroke.grid_size != 0 and stroke.grid_first:
69+
reversed_groups = []
70+
for stitch_group in stitch_groups:
71+
reversed_groups.append(stitch_group[::-1])
72+
stitch_groups = reversed_groups[::-1]
73+
74+
return stitch_groups
75+
76+
77+
def _route_clipped_stitches(stroke, stitches, remove_start_travel=True, remove_end_travel=True, starting_point=None):
78+
if stroke.clip_shape is None:
79+
return [stitches]
80+
81+
clip = stroke.clip_shape
82+
line_strings = LineString(stitches)
83+
clipped_strings = line_strings.intersection(clip)
84+
clipped_strings = ensure_multi_line_string(clipped_strings)
85+
if not clipped_strings:
86+
return [stitches]
87+
88+
segments = _prepare_line_segments(clip, clipped_strings)
89+
90+
if not starting_point:
91+
starting_point = InkstitchPoint(*segments[0][0])
92+
ending_point = InkstitchPoint(*segments[-1][-1])
93+
routed_segments = _auto_route_segments(clip, stroke, segments, starting_point, ending_point, stitches)
94+
95+
if not routed_segments:
96+
return [stitches]
97+
98+
# multi-part stitch result (clip cuts off one or more parts of the original path)
99+
# TODO: sort multipart ripples with grid
100+
_sort_segments(routed_segments, starting_point, ending_point)
101+
stitches = _segments_to_stitches(segments, routed_segments, starting_point, ending_point, remove_start_travel, remove_end_travel)
102+
103+
return stitches
104+
105+
106+
def _segments_to_stitches(segments, routed_segments, starting_point, ending_point, remove_start_travel, remove_end_travel):
107+
stitches = []
108+
for segment in routed_segments:
109+
current_segment = segment
110+
if remove_start_travel:
111+
for i, stitch in enumerate(current_segment):
112+
if 'auto_fill_travel' not in stitch.tags:
113+
current_segment = current_segment[i:]
114+
break
115+
if remove_end_travel:
116+
current_segment.reverse()
117+
for i, stitch in enumerate(current_segment):
118+
if 'auto_fill_travel' not in stitch.tags:
119+
current_segment = current_segment[i:]
120+
break
121+
current_segment.reverse()
122+
123+
if current_segment:
124+
stitches.append(current_segment)
125+
current_segment = []
126+
return stitches
127+
128+
129+
def _sort_segments(routed_segments, starting_point, ending_point):
130+
if len(routed_segments) == 1:
131+
return
132+
start_segment_index = [i for i, segment in enumerate(routed_segments) if segment[0] == starting_point]
133+
if start_segment_index:
134+
routed_segments.insert(0, routed_segments.pop(start_segment_index[0]))
135+
end_segment_index = [(i, segment) for i, segment in enumerate(routed_segments) if segment[-1] == ending_point]
136+
if end_segment_index and end_segment_index != 0:
137+
routed_segments.pop(end_segment_index[0][0])
138+
routed_segments.append(end_segment_index[0][1])
139+
140+
141+
def _auto_route_segments(clip, stroke, segments, starting_point, ending_point, stitches):
50142

51-
return _repeat_coords(stitches, stroke.repeats)
143+
fill_stitch_graph = build_fill_stitch_graph(clip, segments, starting_point, ending_point)
144+
145+
if is_empty(fill_stitch_graph):
146+
return [stitches]
147+
148+
# clipping may split the stitch path into several unconnected sections
149+
connected_graphs = [fill_stitch_graph.subgraph(c).copy() for c in connected_components(fill_stitch_graph)]
150+
result = []
151+
for ripple_graph in connected_graphs:
152+
graph_make_valid(ripple_graph)
153+
154+
travel_graph = build_travel_graph(ripple_graph, clip, 0, False)
155+
path = find_stitch_path(ripple_graph, travel_graph, starting_point, ending_point, True)
156+
stitches = path_to_stitches(
157+
clip, segments, path, travel_graph, ripple_graph,
158+
stroke.running_stitch_length, stroke.running_stitch_tolerance, False, False)
159+
result.append(stitches)
160+
return result
161+
162+
163+
def _prepare_line_segments(clip, clipped_strings):
164+
# merge continuous lines (they may have been split up at self-intersections).
165+
segments = []
166+
current_segment = []
167+
for line in clipped_strings.geoms:
168+
coords = line.coords
169+
first_point = Point(coords[0])
170+
if first_point.distance(clip.boundary) < 0.0001 and current_segment:
171+
segments.append(current_segment)
172+
current_segment = []
173+
174+
# remove additional stitches which were a byproduct of splitting linestrings at intersections
175+
if len(current_segment) > 1:
176+
current_segment = current_segment[:-1]
177+
coords = coords[1:]
178+
179+
current_segment.extend([(coord[0], coord[1]) for coord in coords])
180+
if current_segment:
181+
segments.append(current_segment)
182+
183+
return segments
184+
185+
186+
def path_to_stitches(shape, segments, path, travel_graph, fill_stitch_graph, running_stitch_length, running_stitch_tolerance, skip_last, underpath):
187+
path = collapse_sequential_outline_edges(path, fill_stitch_graph)
188+
189+
stitches = []
190+
191+
# If the very first stitch is travel, we'll omit it in travel(), so add it here.
192+
if not path[0].is_segment():
193+
stitches.append(Stitch(*path[0].nodes[0], tags={'auto_fill_travel'}))
194+
195+
for edge in path:
196+
if edge.is_segment():
197+
current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment']
198+
path_geometry = current_edge['geometry']
199+
200+
if edge[0] != path_geometry.coords[0]:
201+
path_geometry = reverse_line_string(path_geometry)
202+
203+
new_stitches = [Stitch(*point) for point in path_geometry.coords]
204+
205+
# need to tag stitches
206+
if skip_last:
207+
del new_stitches[-1]
208+
209+
stitches.extend(new_stitches)
210+
211+
travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
212+
else:
213+
stitches.extend(travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tolerance, skip_last, underpath, False))
214+
215+
return stitches
52216

53217

54218
def _get_stitches(stroke, is_linear, lines, skip_start):
@@ -197,14 +361,14 @@ def _line_count_adjust(stroke, num_lines):
197361
num_lines -= 1
198362
# ensure minimum line count
199363
num_lines = max(1, num_lines)
200-
if stroke.is_closed or stroke.join_style == 1:
364+
if stroke.is_closed_unclipped or stroke.join_style == 1:
201365
# for flat join styles we need to add an other line
202366
num_lines += 1
203367
return num_lines
204368

205369

206370
def _get_helper_lines(stroke):
207-
lines = stroke.as_multi_line_string().geoms
371+
lines = stroke.as_multi_line_string(False).geoms
208372
if len(lines) > 1:
209373
helper_lines = _get_satin_ripple_helper_lines(stroke)
210374
if stroke.grid_size > 0:
@@ -228,7 +392,7 @@ def _get_helper_lines(stroke):
228392
stroke.running_stitch_tolerance)
229393
)
230394

231-
if stroke.is_closed:
395+
if stroke.is_closed_unclipped:
232396
helper_lines = _get_circular_ripple_helper_lines(stroke, outline)
233397
return False, helper_lines, helper_lines
234398
elif stroke.join_style == 1:

0 commit comments

Comments
 (0)