summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaalleen <36401965+kaalleen@users.noreply.github.com>2023-11-22 20:55:58 +0100
committerGitHub <noreply@github.com>2023-11-22 20:55:58 +0100
commit3bd92265b21d779c41377d2a06d13abaf628fe34 (patch)
tree276372bf131626f127045ce826bb8df25d89cb8d
parent0b12922d3f058f07020e6afdca26ad34e40918db (diff)
Add linear gradient fill (#2587)
-rw-r--r--lib/elements/element.py9
-rw-r--r--lib/elements/fill_stitch.py142
-rw-r--r--lib/stitches/__init__.py2
-rw-r--r--lib/stitches/auto_fill.py4
-rw-r--r--lib/stitches/circular_fill.py4
-rw-r--r--lib/stitches/guided_fill.py2
-rw-r--r--lib/stitches/linear_gradient_fill.py341
-rw-r--r--lib/svg/tags.py1
8 files changed, 464 insertions, 41 deletions
diff --git a/lib/elements/element.py b/lib/elements/element.py
index 963653af..d6881955 100644
--- a/lib/elements/element.py
+++ b/lib/elements/element.py
@@ -526,6 +526,14 @@ class EmbroideryElement(object):
def _get_guides_cache_key_data(self):
return get_marker_elements_cache_key_data(self.node, "guide-line")
+ def _get_gradient_cache_key_data(self):
+ gradient = {}
+ if hasattr(self, 'gradient') and self.gradient is not None:
+ gradient['stops'] = self.gradient.stop_offsets
+ gradient['orientation'] = [self.gradient.x1(), self.gradient.x2(), self.gradient.y1(), self.gradient.y2()]
+ gradient['styles'] = [(style['stop-color'], style['stop-opacity']) for style in self.gradient.stop_styles]
+ return gradient
+
def get_cache_key_data(self, previous_stitch):
return []
@@ -535,6 +543,7 @@ class EmbroideryElement(object):
cache_key_generator.update(self.get_params_and_values())
cache_key_generator.update(self.parse_path())
cache_key_generator.update(list(self._get_specified_style().items()))
+ cache_key_generator.update(self._get_gradient_cache_key_data())
cache_key_generator.update(previous_stitch)
cache_key_generator.update([(c.command, c.target_point) for c in self.commands])
cache_key_generator.update(self._get_patterns_cache_key_data())
diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py
index b20c2715..bc42163e 100644
--- a/lib/elements/fill_stitch.py
+++ b/lib/elements/fill_stitch.py
@@ -11,6 +11,7 @@ import numpy as np
from inkex import Transform
from shapely import geometry as shgeo
from shapely.errors import GEOSException
+from shapely.ops import nearest_points
from shapely.validation import explain_validity, make_valid
from .. import tiles
@@ -18,8 +19,8 @@ from ..i18n import _
from ..marker import get_marker_elements
from ..stitch_plan import StitchGroup
from ..stitches import (auto_fill, circular_fill, contour_fill, guided_fill,
- legacy_fill)
-from ..stitches.meander_fill import meander_fill
+ legacy_fill, linear_gradient_fill, meander_fill)
+from ..stitches.linear_gradient_fill import gradient_angle
from ..svg import PIXELS_PER_MM, get_node_transform
from ..svg.clip import get_clip_path
from ..svg.tags import INKSCAPE_LABEL
@@ -114,6 +115,7 @@ class FillStitch(EmbroideryElement):
ParamOption('guided_fill', _("Guided Fill")),
ParamOption('meander_fill', _("Meander Fill")),
ParamOption('circular_fill', _("Circular Fill")),
+ ParamOption('linear_gradient_fill', _("Linear Gradient Fill")),
ParamOption('legacy_fill', _("Legacy Fill"))]
@property
@@ -226,7 +228,8 @@ class FillStitch(EmbroideryElement):
select_items=[('fill_method', 'auto_fill'),
('fill_method', 'guided_fill'),
('fill_method', 'meander_fill'),
- ('fill_method', 'circular_fill')])
+ ('fill_method', 'circular_fill'),
+ ('fill_method', 'linear_gradient_fill')])
def expand(self):
return self.get_float_param('expand_mm', 0)
@@ -254,6 +257,7 @@ class FillStitch(EmbroideryElement):
select_items=[('fill_method', 'auto_fill'),
('fill_method', 'contour_fill'),
('fill_method', 'guided_fill'),
+ ('fill_method', 'linear_gradient_fill'),
('fill_method', 'legacy_fill')],
default=3.0)
def max_stitch_length(self):
@@ -270,6 +274,7 @@ class FillStitch(EmbroideryElement):
('fill_method', 'contour_fill'),
('fill_method', 'guided_fill'),
('fill_method', 'circular_fill'),
+ ('fill_method', 'linear_gradient_fill'),
('fill_method', 'legacy_fill')],
default=0.25)
def row_spacing(self):
@@ -297,7 +302,10 @@ class FillStitch(EmbroideryElement):
'Fractional values are allowed and can have less visible diagonals than integer values.'),
type='int',
sort_index=25,
- select_items=[('fill_method', 'auto_fill'), ('fill_method', 'guided_fill'), ('fill_method', 'legacy_fill')],
+ select_items=[('fill_method', 'auto_fill'),
+ ('fill_method', 'guided_fill'),
+ ('fill_method', 'linear_gradient_fill'),
+ ('fill_method', 'legacy_fill')],
default=4)
def staggers(self):
return self.get_float_param("staggers", 4)
@@ -310,7 +318,9 @@ class FillStitch(EmbroideryElement):
'Skipping it decreases stitch count and density.'),
type='boolean',
sort_index=26,
- select_items=[('fill_method', 'auto_fill'), ('fill_method', 'guided_fill'),
+ select_items=[('fill_method', 'auto_fill'),
+ ('fill_method', 'guided_fill'),
+ ('fill_method', 'linear_gradient_fill'),
('fill_method', 'legacy_fill')],
default=False)
def skip_last(self):
@@ -330,6 +340,20 @@ class FillStitch(EmbroideryElement):
return self.get_boolean_param("flip", False)
@property
+ @param(
+ 'stop_at_ending_point',
+ _('Stop at ending point'),
+ tooltip=_('If this option is disabled, the ending point will only be used to define a general direction for '
+ 'stitch routing. When enabled the last section will end at the defined spot.'),
+ type='boolean',
+ sort_index=30,
+ select_items=[('fill_method', 'linear_gradient_fill')],
+ default=False
+ )
+ def stop_at_ending_point(self):
+ return self.get_boolean_param("stop_at_ending_point", False)
+
+ @property
@param('underpath',
_('Underpath'),
tooltip=_('Travel inside the shape when moving from section to section. Underpath '
@@ -353,7 +377,8 @@ class FillStitch(EmbroideryElement):
select_items=[('fill_method', 'auto_fill'),
('fill_method', 'guided_fill'),
('fill_method', 'meander_fill'),
- ('fill_method', 'circular_fill')],
+ ('fill_method', 'circular_fill'),
+ ('fill_method', 'linear_gradient_fill')],
sort_index=31)
def running_stitch_length(self):
return max(self.get_float_param("running_stitch_length_mm", 2.5), 0.01)
@@ -403,6 +428,12 @@ class FillStitch(EmbroideryElement):
return self.get_style("fill", "#000000")
@property
+ def gradient(self):
+ color = self.color[5:-1]
+ xpath = f'.//svg:defs/svg:linearGradient[@id="{color}"]'
+ return self.node.getroottree().getroot().findone(xpath)
+
+ @property
@param('fill_underlay', _('Underlay'), type='toggle', group=_('Fill Underlay'), default=True)
def fill_underlay(self):
return self.get_boolean_param("fill_underlay", default=True)
@@ -427,6 +458,8 @@ class FillStitch(EmbroideryElement):
float(angle)) for angle in underlay_angles]
except (TypeError, ValueError):
return default_value
+ elif self.fill_method == 'linear_gradient_fill' and self.gradient is not None:
+ return [-gradient_angle(self.node, self.gradient)]
else:
underlay_angles = default_value
@@ -704,10 +737,25 @@ class FillStitch(EmbroideryElement):
return self.do_legacy_fill()
else:
stitch_groups = []
- end = self.get_ending_point()
- for shape in self.shape.geoms:
+ # start and end points
+ start = self.get_starting_point(previous_stitch_group)
+ final_end = self.get_ending_point()
+
+ # sort shapes to get a nicer routing
+ shapes = list(self.shape.geoms)
+ if start:
+ shapes.sort(key=lambda shape: shape.distance(shgeo.Point(start)))
+ else:
+ shapes.sort(key=lambda shape: shape.bounds[0])
+
+ for i, shape in enumerate(shapes):
start = self.get_starting_point(previous_stitch_group)
+ if i < len(shapes) - 1:
+ end = nearest_points(shape, shapes[i+1])[0].coords
+ else:
+ end = final_end
+
if self.fill_underlay:
underlay_shapes = self.underlay_shape(shape)
for underlay_shape in underlay_shapes.geoms:
@@ -724,10 +772,18 @@ class FillStitch(EmbroideryElement):
stitch_groups.extend(self.do_meander_fill(fill_shape, shape, i, start, end))
elif self.fill_method == 'circular_fill':
stitch_groups.extend(self.do_circular_fill(fill_shape, previous_stitch_group, start, end))
+ elif self.fill_method == 'linear_gradient_fill':
+ stitch_groups.extend(self.do_linear_gradient_fill(fill_shape, previous_stitch_group, start, end))
else:
# auto_fill
stitch_groups.extend(self.do_auto_fill(fill_shape, previous_stitch_group, start, end))
- previous_stitch_group = stitch_groups[-1]
+ if stitch_groups:
+ previous_stitch_group = stitch_groups[-1]
+
+ # sort colors of linear gradient (if multiple shapes)
+ if self.fill_method == 'linear_gradient_fill':
+ colors = [stitch_group.color for stitch_group in stitch_groups]
+ stitch_groups.sort(key=lambda group: colors.index(group.color))
return stitch_groups
@@ -746,10 +802,13 @@ class FillStitch(EmbroideryElement):
lock_stitches=self.lock_stitches) for stitch_list in stitch_lists]
def do_underlay(self, shape, starting_point):
+ color = self.color
+ if self.gradient is not None and self.fill_method == 'linear_gradient_fill':
+ color = [style['stop-color'] for style in self.gradient.stop_styles][0]
stitch_groups = []
for i in range(len(self.fill_underlay_angle)):
underlay = StitchGroup(
- color=self.color,
+ color=color,
tags=("auto_fill", "auto_fill_underlay"),
lock_stitches=self.lock_stitches,
stitches=auto_fill(
@@ -763,7 +822,9 @@ class FillStitch(EmbroideryElement):
self.staggers,
self.fill_underlay_skip_last,
starting_point,
- underpath=self.underlay_underpath))
+ underpath=self.underlay_underpath
+ )
+ )
stitch_groups.append(underlay)
starting_point = underlay.stitches[-1]
return [stitch_groups, starting_point]
@@ -859,15 +920,9 @@ class FillStitch(EmbroideryElement):
starting_point,
ending_point,
self.underpath,
- self.guided_fill_strategy,
- ))
- return [stitch_group]
-
- def do_meander_fill(self, shape, original_shape, i, starting_point, ending_point):
- stitch_group = StitchGroup(
- color=self.color,
- tags=("meander_fill", "meander_fill_top"),
- stitches=meander_fill(self, shape, original_shape, i, starting_point, ending_point))
+ self.guided_fill_strategy
+ )
+ )
return [stitch_group]
@cache
@@ -882,6 +937,16 @@ class FillStitch(EmbroideryElement):
else:
return guide_lines['stroke'][0]
+ def do_meander_fill(self, shape, original_shape, i, starting_point, ending_point):
+ stitch_group = StitchGroup(
+ color=self.color,
+ tags=("meander_fill", "meander_fill_top"),
+ stitches=meander_fill(self, shape, original_shape, i, starting_point, ending_point),
+ force_lock_stitches=self.force_lock_stitches,
+ lock_stitches=self.lock_stitches,
+ )
+ return [stitch_group]
+
def do_circular_fill(self, shape, last_patch, starting_point, ending_point):
# get target position
command = self.get_command('ripple_target')
@@ -893,24 +958,29 @@ class FillStitch(EmbroideryElement):
else:
target = shape.centroid
stitches = circular_fill(
- shape,
- self.angle,
- self.row_spacing,
- self.end_row_spacing,
- self.staggers,
- self.running_stitch_length,
- self.running_stitch_tolerance,
- self.bean_stitch_repeats,
- self.repeats,
- self.skip_last,
- starting_point,
- ending_point,
- self.underpath,
- target
- )
+ shape,
+ self.angle,
+ self.row_spacing,
+ self.end_row_spacing,
+ self.staggers,
+ self.running_stitch_length,
+ self.running_stitch_tolerance,
+ self.bean_stitch_repeats,
+ self.repeats,
+ self.skip_last,
+ starting_point,
+ ending_point,
+ self.underpath,
+ target
+ )
stitch_group = StitchGroup(
color=self.color,
tags=("circular_fill", "auto_fill_top"),
- stitches=stitches)
+ stitches=stitches,
+ force_lock_stitches=self.force_lock_stitches,
+ lock_stitches=self.lock_stitches,)
return [stitch_group]
+
+ def do_linear_gradient_fill(self, shape, last_patch, start, end):
+ return linear_gradient_fill(self, shape, start, end)
diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py
index f77f16e7..ba56a0ec 100644
--- a/lib/stitches/__init__.py
+++ b/lib/stitches/__init__.py
@@ -7,6 +7,8 @@ from .auto_fill import auto_fill
from .circular_fill import circular_fill
from .fill import legacy_fill
from .guided_fill import guided_fill
+from .linear_gradient_fill import linear_gradient_fill
+from .meander_fill import meander_fill
# Can't put this here because we get a circular import :(
# from .auto_satin import auto_satin
diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py
index 83ff226b..24ce6610 100644
--- a/lib/stitches/auto_fill.py
+++ b/lib/stitches/auto_fill.py
@@ -78,7 +78,7 @@ def auto_fill(shape,
segments = [segment for row in rows for segment in row]
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
- if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length):
+ if not graph_is_valid(fill_stitch_graph):
return fallback(shape, running_stitch_length, running_stitch_tolerance)
travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
@@ -269,7 +269,7 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False):
check_stop_flag()
-def graph_is_valid(graph, shape, max_stitch_length):
+def graph_is_valid(graph):
# The graph may be empty if the shape is so small that it fits between the
# rows of stitching. Certain small weird shapes can also cause a non-
# eulerian graph.
diff --git a/lib/stitches/circular_fill.py b/lib/stitches/circular_fill.py
index 351f4cf4..959759dc 100644
--- a/lib/stitches/circular_fill.py
+++ b/lib/stitches/circular_fill.py
@@ -73,7 +73,7 @@ def circular_fill(shape,
segments.append([(point.x, point.y) for point in coords])
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
- if not graph_is_valid(fill_stitch_graph, shape, running_stitch_length):
+ if not graph_is_valid(fill_stitch_graph):
return fallback(shape, running_stitch_length, running_stitch_tolerance)
travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
@@ -124,7 +124,7 @@ def path_to_stitches(shape, path, travel_graph, fill_stitch_graph, running_stitc
# 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]))
+ stitches.append(Stitch(*path[0].nodes[0], tags={'auto_fill_travel'}))
for edge in path:
if edge.is_segment():
diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py
index 67ad4ccd..762515f6 100644
--- a/lib/stitches/guided_fill.py
+++ b/lib/stitches/guided_fill.py
@@ -39,7 +39,7 @@ def guided_fill(shape,
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
- if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length):
+ if not graph_is_valid(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)
diff --git a/lib/stitches/linear_gradient_fill.py b/lib/stitches/linear_gradient_fill.py
new file mode 100644
index 00000000..34f91d5a
--- /dev/null
+++ b/lib/stitches/linear_gradient_fill.py
@@ -0,0 +1,341 @@
+# Authors: see git history
+#
+# Copyright (c) 2023 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+from math import ceil, floor, sqrt
+
+import numpy as np
+from inkex import DirectedLineSegment, Transform
+from networkx import eulerize
+from shapely import segmentize
+from shapely.affinity import rotate
+from shapely.geometry import LineString, MultiLineString, Point, Polygon
+
+from ..stitch_plan import StitchGroup
+from ..svg import get_node_transform
+from ..utils.threading import check_stop_flag
+from .auto_fill import (build_fill_stitch_graph, build_travel_graph,
+ find_stitch_path, graph_is_valid)
+from .circular_fill import path_to_stitches
+from .guided_fill import apply_stitches
+
+
+def linear_gradient_fill(fill, shape, starting_point, ending_point):
+ lines, colors, stop_color_line_indices = _get_lines_and_colors(shape, fill)
+ color_lines, colors = _get_color_lines(lines, colors, stop_color_line_indices)
+ if fill.gradient is None:
+ colors.pop()
+ stitch_groups = _get_stitch_groups(fill, shape, colors, color_lines, starting_point, ending_point)
+ return stitch_groups
+
+
+def _get_lines_and_colors(shape, fill):
+ '''
+ Returns lines and color gradient information
+ lines: a list of lines which cover the whole shape in a 90° angle to the gradient line
+ colors: a list of color values
+ stop_color_line_indices: line indices indicating where color changes are positioned at
+ '''
+ orig_bbox = shape.bounds
+
+ # get angle, colors, as well as start and stop position of the gradient
+ angle, colors, offsets, gradient_start, gradient_end = _get_gradient_info(fill, orig_bbox)
+
+ # get lines
+ lines, bottom_line = _get_lines(fill, shape, orig_bbox, angle)
+
+ gradient_start_line_index = round(bottom_line.project(Point(gradient_start)) / fill.row_spacing)
+ if gradient_start_line_index == 0:
+ gradient_start_line_index = -round(LineString([gradient_start, gradient_end]).project(Point(bottom_line.coords[0])) / fill.row_spacing)
+ stop_color_line_indices = [gradient_start_line_index]
+ gradient_line = LineString([gradient_start, gradient_end])
+ for offset in offsets[1:]:
+ stop_color_line_indices.append(round((gradient_line.length * offset) / fill.row_spacing) + gradient_start_line_index)
+
+ return lines, colors, stop_color_line_indices
+
+
+def _get_gradient_info(fill, bbox):
+ if fill.gradient is None:
+ # there is no linear gradient, let's simply space out one single color instead
+ angle = fill.angle
+ offsets = [0, 1]
+ colors = [fill.color, 'none']
+ gradient_start = (bbox[0], bbox[1])
+ gradient_end = (bbox[2], bbox[3])
+ else:
+ fill.gradient.apply_transform()
+ offsets = fill.gradient.stop_offsets
+ colors = [style['stop-color'] if float(style['stop-opacity']) > 0 else 'none' for style in fill.gradient.stop_styles]
+ gradient_start, gradient_end = gradient_start_end(fill.node, fill.gradient)
+ angle = gradient_angle(fill.node, fill.gradient)
+ return angle, colors, offsets, gradient_start, gradient_end
+
+
+def _get_lines(fill, shape, bounding_box, angle):
+ '''
+ To generate the lines we rotate the bounding box to bring the angle in vertical position.
+ From bounds we create a Polygon which we then rotate back, so we receive a rotated bounding box
+ which aligns well to the stitch angle. Combining the points of the subdivided top and bottom line
+ will finally deliver to our stitch rows
+ '''
+
+ # get the rotated bounding box for the shape
+ rotated_shape = rotate(shape, -angle, origin=(bounding_box[0], bounding_box[3]), use_radians=True)
+ bounds = rotated_shape.bounds
+
+ # Generate a Polygon from the rotated bounding box which we then rotate back into original position
+ # extend bounding box for lines just a little to make sure we cover the whole area with lines
+ # this avoids rounding errors due to the rotation later on
+ rot_bbox = Polygon([
+ (bounds[0] - fill.max_stitch_length, bounds[1] - fill.row_spacing),
+ (bounds[2] + fill.max_stitch_length, bounds[1] - fill.row_spacing),
+ (bounds[2] + fill.max_stitch_length, bounds[3] + fill.row_spacing),
+ (bounds[0] - fill.max_stitch_length, bounds[3] + fill.row_spacing)
+ ])
+ # and rotate it back into original position
+ rot_bbox = list(rotate(rot_bbox, angle, origin=(bounding_box[0], bounding_box[3]), use_radians=True).exterior.coords)
+
+ # segmentize top and bottom line to finally be ableto generate the stitch lines
+ top_line = LineString([rot_bbox[0], rot_bbox[1]])
+ top = segmentize(top_line, max_segment_length=fill.row_spacing)
+
+ bottom_line = LineString([rot_bbox[3], rot_bbox[2]])
+ bottom = segmentize(bottom_line, max_segment_length=fill.row_spacing)
+
+ lines = list(zip(top.coords, bottom.coords))
+
+ # stagger stitched lines according to user settings
+ staggered_lines = []
+ for i, line in enumerate(lines):
+ staggered_line = apply_stitches(LineString(line), fill.max_stitch_length, fill.staggers, fill.row_spacing, i)
+ staggered_lines.append(staggered_line)
+ return staggered_lines, bottom_line
+
+
+def _get_color_lines(lines, colors, stop_color_line_indices):
+ '''
+ To define which line will be stitched in which color, we will loop through the color sections
+ defined by the stop positions of the gradient (stop_color_line_indices).
+ Each section will then be subdivided into smaller sections using the square root of the total line number
+ of the whole section. Lines left over from this operation will be added step by step to the smaller sub-sections.
+ Since we do this symmetrically we may end one line short, which we an add at the end.
+
+ Now we define the line colors of the first half of our color section, we will later mirror this on the second half.
+ Therefor we use one additional line of color2 in each sub-section and position them as evenly as possible between the color1 lines.
+ Doing this we take care, that the number of consecutive lines of color1 is always decreasing.
+
+ For example let's take a 12 lines sub-section, with 5 lines of color2.
+ 12 / 5 = 2.4
+ 12 % 5 = 2
+ This results into the following pattern:
+ xx|xx|x|x|x| (while x = color1 and | = color2).
+ Note that the first two parts have an additional line (as defined by the modulo operation)
+
+ Method returns
+ color_lines: A dictionary with lines grouped by color
+ colors: An updated list of color values.
+ Colors which are positioned outside the shape will be removed.
+ '''
+
+ # create dictionary with a key for each color
+ color_lines = {}
+ for color in colors:
+ color_lines[color] = []
+
+ prev_color = colors[0]
+ prev = None
+ for line_index, color in zip(stop_color_line_indices, colors):
+ if prev is None:
+ if line_index > 0:
+ color_lines[color].extend(lines[0:line_index + 1])
+ prev = line_index
+ prev_color = color
+ continue
+ if prev < 0 and line_index < 0:
+ prev = line_index
+ prev_color = color
+ continue
+
+ prev += 1
+ line_index += 1
+ total_lines = line_index - prev
+ sections = floor(sqrt(total_lines))
+
+ color1 = []
+ color2 = []
+
+ c2_count = 0
+ c1_count = 0
+ current_line = 0
+
+ line_count_diff = floor((total_lines - sections**2) / 2)
+
+ stop = False
+ for i in range(sections):
+ if stop:
+ break
+
+ c2_count += 1
+ c1_count = sections - c2_count
+ rest = c1_count % c2_count
+ c1_count = ceil(c1_count / c2_count)
+
+ current_line, line_count_diff, color1, color2, stop = _add_lines(
+ current_line,
+ total_lines,
+ line_count_diff,
+ color1,
+ color2,
+ stop,
+ rest,
+ c1_count,
+ c2_count
+ )
+
+ # mirror the first half of the color section to receive the full section
+ second_half = color2[-1] * 2 + 1
+
+ color1 = np.array(color1)
+ color2 = np.array(color2)
+
+ c1 = np.append(color1, second_half - color2)
+ color2 = np.append(color2, second_half - color1)
+ color1 = c1
+
+ # until now we only cared about the length of the section
+ # now we need to move it to the correct position
+ color1 += prev
+ color2 += prev
+
+ # add lines to their color key in the dictionary
+ # as sections can start before or after the actual shape we need to make sure,
+ # that we only try to add existing lines
+ color_lines[prev_color].extend([lines[x] for x in color1 if 0 < x < len(lines)])
+ color_lines[color].extend([lines[x] for x in color2 if 0 < x < len(lines)])
+
+ prev = np.max(color2)
+ prev_color = color
+
+ check_stop_flag()
+
+ # add left over lines to last color
+ color_lines[color].extend(lines[prev+1:])
+
+ # remove transparent colors (we just want a gap)
+ color_lines.pop('none', None)
+
+ # remove empty line lists and update colors
+ color_lines = {color: lines for color, lines in color_lines.items() if lines}
+ colors = list(color_lines.keys())
+
+ return color_lines, colors
+
+
+def _add_lines(current_line, total_lines, line_count_diff, color1, color2, stop, rest, c1_count, c2_count):
+ for j in range(c2_count):
+ if stop:
+ break
+ if rest == 0 or j < rest:
+ count = c1_count
+ else:
+ count = c1_count - 1
+ if line_count_diff > 0:
+ count += 1
+ line_count_diff -= 1
+ for k in range(count):
+ color1.append(current_line)
+ current_line += 1
+ if total_lines / 2 <= current_line + 1:
+ stop = True
+ break
+ color2.append(current_line)
+ current_line += 1
+ return current_line, line_count_diff, color1, color2, stop
+
+
+def _get_stitch_groups(fill, shape, colors, color_lines, starting_point, ending_point):
+ stitch_groups = []
+ for i, color in enumerate(colors):
+ lines = color_lines[color]
+
+ multiline = MultiLineString(lines).intersection(shape)
+ if not isinstance(multiline, MultiLineString):
+ if isinstance(multiline, LineString):
+ multiline = MultiLineString([multiline])
+ else:
+ continue
+ segments = [list(line.coords) for line in multiline.geoms if len(line.coords) > 1]
+
+ fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
+
+ if not graph_is_valid(fill_stitch_graph):
+ # try to eulerize
+ fill_stitch_graph = eulerize(fill_stitch_graph)
+ # still not valid? continue without rendering the color section
+ if not graph_is_valid(fill_stitch_graph):
+ continue
+
+ travel_graph = build_travel_graph(fill_stitch_graph, shape, fill.angle, False)
+ path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
+ stitches = path_to_stitches(
+ shape,
+ path,
+ travel_graph,
+ fill_stitch_graph,
+ fill.running_stitch_length,
+ fill.running_stitch_tolerance,
+ fill.skip_last,
+ False # no underpath
+ )
+
+ stitches = _remove_start_end_travel(fill, stitches, colors, i)
+
+ stitch_groups.append(StitchGroup(
+ color=color,
+ tags=("linear_gradient_fill", "auto_fill_top"),
+ stitches=stitches,
+ force_lock_stitches=fill.force_lock_stitches,
+ lock_stitches=fill.lock_stitches,
+ trim_after=fill.has_command("trim") or fill.trim_after
+ ))
+
+ return stitch_groups
+
+
+def _remove_start_end_travel(fill, stitches, colors, color_section):
+ # We can savely remove travel stitches at start since we are changing color all the time
+ # but we do care for the first starting point, it is important when they use an underlay of the same color
+ remove_before = 0
+ if color_section > 0 or not fill.fill_underlay:
+ for stitch in range(len(stitches)-1):
+ if 'auto_fill_travel' not in stitches[stitch].tags:
+ remove_before = stitch
+ break
+ stitches = stitches[remove_before:]
+ remove_after = len(stitches) - 1
+ # We also remove travel stitches at the end. It is optional to the user if the last color block travels
+ # to the defined ending point
+ if color_section < len(colors) - 2 or not fill.stop_at_ending_point:
+ for stitch in range(remove_after, 0, -1):
+ if 'auto_fill_travel' not in stitches[stitch].tags:
+ remove_after = stitch + 1
+ break
+ stitches = stitches[:remove_after]
+ return stitches
+
+
+def gradient_start_end(node, gradient):
+ transform = Transform(get_node_transform(node))
+ gradient_start = transform.apply_to_point((float(gradient.x1()), float(gradient.y1())))
+ gradient_end = transform.apply_to_point((float(gradient.x2()), float(gradient.y2())))
+ return gradient_start, gradient_end
+
+
+def gradient_angle(node, gradient):
+ if gradient is None:
+ return
+ gradient_start, gradient_end = gradient_start_end(node, gradient)
+ gradient_line = DirectedLineSegment(gradient_start, gradient_end)
+ return gradient_line.angle
diff --git a/lib/svg/tags.py b/lib/svg/tags.py
index 5713c399..e3d336df 100644
--- a/lib/svg/tags.py
+++ b/lib/svg/tags.py
@@ -97,6 +97,7 @@ inkstitch_attribs = [
'staggers',
'underlay_underpath',
'underpath',
+ 'stop_at_ending_point',
'flip',
'clip',
# stroke