summaryrefslogtreecommitdiff
path: root/lib/stitches/linear_gradient_fill.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/stitches/linear_gradient_fill.py')
-rw-r--r--lib/stitches/linear_gradient_fill.py341
1 files changed, 341 insertions, 0 deletions
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