summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/elements/element.py18
-rw-r--r--lib/elements/satin_column.py292
-rw-r--r--lib/elements/stroke.py2
-rw-r--r--lib/extensions/base.py2
-rw-r--r--lib/extensions/params.py41
-rw-r--r--lib/stitch_plan/stitch.py11
-rw-r--r--lib/stitch_plan/stitch_group.py8
-rw-r--r--lib/stitches/__init__.py1
-rw-r--r--lib/stitches/ripple_stitch.py4
-rw-r--r--lib/stitches/running_stitch.py44
-rw-r--r--lib/svg/tags.py9
-rw-r--r--lib/utils/geometry.py2
-rw-r--r--lib/utils/prng.py58
13 files changed, 374 insertions, 118 deletions
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)