inkstitch/lib/stitches/linear_gradient_fill.py

335 wiersze
13 KiB
Python

# 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