# 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 is_empty 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.geometry import ensure_multi_line_string from ..utils.threading import check_stop_flag from .auto_fill import (build_fill_stitch_graph, build_travel_graph, find_stitch_path, graph_make_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) 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(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, Point(list(gradient_start)), Point(list(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 = ensure_multi_line_string(MultiLineString(lines).intersection(shape)) if multiline.is_empty: 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 is_empty(fill_stitch_graph): continue graph_make_valid(fill_stitch_graph) 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