From d32a8fd4661331da0affb15623a2ec9a9eac5c44 Mon Sep 17 00:00:00 2001 From: George Steel Date: Sun, 5 May 2024 13:55:33 -0400 Subject: [PATCH] Add randomized running and fill stitches (#2830) Add a mode to running stitch that uses randomized phase and stitch length instead of even spacing. This greatly reduces moire effects when stitching closely-spaced curves in running-stitch-based fills. Add option for randomized running stitch to: ripple stitch circular fill contour fill guided fill auto-fill When is randomization is not selected, ripple stitch will use even running stitch when staggers are set to 0 (default) and the stagger algorithm from guided fill (which does not look nice with a stagger period of 0) when staggers is nonzero. Also includes fix for satin contour underlays (missing tolerance default) mentioned in #2814. This sets the default tolerance to 0.2mm, which is the largest tolerance guaranteed to be backwards-compatible with existing designs using the default inset of 0.4mm. Original commits: * fix satin underlay tolerance default * Add randomized running stitch, make available in ripple stitch, circular, and contour * add randomized guided fill * make ripple stitch use even stitching when not staggering or randomizing. * add random auto-fill and switch jitter parameter to a percentage (matches satin) * fix comments --- lib/elements/fill_stitch.py | 92 +++++++++++++++++++++++++------- lib/elements/satin_column.py | 18 ++++--- lib/elements/stroke.py | 58 +++++++++++++++++--- lib/extensions/fill_to_stroke.py | 6 +-- lib/stitches/auto_fill.py | 22 +++++--- lib/stitches/circular_fill.py | 31 +++++++---- lib/stitches/contour_fill.py | 19 ++++--- lib/stitches/fill.py | 75 ++++++++++++++------------ lib/stitches/guided_fill.py | 25 ++++++--- lib/stitches/meander_fill.py | 6 +-- lib/stitches/ripple_stitch.py | 60 +++++++++++++-------- lib/stitches/running_stitch.py | 68 +++++++++++++++++++++-- lib/svg/tags.py | 2 + lib/utils/smoothing.py | 4 +- 14 files changed, 349 insertions(+), 137 deletions(-) diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index cb1d52257..56b9888d6 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -374,7 +374,7 @@ class FillStitch(EmbroideryElement): tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. ' 'Skipping it decreases stitch count and density.'), type='boolean', - sort_index=26, + sort_index=30, select_items=[('fill_method', 'auto_fill'), ('fill_method', 'guided_fill'), ('fill_method', 'linear_gradient_fill'), @@ -390,7 +390,7 @@ class FillStitch(EmbroideryElement): tooltip=_('The flip option can help you with routing your stitch path. ' 'When you enable flip, stitching goes from right-to-left instead of left-to-right.'), type='boolean', - sort_index=27, + sort_index=31, select_items=[('fill_method', 'legacy_fill')], default=False) def flip(self): @@ -402,7 +402,7 @@ class FillStitch(EmbroideryElement): _('Reverse fill'), tooltip=_('Reverses fill path.'), type='boolean', - sort_index=28, + sort_index=32, select_items=[('fill_method', 'legacy_fill')], default=False) def reverse(self): @@ -415,7 +415,7 @@ class FillStitch(EmbroideryElement): tooltip=_('If this option is disabled, the ending point will only be used to define a general direction for ' 'stitch routing. When enabled the last section will end at the defined spot.'), type='boolean', - sort_index=30, + sort_index=33, select_items=[('fill_method', 'linear_gradient_fill')], default=False ) @@ -431,7 +431,7 @@ class FillStitch(EmbroideryElement): type='boolean', default=True, select_items=[('fill_method', 'auto_fill'), ('fill_method', 'guided_fill'), ('fill_method', 'circular_fill')], - sort_index=30) + sort_index=40) def underpath(self): return self.get_boolean_param('underpath', True) @@ -449,7 +449,7 @@ class FillStitch(EmbroideryElement): ('fill_method', 'circular_fill'), ('fill_method', 'linear_gradient_fill'), ('fill_method', 'tartan_fill')], - sort_index=31) + sort_index=41) def running_stitch_length(self): return max(self.get_float_param("running_stitch_length_mm", 2.5), 0.01) @@ -461,10 +461,40 @@ class FillStitch(EmbroideryElement): unit='mm', type='float', default=0.1, - sort_index=32) + sort_index=43) def running_stitch_tolerance(self): return max(self.get_float_param("running_stitch_tolerance_mm", 0.2), 0.01) + @property + @param('enable_random_stitches', + _('Randomize stitches'), + tooltip=_('Randomize stitch length and phase instead of dividing evenly or staggering. ' + 'This is recommended for closely-spaced curved fills to avoid Moiré artefacts.'), + type='boolean', + select_items=[('fill_method', 'auto_fill'), + ('fill_method', 'contour_fill'), + ('fill_method', 'guided_fill'), + ('fill_method', 'circular_fill')], + default=False, + sort_index=44) + def enable_random_stitches(self): + return self.get_boolean_param('enable_random_stitches', False) + + @property + @param('random_stitch_length_jitter_percent', + _('Random stitch length jitter'), + tooltip=_('Amount to vary the length of each stitch by when randomizing.'), + unit='± %', + type='float', + select_items=[('fill_method', 'auto_fill'), + ('fill_method', 'contour_fill'), + ('fill_method', 'guided_fill'), + ('fill_method', 'circular_fill')], + default=10, + sort_index=46) + def random_stitch_length_jitter(self): + return max(self.get_float_param("random_stitch_length_jitter_percent", 10), 0.0) / 100.0 + @property @param('repeats', _('Repeats'), @@ -473,7 +503,7 @@ class FillStitch(EmbroideryElement): default="1", select_items=[('fill_method', 'meander_fill'), ('fill_method', 'circular_fill')], - sort_index=33) + sort_index=50) def repeats(self): return max(1, self.get_int_param("repeats", 1)) @@ -489,7 +519,7 @@ class FillStitch(EmbroideryElement): ('fill_method', 'circular_fill'), ('fill_method', 'tartan_fill')], default=0, - sort_index=34) + sort_index=51) def bean_stitch_repeats(self): return self.get_multiple_int_param("bean_stitch_repeats", "0") @@ -501,7 +531,7 @@ class FillStitch(EmbroideryElement): type='float', select_items=[('fill_method', 'meander_fill')], default=0, - sort_index=35) + sort_index=60) @cache def zigzag_spacing(self): return self.get_float_param("zigzag_spacing_mm", 0) @@ -514,7 +544,7 @@ class FillStitch(EmbroideryElement): type='float', select_items=[('fill_method', 'meander_fill')], default=3, - sort_index=36) + sort_index=61) @cache def zigzag_width(self): return self.get_float_param("zigzag_width_mm", 3) @@ -527,7 +557,7 @@ class FillStitch(EmbroideryElement): type='int', default="2", select_items=[('fill_method', 'tartan_fill')], - sort_index=35 + sort_index=62 ) def rows_per_thread(self): return max(1, self.get_int_param("rows_per_thread", 2)) @@ -540,7 +570,7 @@ class FillStitch(EmbroideryElement): type='int', default=0, select_items=[('fill_method', 'tartan_fill')], - sort_index=36) + sort_index=63) def herringbone_width(self): return self.get_float_param('herringbone_width_mm', 0) @@ -648,7 +678,11 @@ class FillStitch(EmbroideryElement): @param('random_seed', _('Random seed'), tooltip=_('Use a specific seed for randomized attributes. Uses the element ID if empty.'), - select_items=[('fill_method', 'meander_fill')], + select_items=[('fill_method', 'auto_fill'), + ('fill_method', 'contour_fill'), + ('fill_method', 'guided_fill'), + ('fill_method', 'circular_fill'), + ('fill_method', 'meander_fill')], type='random_seed', default='', sort_index=100) @@ -963,7 +997,10 @@ class FillStitch(EmbroideryElement): self.skip_last, starting_point, ending_point, - self.underpath + self.underpath, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed, ) ) return [stitch_group] @@ -986,21 +1023,30 @@ class FillStitch(EmbroideryElement): self.running_stitch_tolerance, self.smoothness, starting_point, - self.avoid_self_crossing + self.avoid_self_crossing, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed ) elif self.contour_strategy == 1: stitches = contour_fill.single_spiral( tree, self.max_stitch_length, self.running_stitch_tolerance, - starting_point + starting_point, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed ) elif self.contour_strategy == 2: stitches = contour_fill.double_spiral( tree, self.max_stitch_length, self.running_stitch_tolerance, - starting_point + starting_point, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed ) stitch_group = StitchGroup( @@ -1038,7 +1084,10 @@ class FillStitch(EmbroideryElement): starting_point, ending_point, self.underpath, - self.guided_fill_strategy + self.guided_fill_strategy, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed, ) ) return [stitch_group] @@ -1089,7 +1138,10 @@ class FillStitch(EmbroideryElement): starting_point, ending_point, self.underpath, - target + target, + self.enable_random_stitches, + self.random_stitch_length_jitter, + self.random_seed, ) stitch_group = StitchGroup( diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index a33afb8b8..9cf7bc738 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -372,10 +372,11 @@ class SatinColumn(EmbroideryElement): unit='mm', group=_('Contour Underlay'), type='float', + default=0.2, ) def contour_underlay_stitch_tolerance(self): - tolerance = self.get_float_param("contour_underlay_stitch_tolerance_mm", self.contour_underlay_stitch_length) - return max(tolerance, 0.01) + tolerance = self.get_float_param("contour_underlay_stitch_tolerance_mm", 0.2) + return max(tolerance, 0.01 * PIXELS_PER_MM) # sanity check to prevent crash from excessively-small values @property @param('contour_underlay_inset_mm', @@ -428,11 +429,12 @@ class SatinColumn(EmbroideryElement): ), unit='mm', group=_('Center-Walk Underlay'), - type='float' + type='float', + default=0.2 ) def center_walk_underlay_stitch_tolerance(self): - tolerance = self.get_float_param("center_walk_underlay_stitch_tolerance_mm", self.contour_underlay_stitch_length) - return max(tolerance, 0.01) + tolerance = self.get_float_param("center_walk_underlay_stitch_tolerance_mm", 0.2) + return max(tolerance, 0.01 * PIXELS_PER_MM) @property @param('center_walk_underlay_repeats', @@ -1171,12 +1173,12 @@ class SatinColumn(EmbroideryElement): self.contour_underlay_stitch_tolerance, -self.contour_underlay_inset_px, -self.contour_underlay_inset_percent/100) - first_side = running_stitch.running_stitch( + first_side = running_stitch.even_running_stitch( [points[0] for points in pairs], self.contour_underlay_stitch_length, self.contour_underlay_stitch_tolerance ) - second_side = running_stitch.running_stitch( + second_side = running_stitch.even_running_stitch( [points[1] for points in pairs], self.contour_underlay_stitch_length, self.contour_underlay_stitch_tolerance @@ -1209,7 +1211,7 @@ class SatinColumn(EmbroideryElement): (0, 0), inset_prop) points = [points[0] for points in pairs] - stitches = running_stitch.running_stitch(points, self.center_walk_underlay_stitch_length, self.center_walk_underlay_stitch_tolerance) + stitches = running_stitch.even_running_stitch(points, self.center_walk_underlay_stitch_length, self.center_walk_underlay_stitch_tolerance) for i in range(self.center_walk_underlay_repeats - 1): if i % 2 == 0: diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 2ce02dbd3..0bb18ce8c 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -13,8 +13,7 @@ from ..i18n import _ from ..marker import get_marker_elements from ..stitch_plan import StitchGroup from ..stitches.ripple_stitch import ripple_stitch -from ..stitches.running_stitch import (bean_stitch, running_stitch, - zigzag_stitch) +from ..stitches.running_stitch import (bean_stitch, running_stitch, zigzag_stitch) from ..svg import get_node_transform, parse_length_with_units from ..svg.clip import get_clip_path from ..threads import ThreadColor @@ -130,6 +129,30 @@ class Stroke(EmbroideryElement): def running_stitch_tolerance(self): return max(self.get_float_param("running_stitch_tolerance_mm", 0.2), 0.01) + @property + @param('enable_random_stitches', + _('Randomize stitches'), + tooltip=_('Randomize stitch length and phase instead of dividing evenly or staggering. ' + 'This is recommended for closely-spaced curved fills to avoid Moiré artefacts.'), + type='boolean', + select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], + default=False, + sort_index=5) + def enable_random_stitches(self): + return self.get_boolean_param('enable_random_stitches', False) + + @property + @param('random_stitch_length_jitter_percent', + _('Random stitch length jitter'), + tooltip=_('Amount to vary the length of each stitch by when randomizing.'), + unit='± %', + type='float', + select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], + default=10, + sort_index=6) + def random_stitch_length_jitter(self): + return max(self.get_float_param("random_stitch_length_jitter_percent", 10), 0.0) / 100 + @property @param('max_stitch_length_mm', _('Max stitch length'), @@ -203,10 +226,11 @@ class Stroke(EmbroideryElement): _('Stagger lines this many times before repeating'), tooltip=_('Length of the cycle by which successive stitch lines are staggered. ' 'Fractional values are allowed and can have less visible diagonals than integer values. ' + 'A value of 0 (default) disables staggering and instead stitches evenly.' 'For linear ripples only.'), type='int', select_items=[('stroke_method', 'ripple_stitch')], - default=1, + default=0, sort_index=9) def staggers(self): return self.get_float_param("staggers", 1) @@ -367,6 +391,24 @@ class Stroke(EmbroideryElement): def join_style(self): return self.get_int_param('join_style', 0) + @property + @param('random_seed', + _('Random seed'), + tooltip=_('Use a specific seed for randomized attributes. Uses the element ID if empty.'), + select_items=[('stroke_method', 'running_stitch'), + ('stroke_method', 'ripple_stitch')], + type='random_seed', + default='', + sort_index=100) + @cache + def random_seed(self) -> str: + seed = self.get_param('random_seed', '') + if not seed: + seed = self.node.get_id() or '' + # TODO(#1696): When inplementing grouped clones, join this with the IDs of any shadow roots, + # letting each instance without a specified seed get a different default. + return seed + @property @cache def is_closed(self): @@ -443,13 +485,14 @@ class Stroke(EmbroideryElement): # `self.zigzag_spacing` is the length for a zig and a zag # together (a V shape). Start with running stitch at half # that length: - stitch_group = self.running_stitch(path, zigzag_spacing / 2.0, self.running_stitch_tolerance) + stitch_group = self.running_stitch(path, zigzag_spacing / 2.0, self.running_stitch_tolerance, False, 0, "") stitch_group.stitches = zigzag_stitch(stitch_group.stitches, zigzag_spacing, stroke_width, pull_compensation) return stitch_group - def running_stitch(self, path, stitch_length, tolerance): - stitches = running_stitch(path, stitch_length, tolerance) + def running_stitch(self, path, stitch_length, tolerance, enable_random, random_sigma, random_seed): + # running stitch with repeats + stitches = running_stitch(path, stitch_length, tolerance, enable_random, random_sigma, random_seed) repeated_stitches = [] # go back and forth along the path as specified by self.repeats @@ -529,7 +572,8 @@ class Stroke(EmbroideryElement): stitch_group = self.simple_satin(path, self.zigzag_spacing, self.stroke_width, self.pull_compensation) # running stitch else: - stitch_group = self.running_stitch(path, self.running_stitch_length, self.running_stitch_tolerance) + stitch_group = self.running_stitch(path, self.running_stitch_length, self.running_stitch_tolerance, + self.enable_random_stitches, self.random_stitch_length_jitter, self.random_seed) # bean stitch if any(self.bean_stitch_repeats): stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches) diff --git a/lib/extensions/fill_to_stroke.py b/lib/extensions/fill_to_stroke.py index c33ede3d7..db5719a6c 100644 --- a/lib/extensions/fill_to_stroke.py +++ b/lib/extensions/fill_to_stroke.py @@ -10,7 +10,7 @@ from shapely.ops import linemerge, nearest_points, split, voronoi_diagram from ..elements import FillStitch, Stroke from ..i18n import _ -from ..stitches.running_stitch import running_stitch +from ..stitches.running_stitch import even_running_stitch from ..svg import get_correction_transform from ..utils import ensure_multi_line_string from ..utils.geometry import Point as InkstitchPoint @@ -110,11 +110,11 @@ class FillToStroke(InkstitchExtension): def _get_high_res_polygon(self, polygon): # use running stitch method - runs = [running_stitch(line_string_to_point_list(polygon.exterior), 1, 0.1)] + runs = [even_running_stitch(line_string_to_point_list(polygon.exterior), 1, 0.1)] if len(runs[0]) < 3: return for interior in polygon.interiors: - shape = running_stitch(line_string_to_point_list(interior), 1, 0.1) + shape = even_running_stitch(line_string_to_point_list(interior), 1, 0.1) if len(shape) >= 3: runs.append(shape) return MultiPolygon([(runs[0], runs[1:])]) diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index e90093ce3..159a869bf 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -15,6 +15,7 @@ from shapely import segmentize from shapely.ops import snap from shapely.strtree import STRtree + from ..debug import debug from ..stitch_plan import Stitch from ..svg import PIXELS_PER_MM @@ -25,8 +26,9 @@ from ..utils.geometry import (ensure_multi_line_string, line_string_to_point_list) from ..utils.smoothing import smooth_path from ..utils.threading import check_stop_flag +from ..utils.prng import join_args from .fill import intersect_region_with_grating, stitch_row -from .running_stitch import running_stitch +from .running_stitch import even_running_stitch class NoGratingsError(Exception): @@ -77,7 +79,10 @@ def auto_fill(shape, skip_last, starting_point, ending_point=None, - underpath=True): + underpath=True, + enable_random=False, + random_sigma=0.0, + random_seed=""): 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. @@ -102,7 +107,7 @@ def auto_fill(shape, path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) result = path_to_stitches(shape, path, travel_graph, fill_stitch_graph, angle, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance, - staggers, skip_last, underpath) + staggers, skip_last, underpath, enable_random, random_sigma, random_seed) return result @@ -335,7 +340,7 @@ def fallback(shape, running_stitch_length, running_stitch_tolerance): boundary = ensure_multi_line_string(shape.boundary) outline = boundary.geoms[0] - return running_stitch(line_string_to_point_list(outline), running_stitch_length, running_stitch_tolerance) + return even_running_stitch(line_string_to_point_list(outline), running_stitch_length, running_stitch_tolerance) @debug.time @@ -694,7 +699,7 @@ def travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tole if len(path) > 1: path = clamp_path_to_polygon(path, shape) - points = running_stitch(path, running_stitch_length, running_stitch_tolerance) + points = even_running_stitch(path, running_stitch_length, running_stitch_tolerance) stitches = [Stitch(point) for point in points] for stitch in stitches: @@ -718,7 +723,7 @@ def travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tole @debug.time def path_to_stitches(shape, path, travel_graph, fill_stitch_graph, angle, row_spacing, max_stitch_length, running_stitch_length, - running_stitch_tolerance, staggers, skip_last, underpath): + running_stitch_tolerance, staggers, skip_last, underpath, enable_random, random_sigma, random_seed): path = collapse_sequential_outline_edges(path, fill_stitch_graph) stitches = [] @@ -727,9 +732,10 @@ def path_to_stitches(shape, path, travel_graph, fill_stitch_graph, angle, row_sp if not path[0].is_segment(): stitches.append(Stitch(*path[0].nodes[0])) - for edge in path: + for i, edge in enumerate(path): if edge.is_segment(): - stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers, skip_last) + stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers, skip_last, + enable_random, random_sigma, join_args(random_seed, i)) travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', [])) else: stitches.extend(travel(shape, travel_graph, edge, running_stitch_length, running_stitch_tolerance, skip_last, underpath)) diff --git a/lib/stitches/circular_fill.py b/lib/stitches/circular_fill.py index 28346dd91..11fd04325 100644 --- a/lib/stitches/circular_fill.py +++ b/lib/stitches/circular_fill.py @@ -2,13 +2,15 @@ from networkx import is_empty from shapely import geometry as shgeo from shapely.ops import substring +from lib.utils import prng + from ..stitch_plan import Stitch -from ..utils.geometry import reverse_line_string +from ..utils.geometry import Point, reverse_line_string from .auto_fill import (build_fill_stitch_graph, build_travel_graph, collapse_sequential_outline_edges, fallback, find_stitch_path, graph_make_valid, travel) from .contour_fill import _make_fermat_spiral -from .running_stitch import bean_stitch, running_stitch +from .running_stitch import bean_stitch, even_running_stitch, running_stitch def circular_fill(shape, @@ -24,7 +26,10 @@ def circular_fill(shape, starting_point, ending_point, underpath, - target + target, + use_random, + running_stitch_length_jitter, + random_seed, ): # get furthest distance of the target point to a shape border @@ -35,8 +40,8 @@ def circular_fill(shape, if radius > distance: # if the shape is smaller than row_spacing, return a simple circle in the size of row_spacing - stitches = running_stitch([Stitch(*point) for point in center.buffer(radius).exterior.coords], - running_stitch_length, running_stitch_tolerance) + stitches = even_running_stitch([Stitch(*point) for point in center.buffer(radius).exterior.coords], + running_stitch_length, running_stitch_tolerance) return _apply_bean_stitch_and_repeats(stitches, repeats, bean_stitch_repeats) circles = [] @@ -61,16 +66,24 @@ def circular_fill(shape, # if we get a single linestrig (original shape is a circle), apply start and end commands and return path path = list(intersection.coords) path = _apply_start_end_commands(shape, path, starting_point, ending_point) - stitches = running_stitch([Stitch(*point) for point in path], running_stitch_length, running_stitch_tolerance) + stitches = running_stitch([Stitch(*point) for point in path], + running_stitch_length, + running_stitch_tolerance, + use_random, + running_stitch_length_jitter, + random_seed) return _apply_bean_stitch_and_repeats(stitches, repeats, bean_stitch_repeats) segments = [] - for line in intersection.geoms: + for n, line in enumerate(intersection.geoms): if isinstance(line, shgeo.LineString): # use running stitch here to adjust the stitch length - coords = running_stitch([Stitch(point[0], point[1]) for point in line.coords], + coords = running_stitch([Point(*point) for point in line.coords], running_stitch_length, - running_stitch_tolerance) + running_stitch_tolerance, + use_random, + running_stitch_length_jitter, + prng.join_args(random_seed, n)) segments.append([(point.x, point.y) for point in coords]) fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) diff --git a/lib/stitches/contour_fill.py b/lib/stitches/contour_fill.py index e19e1aadc..9eea90ab0 100644 --- a/lib/stitches/contour_fill.py +++ b/lib/stitches/contour_fill.py @@ -409,7 +409,10 @@ def _find_path_inner_to_outer(tree, node, offset, starting_point, avoid_self_cro return LineString(result_coords) -def inner_to_outer(tree, polygon, offset, stitch_length, tolerance, smoothness, starting_point, avoid_self_crossing): +def inner_to_outer(tree, polygon, offset, + stitch_length, tolerance, smoothness, + starting_point, avoid_self_crossing, + enable_random, random_sigma, random_seed): """Fill a shape with spirals, from innermost to outermost.""" stitch_path = _find_path_inner_to_outer(tree, 'root', offset, starting_point, avoid_self_crossing) @@ -419,7 +422,7 @@ def inner_to_outer(tree, polygon, offset, stitch_length, tolerance, smoothness, smoothed = smooth_path(points, smoothness) points = clamp_path_to_polygon(smoothed, polygon) - stitches = running_stitch(points, stitch_length, tolerance) + stitches = running_stitch(points, stitch_length, tolerance, enable_random, random_sigma, random_seed) return stitches @@ -515,24 +518,24 @@ def _check_and_prepare_tree_for_valid_spiral(tree): return process_node('root') -def single_spiral(tree, stitch_length, tolerance, starting_point): +def single_spiral(tree, stitch_length, tolerance, starting_point, enable_random, random_sigma, random_seed): """Fill a shape with a single spiral going from outside to center.""" - return _spiral_fill(tree, stitch_length, tolerance, starting_point, _make_spiral) + return _spiral_fill(tree, stitch_length, tolerance, starting_point, enable_random, random_sigma, random_seed, _make_spiral) -def double_spiral(tree, stitch_length, tolerance, starting_point): +def double_spiral(tree, stitch_length, tolerance, starting_point, enable_random, random_sigma, random_seed): """Fill a shape with a double spiral going from outside to center and back to outside. """ - return _spiral_fill(tree, stitch_length, tolerance, starting_point, _make_fermat_spiral) + return _spiral_fill(tree, stitch_length, tolerance, starting_point, enable_random, random_sigma, random_seed, _make_fermat_spiral) -def _spiral_fill(tree, stitch_length, tolerance, close_point, spiral_maker): +def _spiral_fill(tree, stitch_length, tolerance, close_point, enable_random, random_sigma, random_seed, spiral_maker): starting_point = close_point.coords[0] rings = _get_spiral_rings(tree) path = spiral_maker(rings, stitch_length, starting_point) path = [Stitch(*stitch) for stitch in path] - return running_stitch(path, stitch_length, tolerance) + return running_stitch(path, stitch_length, tolerance, enable_random, random_sigma, random_seed) def _get_spiral_rings(tree): diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index c492a6295..59501a9e2 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -12,6 +12,7 @@ from ..svg import PIXELS_PER_MM from ..utils import Point as InkstitchPoint from ..utils import cache from ..utils.threading import check_stop_flag +from .running_stitch import split_segment_random_phase def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, reverse, staggers, skip_last): @@ -51,46 +52,50 @@ def adjust_stagger(stitch, angle, row_spacing, max_stitch_length, staggers): return stitch - offset * east(angle) -def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers, skip_last=False): - # We want our stitches to look like this: - # - # ---*-----------*----------- - # ------*-----------*-------- - # ---------*-----------*----- - # ------------*-----------*-- - # ---*-----------*----------- - # - # Each successive row of stitches will be staggered, with - # num_staggers rows before the pattern repeats. A value of - # 4 gives a nice fill while hiding the needle holes. The - # first row is offset 0%, the second 25%, the third 50%, and - # the fourth 75%. - # - # Actually, instead of just starting at an offset of 0, we - # can calculate a row's offset relative to the origin. This - # way if we have two abutting fill regions, they'll perfectly - # tile with each other. That's important because we often get - # abutting fill regions from pull_runs(). - +def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers, skip_last, enable_random, random_sigma, random_seed): beg = Stitch(*beg, tags=('fill_row_start',)) - end = Stitch(*end, tags=('fill_row_end',)) - - row_direction = (end - beg).unit() - segment_length = (end - beg).length() - + end = Stitch(*end, tags=('fill_row_start',)) stitches.append(beg) - first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) + if enable_random: + stitches += split_segment_random_phase(beg, end, max_stitch_length, random_sigma, random_seed) + else: + # We want our stitches to look like this: + # + # ---*-----------*----------- + # ------*-----------*-------- + # ---------*-----------*----- + # ------------*-----------*-- + # ---*-----------*----------- + # + # Each successive row of stitches will be staggered, with + # num_staggers rows before the pattern repeats. A value of + # 4 gives a nice fill while hiding the needle holes. The + # first row is offset 0%, the second 25%, the third 50%, and + # the fourth 75%. + # + # Actually, instead of just starting at an offset of 0, we + # can calculate a row's offset relative to the origin. This + # way if we have two abutting fill regions, they'll perfectly + # tile with each other. That's important because we often get + # abutting fill regions from pull_runs(). - # we might have chosen our first stitch just outside this row, so move back in - if (first_stitch - beg) * row_direction < 0: - first_stitch += row_direction * max_stitch_length + row_direction = (end - beg).unit() + segment_length = (end - beg).length() - offset = (first_stitch - beg).length() + stitches.append(beg) - while offset < segment_length: - stitches.append(Stitch(beg + offset * row_direction, tags=('fill_row',))) - offset += max_stitch_length + first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) + + # we might have chosen our first stitch just outside this row, so move back in + if (first_stitch - beg) * row_direction < 0: + first_stitch += row_direction * max_stitch_length + + offset = (first_stitch - beg).length() + + while offset < segment_length: + stitches.append(Stitch(beg + offset * row_direction, tags=('fill_row',))) + offset += max_stitch_length if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM and not skip_last: stitches.append(end) @@ -189,7 +194,7 @@ def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length if (swap): (beg, end) = (end, beg) - stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers, skip_last) + stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers, skip_last, False, 0.0, "") swap = not swap diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py index bc7a3ab23..1b564bcb5 100644 --- a/lib/stitches/guided_fill.py +++ b/lib/stitches/guided_fill.py @@ -8,12 +8,15 @@ from shapely import geometry as shgeo from shapely.affinity import translate from shapely.ops import linemerge, nearest_points, unary_union +from lib.utils import prng + from ..debug import debug from ..stitch_plan import Stitch from ..utils.geometry import Point as InkstitchPoint from ..utils.geometry import (ensure_geometry_collection, ensure_multi_line_string, reverse_line_string) from ..utils.threading import check_stop_flag +from .running_stitch import random_running_stitch from .auto_fill import (auto_fill, build_fill_stitch_graph, build_travel_graph, collapse_sequential_outline_edges, find_stitch_path, graph_make_valid, travel) @@ -31,9 +34,13 @@ def guided_fill(shape, starting_point, ending_point, underpath, - strategy + strategy, + enable_random, + random_sigma, + random_seed, ): - segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing, num_staggers, max_stitch_length, strategy) + segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing, num_staggers, max_stitch_length, strategy, + enable_random, running_stitch_tolerance, random_sigma, random_seed,) if not segments: return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance, num_staggers, skip_last, starting_point, ending_point, underpath) @@ -231,7 +238,8 @@ def _get_start_row(line, shape, row_spacing, line_direction): return copysign(row, shape_direction * line_direction) -def intersect_region_with_grating_guideline(shape, line, row_spacing, num_staggers, max_stitch_length, strategy): +def intersect_region_with_grating_guideline(shape, line, row_spacing, num_staggers, max_stitch_length, strategy, + enable_random, tolerance, random_sigma, random_seed): line = prepare_guide_line(line, shape) debug.log_line_string(shape.exterior, "guided fill shape") @@ -261,13 +269,14 @@ def intersect_region_with_grating_guideline(shape, line, row_spacing, num_stagge offset_line = clean_offset_line(offset_line) - if strategy == 1 and direction == -1: - # negative parallel offsets are reversed, so we need to compensate - offset_line = reverse_line_string(offset_line) - debug.log_line_string(offset_line, f"offset {row}") - stitched_line = apply_stitches(offset_line, max_stitch_length, num_staggers, row_spacing, row) + if enable_random: + points = [InkstitchPoint(*x) for x in offset_line.coords] + stitched_line = shgeo.LineString(random_running_stitch( + points, max_stitch_length, tolerance, random_sigma, prng.join_args(random_seed, row))) + else: + stitched_line = apply_stitches(offset_line, max_stitch_length, num_staggers, row_spacing, row) intersection = shape.intersection(stitched_line) if not intersection.is_empty and shape_envelope.intersects(stitched_line): diff --git a/lib/stitches/meander_fill.py b/lib/stitches/meander_fill.py index 16510ddee..94e38e1f9 100644 --- a/lib/stitches/meander_fill.py +++ b/lib/stitches/meander_fill.py @@ -14,7 +14,7 @@ from ..utils.list import poprandom from ..utils.prng import iter_uniform_floats from ..utils.smoothing import smooth_path from ..utils.threading import check_stop_flag -from .running_stitch import bean_stitch, running_stitch, zigzag_stitch +from .running_stitch import bean_stitch, even_running_stitch, zigzag_stitch def meander_fill(fill, shape, original_shape, shape_index, starting_point, ending_point): @@ -179,10 +179,10 @@ def post_process(points, shape, original_shape, fill): smoothed_points = [InkStitchPoint.from_tuple(point) for point in smoothed_points] if fill.zigzag_spacing > 0: - stitches = running_stitch(smoothed_points, fill.zigzag_spacing / 2, fill.running_stitch_tolerance) + stitches = even_running_stitch(smoothed_points, fill.zigzag_spacing / 2, fill.running_stitch_tolerance) stitches = zigzag_stitch(stitches, fill.zigzag_spacing, fill.zigzag_width, 0) else: - stitches = running_stitch(smoothed_points, fill.running_stitch_length, fill.running_stitch_tolerance) + stitches = even_running_stitch(smoothed_points, fill.running_stitch_length, fill.running_stitch_tolerance) if fill.clip: stitches = clamp_path_to_polygon(stitches, original_shape) diff --git a/lib/stitches/ripple_stitch.py b/lib/stitches/ripple_stitch.py index 156059e93..02dbdeb29 100644 --- a/lib/stitches/ripple_stitch.py +++ b/lib/stitches/ripple_stitch.py @@ -7,10 +7,11 @@ from shapely.geometry import LineString, Point from ..elements import SatinColumn from ..utils import Point as InkstitchPoint +from ..utils import prng from ..utils.geometry import line_string_to_point_list from ..utils.threading import check_stop_flag from .guided_fill import apply_stitches -from .running_stitch import running_stitch +from .running_stitch import even_running_stitch, running_stitch def ripple_stitch(stroke): @@ -46,33 +47,50 @@ def _get_stitches(stroke, is_linear, lines, skip_start): return _get_staggered_stitches(stroke, lines, skip_start) else: points = [point for line in lines for point in line] - return running_stitch(points, stroke.running_stitch_length, stroke.running_stitch_tolerance) + return running_stitch(points, + stroke.running_stitch_length, + stroke.running_stitch_tolerance, + stroke.enable_random_stitches, + stroke.random_stitch_length_jitter, + stroke.random_seed) def _get_staggered_stitches(stroke, lines, skip_start): stitches = [] + stitch_length = stroke.running_stitch_length + tolerance = stroke.running_stitch_tolerance + enable_random = stroke.enable_random_stitches + length_sigma = stroke.random_stitch_length_jitter + random_seed = stroke.random_seed + last_point = None for i, line in enumerate(lines): - stitched_line = [] connector = [] if i != 0 and stroke.join_style == 0: if i % 2 == 0: - last_point = lines[i-1][0] first_point = line[0] else: - last_point = lines[i-1][-1] first_point = line[-1] - connector = running_stitch([InkstitchPoint(*last_point), InkstitchPoint(*first_point)], - stroke.running_stitch_length, - stroke.running_stitch_tolerance) - points = list(apply_stitches(LineString(line), stroke.running_stitch_length, stroke.staggers, 0.5, i, stroke.running_stitch_tolerance).coords) - stitched_line.extend([InkstitchPoint(*point) for point in points]) - if i % 2 == 1 and stroke.join_style == 0: - # reverse every other row in linear ripple - stitched_line.reverse() - if (stroke.join_style == 1 and ((i % 2 == 1 and skip_start % 2 == 0) or - (i % 2 == 0 and skip_start % 2 == 1))): - stitched_line.reverse() - stitched_line = connector + stitched_line + connector = even_running_stitch([last_point, first_point], + stitch_length, tolerance)[1:-1] + if stroke.join_style == 0: + should_reverse = i % 2 == 1 + elif stroke.join_style == 1: + should_reverse = (i + skip_start) % 2 == 1 + + if enable_random or stroke.staggers == 0: + if should_reverse: + line.reverse() + points = running_stitch(line, stitch_length, tolerance, enable_random, length_sigma, prng.join_args(random_seed, i)) + stitched_line = connector + points + else: + # uses the guided fill alforithm to stagger rows of stitches + points = list(apply_stitches(LineString(line), stitch_length, stroke.staggers, 0.5, i, tolerance).coords) + stitched_line = [InkstitchPoint(*point) for point in points] + if should_reverse: + stitched_line.reverse() + stitched_line = connector + stitched_line + + last_point = stitched_line[-1] stitches.extend(stitched_line) return stitches @@ -137,9 +155,9 @@ def _get_helper_lines(stroke): if len(lines) > 1: return True, _get_satin_ripple_helper_lines(stroke) else: - outline = LineString(running_stitch(line_string_to_point_list(lines[0]), - stroke.grid_size or stroke.running_stitch_length, - stroke.running_stitch_tolerance)) + outline = LineString(even_running_stitch(line_string_to_point_list(lines[0]), + stroke.grid_size or stroke.running_stitch_length, + stroke.running_stitch_tolerance)) if stroke.is_closed: return False, _get_circular_ripple_helper_lines(stroke, outline) @@ -278,7 +296,7 @@ def _get_guided_helper_lines(stroke, outline, max_distance): def _generate_guided_helper_lines(stroke, outline, max_distance, guide_line): # helper lines are generated by making copies of the outline along the guide line line_point_dict = defaultdict(list) - outline = LineString(running_stitch(line_string_to_point_list(outline), max_distance, stroke.running_stitch_tolerance)) + outline = LineString(even_running_stitch(line_string_to_point_list(outline), max_distance, stroke.running_stitch_tolerance)) center = outline.centroid center = InkstitchPoint(center.x, center.y) diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index 139a20066..6144a9770 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -18,6 +18,10 @@ from ..utils.threading import check_stop_flag """ Utility functions to produce running stitches. """ +def lerp(a, b, t: float) -> float: + return (1 - t) * a + t * b + + def split_segment_even_n(a, b, segments: int, jitter_sigma: float = 0.0, random_seed=None) -> typing.List[shgeo.Point]: if segments <= 1: return [] @@ -216,8 +220,6 @@ def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, to last = points[0] stitches = [] while i is not None and i < len(points): - check_stop_flag() - d = last.distance(points[i]) + distLeft[i] if d == 0: return stitches @@ -231,6 +233,35 @@ def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, to return stitches +def stitch_curve_randomly(points: typing.Sequence[Point], stitch_length: float, tolerance: float, stitch_length_sigma: float, random_seed: str): + min_stitch_length = max(0, stitch_length * (1 - stitch_length_sigma)) + max_stitch_length = stitch_length * (1 + stitch_length_sigma) + # Will split a straight line into stitches of random length within the range. + # Attempts to randomize phase so that the distribution of outputs does not depend on direction. + # Includes end point but not start point. + if len(points) < 2: + return [] + + i = 1 + last = points[0] + last_shortened = 0.0 + stitches = [] + rand_iter = iter(prng.iter_uniform_floats(random_seed)) + while i is not None and i < len(points): + r = next(rand_iter) + # If the last stitch was shortened due to tolerance (or this is the first stitch), + # reduce the lower length limit to randomize the phase. This prevents moiré and asymmetry. + stitch_len = lerp(last_shortened, 1.0, r) * lerp(min_stitch_length, max_stitch_length, r) + + stitch, newidx = take_stitch(last, points, i, stitch_len, tolerance) + i = newidx + if stitch is not None: + stitches.append(stitch) + last_shortened = min(last.distance(stitch) / stitch_len, 1.0) + last = stitch + return stitches + + def path_to_curves(points: typing.List[Point], min_len: float): # split a path at obvious corner points so that they get stitched exactly # min_len controls the minimum length after splitting for which it won't split again, @@ -265,17 +296,44 @@ def path_to_curves(points: typing.List[Point], min_len: float): return curves -def running_stitch(points, stitch_length, tolerance): - # Turn a continuous path into a running stitch. +def even_running_stitch(points, stitch_length, tolerance): + # Turn a continuous path into a running stitch with as close to even stitch length as possible + # (including the first and last segments), keeping it within the tolerance of the path. + # This should not be used for stitching tightly-spaced parallel curves + # as it tends to produce ugly moiré effects in those situations. + # In these situations, random_running_stitch sould be used even if the maximum stitch length range is a single value. if not points: return stitches = [points[0]] for curve in path_to_curves(points, 2 * tolerance): - # segments longer than twice the tollerance will usually be forced by it, so set that as the minimum for corner detection + # segments longer than twice the tolerance will usually be forced by it, so set that as the minimum for corner detection + check_stop_flag() stitches.extend(stitch_curve_evenly(curve, stitch_length, tolerance)) return stitches +def random_running_stitch(points, stitch_length, tolerance, stitch_length_sigma, random_seed): + # Turn a continuous path into a running stitch with randomized phase and stitch length, + # keeping it within the tolerance of the path. + # This is suitable for tightly-spaced parallel curves. + if not points: + return + stitches = [points[0]] + for i, curve in enumerate(path_to_curves(points, 2 * tolerance)): + # segments longer than twice the tolerance will usually be forced by it, so set that as the minimum for corner detection + check_stop_flag() + stitches.extend(stitch_curve_randomly(curve, stitch_length, tolerance, stitch_length_sigma, prng.join_args(random_seed, i))) + return stitches + + +def running_stitch(points, stitch_length, tolerance, is_random, stitch_length_sigma, random_seed): + # running stitch with a choice of algorithm + if is_random: + return random_running_stitch(points, stitch_length, tolerance, stitch_length_sigma, random_seed) + else: + return even_running_stitch(points, stitch_length, tolerance) + + def bean_stitch(stitches, repeats, tags_to_ignore=None): """Generate bean stitch from a set of stitches. diff --git a/lib/svg/tags.py b/lib/svg/tags.py index c05a9712a..cc7fdda5f 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -106,6 +106,8 @@ inkstitch_attribs = [ 'rows_per_thread', 'herringbone_width_mm', 'tartan_angle', + 'enable_random_stitches', + 'random_stitch_length_jitter_percent', # stroke 'stroke_method', 'bean_stitch_repeats', diff --git a/lib/utils/smoothing.py b/lib/utils/smoothing.py index 2c210e372..49771b6ce 100644 --- a/lib/utils/smoothing.py +++ b/lib/utils/smoothing.py @@ -2,7 +2,7 @@ import numpy as np from scipy.interpolate import splprep, splev from .geometry import Point, coordinate_list_to_point_list -from ..stitches.running_stitch import running_stitch +from ..stitches.running_stitch import even_running_stitch def _remove_duplicate_coordinates(coords_array): @@ -48,7 +48,7 @@ def smooth_path(path, smoothness=1.0): # # Fortunately, we can convert the path to segments that are mostly the same # length by using the running stitch algorithm. - path = running_stitch(coordinate_list_to_point_list(path), 5 * smoothness, smoothness / 2) + path = even_running_stitch(coordinate_list_to_point_list(path), 5 * smoothness, smoothness / 2) # splprep blows up on duplicated consecutive points with "Invalid inputs" coords = _remove_duplicate_coordinates(np.array(path))