Skip to content

Commit 7ace6b0

Browse files
committed
add cross stitch option to fill stitch types
1 parent ad6b929 commit 7ace6b0

File tree

4 files changed

+332
-5
lines changed

4 files changed

+332
-5
lines changed

lib/elements/fill_stitch.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ..i18n import _
1919
from ..marker import get_marker_elements
2020
from ..stitch_plan import StitchGroup
21-
from ..stitches import (auto_fill, circular_fill, contour_fill, guided_fill,
21+
from ..stitches import (auto_fill, circular_fill, contour_fill, cross_stitch, guided_fill,
2222
legacy_fill, linear_gradient_fill, meander_fill,
2323
tartan_fill)
2424
from ..stitches.linear_gradient_fill import gradient_angle
@@ -153,6 +153,7 @@ def auto_fill(self):
153153
_fill_methods = [ParamOption('auto_fill', _("Auto Fill")),
154154
ParamOption('circular_fill', _("Circular Fill")),
155155
ParamOption('contour_fill', _("Contour Fill")),
156+
ParamOption('cross_stitch', _("Cross Stitch")),
156157
ParamOption('guided_fill', _("Guided Fill")),
157158
ParamOption('linear_gradient_fill', _("Linear Gradient Fill")),
158159
ParamOption('meander_fill', _("Meander Fill")),
@@ -318,6 +319,7 @@ def tartan_angle(self):
318319
type='float',
319320
select_items=[('fill_method', 'auto_fill'),
320321
('fill_method', 'contour_fill'),
322+
('fill_method', 'cross_stitch'),
321323
('fill_method', 'guided_fill'),
322324
('fill_method', 'linear_gradient_fill'),
323325
('fill_method', 'tartan_fill'),
@@ -535,6 +537,7 @@ def repeats(self):
535537
type='str',
536538
select_items=[('fill_method', 'meander_fill'),
537539
('fill_method', 'circular_fill'),
540+
('fill_method', 'cross_stitch'),
538541
('fill_method', 'tartan_fill')],
539542
default=0,
540543
sort_index=51)
@@ -734,6 +737,20 @@ def random_seed(self) -> str:
734737
# letting each instance without a specified seed get a different default.
735738
return seed
736739

740+
@property
741+
@param(
742+
'cross_coverage',
743+
_("Cross coverage"),
744+
tooltip=_("Percentage of overlap for each cross with the fill area"),
745+
type='int',
746+
default="50",
747+
unit='%',
748+
select_items=[('fill_method', 'cross_stitch')],
749+
sort_index=62
750+
)
751+
def cross_coverage(self):
752+
return max(1, self.get_int_param("cross_coverage", 50))
753+
737754
@property
738755
@cache
739756
def paths(self):
@@ -897,16 +914,18 @@ def to_stitch_groups(self, previous_stitch_group, next_element=None): # noqa: C
897914
for i, fill_shape in enumerate(fill_shapes.geoms):
898915
if not self.auto_fill or self.fill_method == 'legacy_fill':
899916
stitch_groups.extend(self.do_legacy_fill(fill_shape))
917+
elif self.fill_method == 'circular_fill':
918+
stitch_groups.extend(self.do_circular_fill(fill_shape, start, end))
900919
elif self.fill_method == 'contour_fill':
901920
stitch_groups.extend(self.do_contour_fill(fill_shape, start))
921+
elif self.fill_method == 'cross_stitch':
922+
stitch_groups.extend(self.do_cross_stitch(fill_shape, start, end))
902923
elif self.fill_method == 'guided_fill':
903924
stitch_groups.extend(self.do_guided_fill(fill_shape, start, end))
904-
elif self.fill_method == 'meander_fill':
905-
stitch_groups.extend(self.do_meander_fill(fill_shape, shape, i, start, end))
906-
elif self.fill_method == 'circular_fill':
907-
stitch_groups.extend(self.do_circular_fill(fill_shape, start, end))
908925
elif self.fill_method == 'linear_gradient_fill':
909926
stitch_groups.extend(self.do_linear_gradient_fill(fill_shape, start, end))
927+
elif self.fill_method == 'meander_fill':
928+
stitch_groups.extend(self.do_meander_fill(fill_shape, shape, i, start, end))
910929
elif self.fill_method == 'tartan_fill':
911930
stitch_groups.extend(self.do_tartan_fill(fill_shape, start, end))
912931
else:
@@ -1129,6 +1148,16 @@ def do_meander_fill(self, shape, original_shape, i, starting_point, ending_point
11291148
)
11301149
return [stitch_group]
11311150

1151+
def do_cross_stitch(self, shape, starting_point, ending_point):
1152+
stitch_group = StitchGroup(
1153+
color=self.color,
1154+
tags=("cross_stitch"),
1155+
stitches=cross_stitch(self, shape, starting_point, ending_point),
1156+
force_lock_stitches=self.force_lock_stitches,
1157+
lock_stitches=self.lock_stitches
1158+
)
1159+
return [stitch_group]
1160+
11321161
def do_circular_fill(self, shape, starting_point, ending_point):
11331162
# get target position
11341163
command = self.get_command('target_point')

lib/stitches/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from .auto_fill import auto_fill
77
from .circular_fill import circular_fill
8+
from .cross_stitch import cross_stitch
89
from .fill import legacy_fill
910
from .guided_fill import guided_fill
1011
from .linear_gradient_fill import linear_gradient_fill

lib/stitches/cross_stitch.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# Authors: see git history
2+
#
3+
# Copyright (c) 2010 Authors
4+
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
5+
6+
# -*- coding: UTF-8 -*-
7+
8+
from math import sqrt
9+
10+
import networkx
11+
from shapely import line_merge, prepare, snap
12+
from shapely.affinity import rotate, scale, translate
13+
from shapely.geometry import (LineString, MultiLineString, MultiPoint, Point,
14+
Polygon)
15+
from shapely.ops import nearest_points, unary_union
16+
17+
from ..debug.debug import debug
18+
from ..stitch_plan import Stitch
19+
from ..utils.clamp_path import clamp_path_to_polygon
20+
from ..utils.geometry import Point as InkstitchPoint
21+
from ..utils.geometry import ensure_multi_line_string, reverse_line_string
22+
from ..utils.threading import check_stop_flag
23+
from .auto_fill import (add_edges_between_outline_nodes,
24+
build_fill_stitch_graph, fallback, find_stitch_path,
25+
graph_make_valid, process_travel_edges,
26+
tag_nodes_with_outline_and_projection)
27+
from .circular_fill import _apply_bean_stitch_and_repeats
28+
29+
30+
@debug.time
31+
def cross_stitch(fill, shape, starting_point, ending_point):
32+
stitch_length = fill.max_stitch_length
33+
34+
square_size = stitch_length / sqrt(2) # 45° angle
35+
square = Polygon([(0, 0), (square_size, 0), (square_size, square_size), (0, square_size)])
36+
full_square_area = square.area
37+
38+
# start and end have to be a multiple of the stitch length
39+
minx, miny, maxx, maxy = shape.bounds
40+
adapted_minx = minx - minx % square_size
41+
adapted_miny = miny - miny % square_size
42+
adapted_maxx = maxx + square_size - maxx % square_size
43+
adapted_maxy = maxy + square_size - maxy % square_size
44+
prepare(shape)
45+
46+
crosses_lr = []
47+
crosses_rl = []
48+
boxes = []
49+
scaled_boxes = []
50+
center_points = []
51+
y = adapted_miny
52+
while y <= adapted_maxy:
53+
x = adapted_minx
54+
while x <= adapted_maxx:
55+
box = translate(square, x, y)
56+
if shape.contains(box):
57+
boxes, scaled_boxes, center_points, crosses_lr, crosses_rl = add_cross(
58+
box, scaled_boxes, center_points, crosses_lr, crosses_rl, boxes
59+
)
60+
elif shape.intersects(box):
61+
intersection = box.intersection(shape)
62+
intersection_area = intersection.area
63+
if intersection_area / full_square_area * 100 > fill.cross_coverage:
64+
boxes, scaled_boxes, center_points, crosses_lr, crosses_rl = add_cross(
65+
box, scaled_boxes, center_points, crosses_lr, crosses_rl, boxes
66+
)
67+
x += square_size
68+
y += square_size
69+
check_stop_flag()
70+
71+
if not crosses_lr:
72+
return []
73+
74+
lr = ensure_multi_line_string(line_merge(MultiLineString(crosses_lr)))
75+
rl = ensure_multi_line_string(line_merge(MultiLineString(crosses_rl)))
76+
77+
clamp = False
78+
outline = unary_union(boxes)
79+
if outline.geom_type == 'MultiPolygon':
80+
outline = unary_union(scaled_boxes)
81+
if outline.geom_type == 'MultiPolygon':
82+
# we will have to run this on multiple outline shapes
83+
return cross_stitch_multiple(outline, fill, starting_point, ending_point)
84+
clamp = True
85+
86+
# used for snapping
87+
center_points = MultiPoint(center_points)
88+
89+
nodes = get_line_endpoints(rl)
90+
nodes.extend(get_line_endpoints(lr))
91+
92+
check_stop_flag()
93+
94+
starting_point, ending_point = get_start_and_end(fill.max_stitch_length, starting_point, ending_point, lr, rl)
95+
96+
stitches = _lines_to_stitches(
97+
lr, crosses_rl, outline, stitch_length, fill.bean_stitch_repeats,
98+
starting_point, ending_point, nodes, center_points, clamp
99+
)
100+
starting_point = InkstitchPoint(*stitches[-1])
101+
stitches.extend(
102+
_lines_to_stitches(
103+
rl, crosses_rl, outline, stitch_length, fill.bean_stitch_repeats,
104+
starting_point, ending_point, nodes, center_points, clamp
105+
)
106+
)
107+
108+
return stitches
109+
110+
111+
def cross_stitch_multiple(outline, fill, starting_point, ending_point):
112+
shapes = list(outline.geoms)
113+
if starting_point:
114+
shapes.sort(key=lambda shape: shape.distance(Point(starting_point)))
115+
else:
116+
shapes.sort(key=lambda shape: shape.bounds[0])
117+
stitches = []
118+
for i, polygon in enumerate(shapes):
119+
if i < len(shapes) - 1:
120+
end = nearest_points(polygon, shapes[i+1])[0].coords
121+
else:
122+
end = ending_point
123+
stitches.extend(cross_stitch(fill, polygon, starting_point, end))
124+
starting_point = InkstitchPoint(*stitches[-1])
125+
return stitches
126+
127+
128+
def get_start_and_end(stitch_length, starting_point, ending_point, lr, rl):
129+
if starting_point is not None:
130+
starting_point = snap(Point(starting_point), lr, tolerance=stitch_length).coords
131+
if ending_point is not None:
132+
ending_point = snap(Point(ending_point), rl, tolerance=stitch_length).coords
133+
return starting_point, ending_point
134+
135+
136+
def get_line_endpoints(multilinestring):
137+
nodes = []
138+
for line in multilinestring.geoms:
139+
coords = list(line.coords)
140+
nodes.extend((coords[0], coords[-1]))
141+
return nodes
142+
143+
144+
def add_cross(box, scaled_boxes, center_points, crosses_lr, crosses_rl, boxes):
145+
minx, miny, maxx, maxy = box.bounds
146+
center_points.append(box.centroid)
147+
crosses_lr.append(LineString([(minx, miny), (maxx, maxy)]))
148+
crosses_rl.append(LineString([(maxx, miny), (minx, maxy)]))
149+
boxes.append(box)
150+
# scaling the outline allows us to connect otherwise unconnected boxes
151+
box = scale(box, xfact=1.000000000000001, yfact=1.000000000000001)
152+
scaled_boxes.append(box)
153+
return boxes, scaled_boxes, center_points, crosses_lr, crosses_rl
154+
155+
156+
def _lines_to_stitches(
157+
line_geoms, travel_edges, shape, stitch_length,
158+
bean_stitch_repeats, starting_point, ending_point, nodes, center_points, clamp):
159+
segments = []
160+
for line in line_geoms.geoms:
161+
segments.append(list(line.coords))
162+
163+
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
164+
graph_make_valid(fill_stitch_graph)
165+
166+
if networkx.is_empty(fill_stitch_graph):
167+
return fallback(shape, stitch_length, 0.2)
168+
if not networkx.is_connected(fill_stitch_graph):
169+
return fallback(shape, stitch_length, 0.2)
170+
else:
171+
graph_make_valid(fill_stitch_graph)
172+
173+
travel_graph = build_travel_graph(fill_stitch_graph, shape, travel_edges, nodes)
174+
graph_make_valid(travel_graph)
175+
path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point, False)
176+
result = path_to_stitches(
177+
shape, path, travel_graph, fill_stitch_graph, stitch_length, center_points, clamp
178+
)
179+
result = collapse_travel_edges(result)
180+
result = _apply_bean_stitch_and_repeats(result, 1, bean_stitch_repeats)
181+
return result
182+
183+
184+
def collapse_travel_edges(result):
185+
new_sitches = []
186+
last_travel_stitches = []
187+
for i, stitch in enumerate(result):
188+
if 'auto_fill_travel' in stitch.tags:
189+
if stitch in last_travel_stitches:
190+
last_travel_stitches = last_travel_stitches[0:last_travel_stitches.index(stitch)]
191+
last_travel_stitches.append(stitch)
192+
else:
193+
last_travel_stitches.append(stitch)
194+
new_sitches.extend(last_travel_stitches)
195+
last_travel_stitches = []
196+
if last_travel_stitches:
197+
new_sitches.extend(last_travel_stitches)
198+
return new_sitches
199+
200+
201+
def path_to_stitches(shape, path, travel_graph, fill_stitch_graph, stitch_length, center_points, clamp):
202+
# path = collapse_sequential_outline_edges(path, fill_stitch_graph)
203+
204+
stitches = []
205+
if not path[0].is_segment():
206+
stitches.append(Stitch(*path[0].nodes[0]))
207+
208+
for i, edge in enumerate(path):
209+
check_stop_flag()
210+
if edge.is_segment():
211+
current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment']
212+
path_geometry = current_edge['geometry']
213+
214+
if edge[0] != path_geometry.coords[0]:
215+
path_geometry = reverse_line_string(path_geometry)
216+
217+
stitches.extend([Stitch(*point, tags=["auto_fill", "fill_row"]) for point in path_geometry.coords])
218+
219+
# note: gap fill segments won't be in the graph
220+
if fill_stitch_graph.has_edge(edge[0], edge[1], key='segment'):
221+
travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
222+
else:
223+
stitches.extend(travel(shape, travel_graph, edge, center_points, stitch_length, clamp))
224+
225+
return stitches
226+
227+
228+
def travel(shape, travel_graph, edge, center_points, stitch_length, clamp=True):
229+
"""Create stitches to get from one point on an outline of the shape to another."""
230+
231+
start, end = edge
232+
try:
233+
path = networkx.shortest_path(travel_graph, start, end, weight='weight')
234+
except networkx.NetworkXNoPath:
235+
# This may not look good, but it prevents the fill from failing (which hopefully never happens)
236+
path = [start, end]
237+
238+
path = [InkstitchPoint.from_tuple(point) for point in path]
239+
if not path:
240+
# This may happen on very small shapes.
241+
# Simply return nothing as we do not want to error out
242+
return []
243+
244+
if len(path) > 1 and clamp:
245+
path = clamp_path_to_polygon(path, shape)
246+
247+
stitches = []
248+
last_point = None
249+
for point in path:
250+
check_stop_flag()
251+
if last_point is None:
252+
last_point = point
253+
continue
254+
if last_point[0] != point[0] and last_point[1] != point[1]:
255+
pass
256+
else:
257+
line = LineString([last_point, point])
258+
if line.length < stitch_length / 2 + 2:
259+
stitches.append(Stitch(point, tags=["auto_fill_travel"]))
260+
last_point = point
261+
continue
262+
center = list(rotate(line, 90).coords)
263+
point1 = Point(center[0])
264+
if point1.within(shape):
265+
center_point = point1
266+
else:
267+
center_point = Point(center[1])
268+
# snap to avoid problems in edge collapsing later on
269+
center_point = snap(center_point, center_points, tolerance=0.01)
270+
stitches.append(Stitch(center_point, tags=["auto_fill_travel"]))
271+
stitches.append(Stitch(*point, tags=["auto_fill_travel"]))
272+
last_point = point
273+
274+
return stitches
275+
276+
277+
def build_travel_graph(fill_stitch_graph, shape, travel_edges, nodes):
278+
"""Build a graph for travel stitches.
279+
"""
280+
graph = networkx.MultiGraph()
281+
282+
# Add all the nodes from the main graph. This will be all of the endpoints
283+
# of the rows of stitches. Every node will be on the outline of the shape.
284+
# They'll all already have their `outline` and `projection` tags set.
285+
graph.add_nodes_from(fill_stitch_graph.nodes(data=True))
286+
287+
# This will ensure that a path traveling inside the shape can reach its
288+
# target on the outline, which will be one of the points added above.
289+
tag_nodes_with_outline_and_projection(graph, shape, nodes)
290+
add_edges_between_outline_nodes(graph, duplicate_every_other=True)
291+
292+
process_travel_edges(graph, fill_stitch_graph, shape, travel_edges)
293+
294+
debug.log_graph(graph, "travel graph")
295+
296+
return graph

0 commit comments

Comments
 (0)