diff options
Diffstat (limited to 'lib/stitches/linear_gradient_fill.py')
| -rw-r--r-- | lib/stitches/linear_gradient_fill.py | 341 |
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 |
