diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index 188c2ad7b..c539cf7e6 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -596,6 +596,37 @@ class FillStitch(EmbroideryElement): def herringbone_width(self): return self.get_float_param('herringbone_width_mm', 0) + @property + @param( + 'pull_compensation_mm', + _('Pull compensation'), + tooltip=_('Fill stitch can pull the fabric together, resulting in a shape narrower than you draw in Inkscape. ' + 'This setting expands each row of stitches outward from the center of the row by a fixed length. ' + 'Two values separated by a space may be used for an asymmetric effect.'), + select_items=[('fill_method', 'auto_fill')], + unit=_('mm (each side)'), + type='float', + default=0, + sort_index=26) + @cache + def pull_compensation_px(self): + return np.maximum(self.get_split_mm_param_as_px("pull_compensation_mm", (0, 0)), 0) + + @property + @param( + 'pull_compensation_percent', + _('Pull compensation percentage'), + tooltip=_('Additional pull compensation which varies as a percentage of row width. ' + 'Two values separated by a space may be used for an asymmetric effect.'), + select_items=[('fill_method', 'auto_fill')], + unit=_('% (each side)'), + type='float', + default=0, + sort_index=27) + @cache + def pull_compensation_percent(self): + return np.maximum(self.get_split_float_param("pull_compensation_percent", (0, 0)), 0) + @property def color(self): # SVG spec says the default fill is black @@ -1027,6 +1058,8 @@ class FillStitch(EmbroideryElement): self.enable_random_stitch_length, self.random_stitch_length_jitter, self.random_seed, + self.pull_compensation_px, + self.pull_compensation_percent / 100, ) ) return [stitch_group] diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index c031f229e..dd04b507d 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -20,7 +20,7 @@ from ..metadata import InkStitchMetadata from ..stitch_plan import StitchGroup from ..stitches import running_stitch from ..svg import line_strings_to_csp, point_lists_to_csp -from ..utils import Point, cache, cut, cut_multiple, prng +from ..utils import Point, cache, cut, cut_multiple, offset_points, prng from ..utils.param import ParamOption from ..utils.threading import check_stop_flag from .element import PIXELS_PER_MM, EmbroideryElement, param @@ -260,7 +260,7 @@ class SatinColumn(EmbroideryElement): 'pull_compensation_percent', _('Pull compensation percentage'), tooltip=_('Additional pull compensation which varies as a percentage of stitch width. ' - 'Two values separated by a space may be used for an aysmmetric effect.'), + 'Two values separated by a space may be used for an asymmetric effect.'), unit=_('% (each side)'), type='float', default=0, @@ -276,7 +276,7 @@ class SatinColumn(EmbroideryElement): _('Pull compensation'), tooltip=_('Satin stitches pull the fabric together, resulting in a column narrower than you draw in Inkscape. ' 'This setting expands each pair of needle penetrations outward from the center of the satin column by a fixed length. ' - 'Two values separated by a space may be used for an aysmmetric effect.'), + 'Two values separated by a space may be used for an asymmetric effect.'), unit=_('mm (each side)'), type='float', default=0, @@ -1015,34 +1015,6 @@ class SatinColumn(EmbroideryElement): center_walk = [center_walk[0], center_walk[0]] return shgeo.LineString(center_walk) - def offset_points(self, pos1, pos2, offset_px, offset_proportional): - # Expand or contract two points about their midpoint. This is - # useful for pull compensation and insetting underlay. - - distance = (pos1 - pos2).length() - - if distance < 0.0001: - # if they're the same point, we don't know which direction - # to offset in, so we have to just return the points - return pos1, pos2 - - # calculate the offset for each side - offset_a = offset_px[0] + (distance * offset_proportional[0]) - offset_b = offset_px[1] + (distance * offset_proportional[1]) - offset_total = offset_a + offset_b - - # don't contract beyond the midpoint, or we'll start expanding - if offset_total < -distance: - scale = -distance / offset_total - offset_a = offset_a * scale - offset_b = offset_b * scale - - # convert offset to float before using because it may be a numpy.float64 - out1 = pos1 + (pos1 - pos2).unit() * float(offset_a) - out2 = pos2 + (pos2 - pos1).unit() * float(offset_b) - - return out1, out2 - def _stitch_distance(self, pos0, pos1, previous_pos0, previous_pos1): """Return the distance from one stitch to the next.""" @@ -1567,7 +1539,7 @@ class SatinColumn(EmbroideryElement): offset_px[0] = -inset_px if b.distance(pairs[i-1][1]) < min_dist: offset_px[1] = -inset_px - shortened.append(self.offset_points(a, b, offset_px, (0, 0))) + shortened.append(offset_points(a, b, offset_px, (0, 0))) return shortened def _get_inset_point(self, point1, point2, distance_fraction): @@ -1656,7 +1628,7 @@ class SatinProcessor: else: offset_prop = self.offset_proportional - a, b = self.satin.offset_points(pos0, pos1, self.offset_px, offset_prop) + a, b = offset_points(pos0, pos1, self.offset_px, offset_prop) return a, b def get_stitch_spacing_multiple(self): diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index d3db978a2..84832fc8f 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -11,8 +11,8 @@ from typing import Iterator import networkx from shapely import geometry as shgeo -from shapely import segmentize -from shapely.ops import snap +from shapely import make_valid, segmentize +from shapely.ops import snap, unary_union from shapely.strtree import STRtree from ..debug.debug import debug @@ -22,7 +22,8 @@ from ..utils import cache from ..utils.clamp_path import clamp_path_to_polygon from ..utils.geometry import Point as InkstitchPoint from ..utils.geometry import (ensure_multi_line_string, - line_string_to_point_list) + line_string_to_point_list, offset_points) +from ..utils.list import is_all_zeroes from ..utils.prng import join_args from ..utils.smoothing import smooth_path from ..utils.threading import check_stop_flag @@ -82,12 +83,18 @@ def auto_fill(shape, gap_fill_rows=0, enable_random_stitch_length=False, random_sigma=0.0, - random_seed=""): + random_seed="", + pull_compensation_px=(0, 0), + pull_compensation_percent=(0, 0)): + has_pull_compensation = not is_all_zeroes(pull_compensation_px) or not is_all_zeroes(pull_compensation_percent) + if has_pull_compensation: + spacing = min(row_spacing, end_row_spacing or row_spacing) + shape = adjust_shape_for_pull_compensation(shape, angle, spacing, pull_compensation_px, pull_compensation_percent) + rows = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) if not rows: # Small shapes may not intersect with the grating at all. return fallback(shape, running_stitch_length, running_stitch_tolerance) - segments = [segment for row in rows for segment in row] fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) @@ -120,6 +127,34 @@ def round_to_multiple_of_2(number): return number +@debug.time +def adjust_shape_for_pull_compensation(shape, angle, row_spacing, pull_compensation_px, pull_compensation_percent): + rows = intersect_region_with_grating(shape, angle, row_spacing) + if not rows: + return shape + segments = [segment for row in rows for segment in row] + segments = apply_pull_compensation(segments, pull_compensation_px, pull_compensation_percent) + + lines = [shgeo.LineString((start, end)) for start, end in segments] + buffer_amount = row_spacing/2 + 0.01 + buffered_lines = [line.buffer(buffer_amount) for line in lines] + + polygon = unary_union(buffered_lines) + return make_valid(polygon) + + +def apply_pull_compensation(segments, pull_compensation_px, pull_compensation_percent): + new_segments = [] + for segment in segments: + start = InkstitchPoint.from_tuple(segment[0]) + end = InkstitchPoint.from_tuple(segment[1]) + end = InkstitchPoint.from_tuple(segment[1]) + new_start, new_end = offset_points(start, end, pull_compensation_px, pull_compensation_percent) + new_segments.append((new_start.as_tuple(), new_end.as_tuple())) + + return new_segments + + def which_outline(shape, coords): """return the index of the outline on which the point resides @@ -374,7 +409,6 @@ def build_travel_graph(fill_stitch_graph, shape, fill_stitch_angle, underpath): calculation. We also weight the interior edges extra proportional to how close they are to the boundary. """ - graph = networkx.MultiGraph() # Add all the nodes from the main graph. This will be all of the endpoints diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 4f3e2208c..8480ea0b3 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -113,10 +113,10 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non # Now get a unit vector rotated to the requested angle. I use -angle # because shapely rotates clockwise, but my geometry textbooks taught # me to consider angles as counter-clockwise from the X axis. - direction = InkstitchPoint(1, 0).rotate(-angle) + direction = InkstitchPoint(-1, 0).rotate(-angle) # and get a normal vector - normal = direction.rotate(math.pi / 2) + normal = direction.rotate(-math.pi / 2) # I'll start from the center, move in the normal direction some amount, # and then walk left and right half_length in each direction to create diff --git a/lib/utils/clamp_path.py b/lib/utils/clamp_path.py index 0f58f83c8..fcb34a4bf 100644 --- a/lib/utils/clamp_path.py +++ b/lib/utils/clamp_path.py @@ -1,5 +1,6 @@ from shapely.geometry import LineString, MultiPolygon from shapely.geometry import Point as ShapelyPoint +from shapely.ops import nearest_points from shapely.prepared import prep from .geometry import (Point, ensure_geometry_collection, @@ -14,6 +15,9 @@ def path_to_segments(path): def segments_to_path(segments): """Convert a list of contiguous LineStrings into a list of Points.""" + if not segments: + return [] + coords = [segments[0].coords[0]] for segment in segments: @@ -68,6 +72,22 @@ def find_border(polygon, point): return polygon.exterior +def clamp_fully_external_path(path, polygon): + """Clamp a path that lies entirely outside a polygon.""" + + start = ShapelyPoint(path[0]) + end = ShapelyPoint(path[-1]) + + start_on_outline = nearest_points(start, polygon.exterior)[1].buffer(0.01, resolution=1) + end_on_outline = nearest_points(end, polygon.exterior)[1].buffer(0.01, resolution=1) + + border_pieces = ensure_multi_line_string(polygon.exterior.difference(MultiPolygon((start_on_outline, end_on_outline)))).geoms + border_pieces = fix_starting_point(border_pieces) + shorter = min(border_pieces, key=lambda piece: piece.length) + + return adjust_line_end(shorter, start) + + def clamp_path_to_polygon(path, polygon): """Constrain a path to a Polygon. @@ -143,4 +163,7 @@ def clamp_path_to_polygon(path, polygon): else: was_inside = False + if not result: + return clamp_fully_external_path(path, polygon) + return segments_to_path(result) diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 24cf8459e..541d9cbc3 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -197,6 +197,37 @@ def cut_path(points, length): return [Point(*point) for point in subpath.coords] +def offset_points(pos1, pos2, offset_px, offset_proportional): + """Expand or contract two points about their midpoint. + + This is useful for pull compensation and insetting underlay. + """ + + distance = (pos1 - pos2).length() + + if distance < 0.0001: + # if they're the same point, we don't know which direction + # to offset in, so we have to just return the points + return pos1, pos2 + + # calculate the offset for each side + offset_a = offset_px[0] + (distance * offset_proportional[0]) + offset_b = offset_px[1] + (distance * offset_proportional[1]) + offset_total = offset_a + offset_b + + # don't contract beyond the midpoint, or we'll start expanding + if offset_total < -distance: + scale = -distance / offset_total + offset_a = offset_a * scale + offset_b = offset_b * scale + + # convert offset to float before using because it may be a numpy.float64 + out1 = pos1 + (pos1 - pos2).unit() * float(offset_a) + out2 = pos2 + (pos2 - pos1).unit() * float(offset_b) + + return out1, out2 + + class Point: def __init__(self, x: typing.Union[float, numpy.float64], y: typing.Union[float, numpy.float64]): self.x = float(x) diff --git a/lib/utils/list.py b/lib/utils/list.py index efa3969eb..e5838b5a4 100644 --- a/lib/utils/list.py +++ b/lib/utils/list.py @@ -21,3 +21,7 @@ def poprandom(sequence, rng=_rng): sequence[index] = last_item return item + + +def is_all_zeroes(sequence): + return all(item == 0 for item in sequence)