diff options
| -rw-r--r-- | icons/randomize_20x20.png | bin | 0 -> 697 bytes | |||
| -rw-r--r-- | lib/elements/element.py | 18 | ||||
| -rw-r--r-- | lib/elements/satin_column.py | 292 | ||||
| -rw-r--r-- | lib/elements/stroke.py | 2 | ||||
| -rw-r--r-- | lib/extensions/base.py | 2 | ||||
| -rw-r--r-- | lib/extensions/params.py | 41 | ||||
| -rw-r--r-- | lib/stitch_plan/stitch.py | 11 | ||||
| -rw-r--r-- | lib/stitch_plan/stitch_group.py | 8 | ||||
| -rw-r--r-- | lib/stitches/__init__.py | 1 | ||||
| -rw-r--r-- | lib/stitches/ripple_stitch.py | 4 | ||||
| -rw-r--r-- | lib/stitches/running_stitch.py | 44 | ||||
| -rw-r--r-- | lib/svg/tags.py | 9 | ||||
| -rw-r--r-- | lib/utils/geometry.py | 2 | ||||
| -rw-r--r-- | lib/utils/prng.py | 58 |
14 files changed, 374 insertions, 118 deletions
diff --git a/icons/randomize_20x20.png b/icons/randomize_20x20.png Binary files differnew file mode 100644 index 00000000..40d8e88b --- /dev/null +++ b/icons/randomize_20x20.png diff --git a/lib/elements/element.py b/lib/elements/element.py index 436423a4..75f9fc10 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -163,6 +163,8 @@ class EmbroideryElement(object): return self.get_split_float_param(param, default) * PIXELS_PER_MM def set_param(self, name, value): + # Sets a param on the node backing this element. Used by params dialog. + # After calling, this element is invalid due to caching and must be re-created to use the new value. param = INKSTITCH_ATTRIBS[name] self.node.set(param, str(value)) @@ -251,6 +253,22 @@ class EmbroideryElement(object): return self.get_boolean_param('force_lock_stitches', False) @property + @param('random_seed', + _('Random seed'), + tooltip=_('Use a specific seed for randomized attributes. Uses the element ID if empty.'), + 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 def path(self): # A CSP is a "cubic superpath". # diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index 26fc411d..e4d3ba94 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -4,18 +4,21 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from copy import deepcopy +import itertools from itertools import chain -import numpy as np +import typing +import numpy as np +from inkex import paths +from shapely import affinity as shaffinity from shapely import geometry as shgeo from shapely.ops import nearest_points -from inkex import paths - from ..i18n import _ from ..stitch_plan import StitchGroup from ..svg import line_strings_to_csp, point_lists_to_csp -from ..utils import Point, cache, cut, cut_multiple +from ..utils import Point, cache, cut, cut_multiple, prng +from ..stitches import running_stitch from .element import EmbroideryElement, param, PIXELS_PER_MM from .validation import ValidationError, ValidationWarning @@ -84,13 +87,68 @@ class SatinColumn(EmbroideryElement): _('Maximum stitch length'), tooltip=_('Maximum stitch length for split stitches.'), type='float', unit="mm") - def max_stitch_length(self): + def max_stitch_length_px(self): return self.get_float_param("max_stitch_length_mm") or None @property + @param('random_width_decrease_percent', + _('Random percentage of satin width decrease'), + tooltip=_('shorten stitch across rails at most this percent. ' + 'Two values separated by a space may be used for an aysmmetric effect.'), + default=0, type='float', unit="% (each side)", sort_index=91) + @cache + def random_width_decrease(self): + return self.get_split_float_param("random_width_decrease_percent", (0, 0)) / 100 + + @property + @param('random_width_increase_percent', + _('Random percentage of satin width increase'), + tooltip=_('lengthen stitch across rails at most this percent. ' + 'Two values separated by a space may be used for an aysmmetric effect.'), + default=0, type='float', unit="% (each side)", sort_index=90) + @cache + def random_width_increase(self): + return self.get_split_float_param("random_width_increase_percent", (0, 0)) / 100 + + @property + @param('random_zigzag_spacing_percent', + _('Random zig-zag spacing percentage'), + tooltip=_('Amount of random jitter added to stitch length.'), + default=0, type='float', unit="± %", sort_index=92) + def random_zigzag_spacing(self): + # peak-to-peak distance between zigzags + return max(self.get_float_param("random_zigzag_spacing_percent", 0), 0) / 100 + + @property + @param('random_split_phase', + _('Random phase for split stitches'), + tooltip=_('Controls whether split stitches are centered or with a random phase (which may increase stitch count).'), + default=False, type='boolean', sort_index=96) + def random_split_phase(self): + return self.get_boolean_param('random_split_phase') + + @property + @param('min_random_split_length_mm', + _('Minimum length for random-phase split.'), + tooltip=_('Defaults to maximum stitch length. Smaller values allow for a transition between single-stitch and split-stitch.'), + default='', type='float', unit='mm', sort_index=97) + def min_random_split_length_px(self): + if self.max_stitch_length_px is None: + return None + return min(self.max_stitch_length_px, self.get_float_param('min_random_split_length_mm', self.max_stitch_length_px)) + + @property + @param('random_split_jitter_percent', + _('Random jitter for split stitches'), + tooltip=_('Randomizes split stitch length if random phase is enabled, stitch position if disabled.'), + default=0, type='float', unit="± %", sort_index=95) + def random_split_jitter(self): + return min(max(self.get_float_param("random_split_jitter_percent", 0), 0), 100) / 100 + + @property @param('short_stitch_inset', _('Short stitch inset'), - tooltip=_('Stitches in areas with high density will be shortened by this amount.'), + tooltip=_('Stitches in areas with high density will be inset by this amount.'), type='float', unit="%", default=15) def short_stitch_inset(self): @@ -99,7 +157,7 @@ class SatinColumn(EmbroideryElement): @property @param('short_stitch_distance_mm', _('Short stitch distance'), - tooltip=_('Do short stitches if the distance between stitches is smaller than this.'), + tooltip=_('Inset stitches if the distance between stitches is smaller than this.'), type='float', unit="mm", default=0.25) def short_stitch_distance(self): @@ -497,6 +555,22 @@ class SatinColumn(EmbroideryElement): for rung in self.rungs: point_lists.append(self.flatten_subpath(rung)) + # If originally there were only two subpaths (no rungs) with same number of rails, the rails may now + # have two rails with different number of points, and still no rungs, let's add one. + + if not self.rungs: + rails = [shgeo.LineString(reversed(self.flatten_subpath(rail))) for rail in self.rails] + rails.reverse() + path_list = rails + + rung_start = path_list[0].interpolate(0.1) + rung_end = path_list[1].interpolate(0.1) + rung = shgeo.LineString((rung_start, rung_end)) + # make it a bit bigger so that it definitely intersects + rung = shaffinity.scale(rung, 1.1, 1.1) + path_list.append(rung) + return (self._path_list_to_satins(path_list)) + return self._csp_to_satin(point_lists_to_csp(point_lists)) def apply_transform(self): @@ -545,7 +619,7 @@ class SatinColumn(EmbroideryElement): """ # like in do_satin() - points = list(chain.from_iterable(zip(*self.plot_points_on_rails(self.zigzag_spacing)))) + points = list(chain.from_iterable(self.plot_points_on_rails(self.zigzag_spacing))) if isinstance(split_point, float): index_of_closest_stitch = int(round(len(points) * split_point)) @@ -633,7 +707,7 @@ class SatinColumn(EmbroideryElement): @cache def center_line(self): # similar technique to do_center_walk() - center_walk, _ = self.plot_points_on_rails(self.zigzag_spacing, (0, 0), (-0.5, -0.5)) + center_walk = [p[0] for p in self.plot_points_on_rails(self.zigzag_spacing, (0, 0), (-0.5, -0.5))] return shgeo.LineString(center_walk) def offset_points(self, pos1, pos2, offset_px, offset_proportional): @@ -696,19 +770,23 @@ class SatinColumn(EmbroideryElement): distance_remaining -= segment_length pos = segment_end - def plot_points_on_rails(self, spacing, offset_px=(0, 0), offset_proportional=(0, 0)): + def plot_points_on_rails(self, spacing, offset_px=(0, 0), offset_proportional=(0, 0), use_random=False + ) -> typing.List[typing.Tuple[Point, Point]]: # Take a section from each rail in turn, and plot out an equal number # of points on both rails. Return the points plotted. The points will # be contracted or expanded by offset using self.offset_points(). - def add_pair(pos0, pos1): - pos0, pos1 = self.offset_points(pos0, pos1, offset_px, offset_proportional) - points[0].append(pos0) - points[1].append(pos1) + # pre-cache ramdomised parameters to avoid property calls in loop + if use_random: + seed = prng.joinArgs(self.random_seed, "satin-points") + offset_proportional_min = np.array(offset_proportional) - self.random_width_decrease + offset_range = (self.random_width_increase + self.random_width_decrease) + spacing_sigma = spacing * self.random_zigzag_spacing - points = [[], []] + pairs = [] to_travel = 0 + cycle = 0 for section0, section1 in self.flattened_sections: # Take one section at a time, delineated by the rungs. For each @@ -771,23 +849,32 @@ class SatinColumn(EmbroideryElement): old_center = new_center if to_travel <= 0: - add_pair(pos0, pos1) - to_travel = spacing + if use_random: + roll = prng.uniformFloats(seed, cycle) + offset_prop = offset_proportional_min + roll[0:2] * offset_range + to_travel = spacing + ((roll[2] - 0.5) * 2 * spacing_sigma) + else: + offset_prop = offset_proportional + to_travel = spacing + + a, b = self.offset_points(pos0, pos1, offset_px, offset_prop) + pairs.append((a, b)) + cycle += 1 if to_travel > 0: - add_pair(pos0, pos1) + pairs.append((pos0, pos1)) - return points + return pairs def do_contour_underlay(self): # "contour walk" underlay: do stitches up one side and down the # other. - forward, back = self.plot_points_on_rails( + pairs = self.plot_points_on_rails( self.contour_underlay_stitch_length, -self.contour_underlay_inset_px, -self.contour_underlay_inset_percent/100) - stitches = (forward + list(reversed(back))) + stitches = [p[0] for p in pairs] + [p[1] for p in reversed(pairs)] if self._center_walk_is_odd(): - stitches = (list(reversed(back)) + forward) + stitches = list(reversed(stitches)) return StitchGroup( color=self.color, @@ -801,16 +888,16 @@ class SatinColumn(EmbroideryElement): inset_prop = -np.array([self.center_walk_underlay_position, 100-self.center_walk_underlay_position]) / 100 # Do it like contour underlay, but inset all the way to the center. - forward, back = self.plot_points_on_rails( + pairs = self.plot_points_on_rails( self.center_walk_underlay_stitch_length, (0, 0), inset_prop) stitches = [] for i in range(self.center_walk_underlay_repeats): if i % 2 == 0: - stitches += forward + stitches += [p[0] for p in pairs] else: - stitches += list(reversed(back)) + stitches += [p[1] for p in reversed(pairs)] return StitchGroup( color=self.color, @@ -830,27 +917,26 @@ class SatinColumn(EmbroideryElement): patch = StitchGroup(color=self.color) - sides = self.plot_points_on_rails(self.zigzag_underlay_spacing / 2.0, + pairs = self.plot_points_on_rails(self.zigzag_underlay_spacing / 2.0, -self.zigzag_underlay_inset_px, -self.zigzag_underlay_inset_percent/100) if self._center_walk_is_odd(): - sides = [list(reversed(sides[0])), list(reversed(sides[1]))] + pairs = list(reversed(pairs)) # This organizes the points in each side in the order that they'll be # visited. - sides = [sides[0][::2] + list(reversed(sides[0][1::2])), - sides[1][1::2] + list(reversed(sides[1][::2]))] + # take a points, from each side in turn, then go backed over the other points + points = [p[i % 2] for i, p in enumerate(pairs)] + list(reversed([p[i % 2] for i, p in enumerate(pairs, 1)])) - # This fancy bit of iterable magic just repeatedly takes a point - # from each side in turn. + max_len = self.zigzag_underlay_max_stitch_length last_point = None - for point in chain.from_iterable(zip(*sides)): - if last_point and self.zigzag_underlay_max_stitch_length: - if last_point.distance(point) > self.zigzag_underlay_max_stitch_length: - points, count = self._get_split_points(last_point, point, self.zigzag_underlay_max_stitch_length) - for point in points: - patch.add_stitch(point) + for point in points: + if last_point and max_len: + if last_point.distance(point) > max_len: + split_points = running_stitch.split_segment_even_dist(last_point, point, max_len) + for p in split_points: + patch.add_stitch(p) last_point = point patch.add_stitch(point) @@ -869,28 +955,47 @@ class SatinColumn(EmbroideryElement): patch = StitchGroup(color=self.color) # pull compensation is automatically converted from mm to pixels by get_float_param - sides = self.plot_points_on_rails( + pairs = self.plot_points_on_rails( self.zigzag_spacing, self.pull_compensation_px, - self.pull_compensation_percent/100 + self.pull_compensation_percent/100, + True, ) - if self.max_stitch_length: - return self.do_split_stitch(patch, sides) + max_stitch_length = self.max_stitch_length_px + length_sigma = self.random_split_jitter + random_phase = self.random_split_phase + min_split_length = self.min_random_split_length_px + seed = self.random_seed - # short stitches are not not included into the split stitch - # they would move the points in a maybe unwanted behaviour - if self.short_stitch_inset > 0: - self._do_short_stitches(sides) + short_pairs = self.inset_short_stitches_sawtooth(pairs) - # Like in zigzag_underlay(): take a point from each side in turn. - for point in chain.from_iterable(zip(*sides)): - patch.add_stitch(point) + last_point = None + last_short_point = None + last_count = None + for i, (a, b), (a_short, b_short) in zip(itertools.count(0), pairs, short_pairs): + if last_point is not None: + split_points, _ = self.get_split_points( + last_point, a, last_short_point, a_short, max_stitch_length, last_count, + length_sigma, random_phase, min_split_length, prng.joinArgs(seed, 'satin-split', 2*i)) + patch.add_stitches(split_points, ("satin_column", "satin_split_stitch")) + + patch.add_stitch(a_short) + patch.stitches[-1].add_tags(("satin_column", "satin_column_edge")) + + split_points, last_count = self.get_split_points( + a, b, a_short, b_short, max_stitch_length, None, + length_sigma, random_phase, min_split_length, prng.joinArgs(seed, 'satin-split', 2*i+1)) + patch.add_stitches(split_points, ("satin_column", "satin_split_stitch")) + + patch.add_stitch(b_short) + patch.stitches[-1].add_tags(("satin_column", "satin_column_edge")) + last_point = b + last_short_point = b_short if self._center_walk_is_odd(): patch.stitches = list(reversed(patch.stitches)) - patch.add_tags(("satin_column", "satin_column_edge")) return patch def do_e_stitch(self): @@ -903,15 +1008,16 @@ class SatinColumn(EmbroideryElement): patch = StitchGroup(color=self.color) - sides = self.plot_points_on_rails( + pairs = self.plot_points_on_rails( self.zigzag_spacing, self.pull_compensation_px, - self.pull_compensation_percent/100 + self.pull_compensation_percent/100, + self.random_width_decrease.any() and self.random_width_increase.any() and self.random_zigzag_spacing, ) # "left" and "right" here are kind of arbitrary designations meaning # a point from the first and second rail respectively - for left, right in zip(*sides): + for left, right in pairs: patch.add_stitch(left) patch.add_stitch(right) patch.add_stitch(left) @@ -922,49 +1028,49 @@ class SatinColumn(EmbroideryElement): patch.add_tags(("satin_column", "e_stitch")) return patch - def do_split_stitch(self, patch, sides): - # stitches exceeding the maximum stitch length will be divided into equal parts through additional stitches - for i, (left, right) in enumerate(zip(*sides)): - patch.add_stitch(left) - patch.stitches[-1].add_tags(("satin_column", "satin_column_edge")) - points, count = self._get_split_points(left, right, self.max_stitch_length) - for point in points: - patch.add_stitch(point) - patch.stitches[-1].add_tags(("satin_column", "satin_split_stitch")) - patch.add_stitch(right) - patch.stitches[-1].add_tags(("satin_column", "satin_column_edge")) - # it is possible that the way back has a different length from the first - # but it looks ugly if the points differ too much - # so let's make sure they have at least the same amount of divisions - if not i+1 >= len(sides[0]): - points, count = self._get_split_points(right, sides[0][i+1], self.max_stitch_length, count) - for point in points: - patch.add_stitch(point) - patch.stitches[-1].add_tags(("satin_column", "satin_split_stitch")) - if self._center_walk_is_odd(): - patch.stitches = list(reversed(patch.stitches)) - return patch - - def _get_split_points(self, left, right, max_stitch_length, count=None): - points = [] - distance = left.distance(right) - split_count = count or int(-(-distance // max_stitch_length)) - for i in range(split_count): - line = shgeo.LineString((left, right)) - split_point = line.interpolate((i+1)/split_count, normalized=True) - points.append(Point(split_point.x, split_point.y)) - return [points, split_count] - - def _do_short_stitches(self, sides): - for i, (left, right) in enumerate(zip(*sides)): + def get_split_points(self, a, b, a_short, b_short, length, count=None, length_sigma=0.0, random_phase=False, min_split_length=None, seed=None): + if not length: + return ([], None) + if min_split_length is None: + min_split_length = length + distance = a.distance(b) + if distance <= min_split_length: + return ([], 1) + if random_phase: + points = running_stitch.split_segment_random_phase(a_short, b_short, length, length_sigma, seed) + return (points, None) + elif count is not None: + points = running_stitch.split_segment_even_n(a, b, count, length_sigma, seed) + return (points, count) + else: + points = running_stitch.split_segment_even_dist(a, b, length, length_sigma, seed) + return (points, len(points) + 1) + + def inset_short_stitches_sawtooth(self, pairs): + min_dist = self.short_stitch_distance + inset = min(self.short_stitch_inset, 0.5) + max_stitch_length = None if self.random_split_phase else self.max_stitch_length_px + if not min_dist or not inset: + return pairs + + shortened = [] + for i, (a, b) in enumerate(pairs): if i % 2 == 0: + shortened.append((a, b)) continue - if left.distance(sides[0][i-1]) < self.short_stitch_distance: - split_point = self._get_inset_point(left, right, self.short_stitch_inset) - sides[0][i] = Point(split_point.x, split_point.y) - if right.distance(sides[1][i-1]) < self.short_stitch_distance: - split_point = self._get_inset_point(right, left, self.short_stitch_inset) - sides[1][i] = Point(split_point.x, split_point.y) + dist = a.distance(b) + inset_px = inset * dist + if max_stitch_length and not self.random_split_phase: + # make sure inset is less than split etitch length + inset_px = min(inset_px, max_stitch_length / 3) + + offset_px = [0, 0] + if a.distance(pairs[i-1][0]) < min_dist: + offset_px[0] = -inset_px + if b.distance(pairs[i-1][0]) < min_dist: + offset_px[1] = -inset_px + shortened.append(self.offset_points(a, b, offset_px, (0, 0))) + return shortened def _get_inset_point(self, point1, point2, distance_fraction): return point1 * (1 - distance_fraction) + point2 * distance_fraction diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 00e069b2..a95edf70 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -12,7 +12,7 @@ from inkex import Transform from ..i18n import _ from ..marker import get_marker_elements from ..stitch_plan import StitchGroup -from ..stitches import bean_stitch, running_stitch +from ..stitches.running_stitch import bean_stitch, running_stitch from ..stitches.ripple_stitch import ripple_stitch from ..svg import get_node_transform, parse_length_with_units from ..utils import Point, cache diff --git a/lib/extensions/base.py b/lib/extensions/base.py index cf94714c..c2f76b27 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -179,6 +179,8 @@ class InkstitchExtension(inkex.Effect): return nodes def get_nodes(self, troubleshoot=False): + # Postorder traversal of selected nodes and their descendants. + # Returns all nodes if there is no selection. return self.descendants(self.document.getroot(), troubleshoot=troubleshoot) def get_elements(self, troubleshoot=False): diff --git a/lib/extensions/params.py b/lib/extensions/params.py index df62128f..306e5e56 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -7,9 +7,11 @@ import os import sys +import traceback from collections import defaultdict from copy import copy from itertools import groupby, zip_longest +from secrets import randbelow import wx from wx.lib.scrolledpanel import ScrolledPanel @@ -34,6 +36,8 @@ class ParamsTab(ScrolledPanel): def __init__(self, *args, **kwargs): self.params = kwargs.pop('params', []) self.name = kwargs.pop('name', None) + + # TODO: this is actually a list of embroidery elements, not DOM nodes, and needs to be renamed self.nodes = kwargs.pop('nodes') kwargs["style"] = wx.TAB_TRAVERSAL ScrolledPanel.__init__(self, *args, **kwargs) @@ -78,6 +82,9 @@ class ParamsTab(ScrolledPanel): self.pencil_icon = wx.Image(os.path.join(get_resource_dir( "icons"), "pencil_20x20.png")).ConvertToBitmap() + self.randomize_icon = wx.Image(os.path.join(get_resource_dir( + "icons"), "randomize_20x20.png")).ConvertToBitmap() + self.__set_properties() self.__do_layout() @@ -187,6 +194,22 @@ class ParamsTab(ScrolledPanel): return values + def on_reroll(self, event): + if len(self.nodes) == 1: + new_seed = str(randbelow(int(1e8))) + input = self.param_inputs['random_seed'] + input.SetValue(new_seed) + self.changed_inputs.add(input) + else: + for node in self.nodes: + new_seed = str(randbelow(int(1e8))) + node.set_param('random_seed', new_seed) + + self.enable_change_indicator('random_seed') + event.Skip() + if self.on_change_hook: + self.on_change_hook(self) + def apply(self): values = self.get_values() for node in self.nodes: @@ -363,7 +386,12 @@ class ParamsTab(ScrolledPanel): self.param_inputs[param.name] = input - col4 = wx.StaticText(self, label=param.unit or "") + if param.type == 'random_seed': + col4 = wx.Button(self, wx.ID_ANY, _("Re-roll")) + col4.Bind(wx.EVT_BUTTON, self.on_reroll) + col4.SetBitmap(self.randomize_icon) + else: + col4 = wx.StaticText(self, label=param.unit or "") if param.select_items is not None: input.Hide() @@ -379,6 +407,7 @@ class ParamsTab(ScrolledPanel): self.inputs_to_params = {v: k for k, v in self.param_inputs.items()} box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10) + self.SetSizer(box) self.update_choice_widgets() @@ -400,11 +429,6 @@ class ParamsTab(ScrolledPanel): self.param_change_indicators[param].SetToolTip( _('This parameter will be saved when you click "Apply and Quit"')) - self.changed_inputs.add(self.param_inputs[param]) - - if self.on_change_hook: - self.on_change_hook(self) - # end of class SatinPane @@ -488,9 +512,10 @@ class SettingsFrame(wx.Frame): except SystemExit: wx.CallAfter(self._show_warning) raise - except Exception: + except Exception as e: # Ignore errors. This can be things like incorrect paths for # satins or division by zero caused by incorrect param values. + traceback.print_exception(e, file=sys.stderr) pass return patches @@ -611,6 +636,8 @@ class Params(InkstitchExtension): return classes def get_nodes_by_class(self): + # returns embroidery elements (not nodes) by class + # TODO: rename this nodes = self.get_nodes() nodes_by_class = defaultdict(list) diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py index 0d46b85d..3bfa7075 100644 --- a/lib/stitch_plan/stitch.py +++ b/lib/stitch_plan/stitch.py @@ -4,6 +4,7 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from ..utils.geometry import Point +from shapely import geometry as shgeo class Stitch(Point): @@ -17,13 +18,13 @@ class Stitch(Point): # Allow creating a Stitch from another Stitch. Attributes passed as # arguments will override any existing attributes. base_stitch = x - self.x = base_stitch.x - self.y = base_stitch.y - elif isinstance(x, Point): + self.x: float = base_stitch.x + self.y: float = base_stitch.y + elif isinstance(x, (Point, shgeo.Point)): # Allow creating a Stitch from a Point point = x - self.x = point.x - self.y = point.y + self.x: float = point.x + self.y: float = point.y else: Point.__init__(self, x, y) diff --git a/lib/stitch_plan/stitch_group.py b/lib/stitch_plan/stitch_group.py index 21beebe1..6dbeb9e5 100644 --- a/lib/stitch_plan/stitch_group.py +++ b/lib/stitch_plan/stitch_group.py @@ -43,14 +43,14 @@ class StitchGroup: # This method allows `len(patch)` and `if patch: return len(self.stitches) - def add_stitches(self, stitches): + def add_stitches(self, stitches, tags=None): for stitch in stitches: - self.add_stitch(stitch) + self.add_stitch(stitch, tags=tags) - def add_stitch(self, stitch): + def add_stitch(self, stitch, tags=None): if not isinstance(stitch, Stitch): # probably a Point - stitch = Stitch(stitch) + stitch = Stitch(stitch, tags=tags) self.stitches.append(stitch) diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py index b0ff64fc..cfa05e51 100644 --- a/lib/stitches/__init__.py +++ b/lib/stitches/__init__.py @@ -6,7 +6,6 @@ from .auto_fill import auto_fill from .fill import legacy_fill from .guided_fill import guided_fill -from .running_stitch import * # Can't put this here because we get a circular import :( # from .auto_satin import auto_satin diff --git a/lib/stitches/ripple_stitch.py b/lib/stitches/ripple_stitch.py index 6b7ce6ca..a66eff74 100644 --- a/lib/stitches/ripple_stitch.py +++ b/lib/stitches/ripple_stitch.py @@ -74,12 +74,12 @@ def _get_satin_ripple_helper_lines(stroke): length = stroke.grid_size or stroke.running_stitch_length # use satin column points for satin like build ripple stitches - rail_points = SatinColumn(stroke.node).plot_points_on_rails(length) + rail_pairs = SatinColumn(stroke.node).plot_points_on_rails(length) steps = _get_steps(stroke.get_line_count(), exponent=stroke.exponent, flip=stroke.flip_exponent) helper_lines = [] - for point0, point1 in zip(*rail_points): + for point0, point1 in rail_pairs: helper_lines.append([]) helper_line = LineString((point0, point1)) for step in steps: diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index 8c86eb7c..34729109 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -4,13 +4,51 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. import math +import typing from copy import copy -from shapely.geometry import LineString +import numpy as np +from shapely import geometry as shgeo +from ..utils import prng """ Utility functions to produce running stitches. """ +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 [] + line = shgeo.LineString((a, b)) + + splits = np.array(range(1, segments)) / segments + if random_seed is not None: + jitters = (prng.nUniformFloats(len(splits), random_seed) * 2) - 1 + splits = splits + jitters * (jitter_sigma / segments) + + # sort the splits in case a bad roll transposes any of them + return [line.interpolate(x, normalized=True) for x in sorted(splits)] + + +def split_segment_even_dist(a, b, max_length: float, jitter_sigma: float = 0.0, random_seed=None) -> typing.List[shgeo.Point]: + distance = shgeo.Point(a).distance(shgeo.Point(b)) + segments = math.ceil(distance / max_length) + return split_segment_even_n(a, b, segments, jitter_sigma, random_seed) + + +def split_segment_random_phase(a, b, length: float, length_sigma: float, random_seed: str) -> typing.List[shgeo.Point]: + line = shgeo.LineString([a, b]) + progress = length * prng.uniformFloats(random_seed, "phase")[0] + splits = [progress] + distance = line.length + if progress >= distance: + return [] + for x in prng.iterUniformFloats(random_seed): + progress += length * (1 + length_sigma * (x - 0.5) * 2) + if progress >= distance: + break + splits.append(progress) + return [line.interpolate(x, normalized=False) for x in splits] + + def running_stitch(points, stitch_length, tolerance): """Generate running stitch along a path. @@ -28,7 +66,7 @@ def running_stitch(points, stitch_length, tolerance): # simplify will remove as many points as possible while ensuring that the # resulting path stays within the specified tolerance of the original path. - path = LineString(points) + path = shgeo.LineString(points) simplified = path.simplify(tolerance, preserve_topology=False) # save the points that simplify picked and make sure we stitch them @@ -45,7 +83,7 @@ def running_stitch(points, stitch_length, tolerance): # Now split each section up evenly into stitches, each with a length no # greater than the specified stitch_length. - section_ls = LineString(section) + section_ls = shgeo.LineString(section) section_length = section_ls.length if section_length > stitch_length: # a fractional stitch needs to be rounded up, which will make all diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 059396bd..0227274c 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -125,11 +125,18 @@ inkstitch_attribs = [ 'pull_compensation_mm', 'pull_compensation_percent', 'stroke_first', + 'random_width_decrease_percent', + 'random_width_increase_percent', + 'random_zigzag_spacing_percent', + 'random_split_phase', + 'random_split_jitter_percent', + 'min_random_split_length_mm', # stitch_plan 'invisible_layers', - # Legacy + # All elements 'trim_after', 'stop_after', + 'random_seed', 'manual_stitch', ] for attrib in inkstitch_attribs: diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 98f40709..0ca13d8f 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -148,7 +148,7 @@ def cut_path(points, length): class Point: - def __init__(self, x, y): + def __init__(self, x: float, y: float): self.x = x self.y = y diff --git a/lib/utils/prng.py b/lib/utils/prng.py new file mode 100644 index 00000000..2ec037c6 --- /dev/null +++ b/lib/utils/prng.py @@ -0,0 +1,58 @@ +from hashlib import blake2s +from math import ceil +from itertools import count, chain +import numpy as np + +# Framework for reproducable pseudo-random number generation. + +# Unlike python's random module (which uses a stateful generator based on global variables), +# a counter-mode PRNG like uniformFloats can be used to generate multiple, independent random streams +# by including an additional parameter before the loop counter. +# This allows different aspects of an embroidery element to not effect each other's rolls, +# making random generation resistant to small edits in the control paths or refactoring. +# Using multiple counters for n-dimentional random streams is also possible and is useful for grid-like structures. + + +def joinArgs(*args): + # Stringifies parameters into a slash-separated string for use in hash keys. + # Idempotent and associative. + return "/".join([str(x) for x in args]) + + +MAX_UNIFORM_INT = 2 ** 32 - 1 + + +def uniformInts(*args): + # Single pseudo-random drawing determined by the joined parameters. + # To get a longer sequence of random numbers, call this loop with a counter as one of the parameters. + # Returns 8 uniformly random uint32. + + s = joinArgs(*args) + # blake2s is python's fastest hash algorithm for small inputs and is designed to be usable as a PRNG. + h = blake2s(s.encode()).hexdigest() + nums = [] + for i in range(0, 64, 8): + nums.append(int(h[i:i+8], 16)) + return np.array(nums) + + +def uniformFloats(*args): + # Single pseudo-random drawing determined by the joined parameters. + # To get a longer sequence of random numbers, call this loop with a counter as one of the parameters. + # Returns an array of 8 floats in the range [0,1] + return uniformInts(*args) / MAX_UNIFORM_INT + + +def nUniformFloats(n: int, *args): + # returns a fixed number (which may exceed 8) of floats in the range [0,1] + seed = joinArgs(*args) + nBlocks = ceil(n/8) + blocks = [uniformFloats(seed, x) for x in range(nBlocks)] + return np.concatenate(blocks)[0:n] + + +def iterUniformFloats(*args): + # returns an infinite sequence of floats in the range [0,1] + seed = joinArgs(*args) + blocks = map(lambda x: list(uniformFloats(seed, x)), count(0)) + return chain.from_iterable(blocks) |
