summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGeorge Steel <george.steel@gmail.com>2022-12-26 20:13:48 -0500
committerGeorge Steel <george.steel@gmail.com>2022-12-26 20:13:48 -0500
commite28ea888a9604052d6d7b94c8e29e34b74242994 (patch)
treed9c20a8b56aca08791cde310e47a0a562c5ad97b
parentb63f19b2d0747769c60c8a2a52489ee30fa02a07 (diff)
use random oracle for randomized satin columns and redo split stitches
-rw-r--r--lib/elements/satin_column.py298
-rw-r--r--lib/elements/stroke.py2
-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.py43
-rw-r--r--lib/svg/tags.py5
-rw-r--r--lib/utils/geometry.py2
-rw-r--r--lib/utils/prng.py44
10 files changed, 265 insertions, 153 deletions
diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py
index 4d3bbdc3..bfb716af 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -3,8 +3,8 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-import random
from copy import deepcopy
+import itertools
from itertools import chain
import numpy as np
@@ -16,7 +16,8 @@ from shapely.ops import nearest_points
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
@@ -85,48 +86,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_split_factor',
- _('Random Split Factor'),
- tooltip=_('randomize position for split stitches.'),
- type='int', unit="%", sort_index=70)
- def random_split_factor(self):
- return min(max(self.get_int_param("random_split_factor", 0), 0), 100)
-
- @property
- @param('random_zigzag_spacing',
- _('Zig-zag spacing randomness(peak-to-peak)'),
- tooltip=_('percentage of randomness of Peak-to-peak distance between zig-zags.'),
- type='int', unit="%", sort_index=64)
- def random_zigzag_spacing(self):
- # peak-to-peak distance between zigzags
- return max(self.get_int_param("random_zigzag_spacing", 0), 0)
-
- @property
@param('random_width_decrease_percent',
_('Random percentage of satin width decrease'),
- tooltip=_('shorten stitch across rails at most this percent.'
+ tooltip=_('shorten stitch across rails at most this percent. '
'Two values separated by a space may be used for an aysmmetric effect.'),
- type='int', unit="% (each side)", sort_index=60)
+ default=0, type='float', unit="% (each side)", sort_index=91)
+ @cache
def random_width_decrease_percent(self):
return self.get_split_float_param("random_width_decrease_percent", (0, 0))
@property
@param('random_width_increase_percent',
_('Random percentage of satin width increase'),
- tooltip=_('lengthen stitch across rails at most this percent.'
+ tooltip=_('lengthen stitch across rails at most this percent. '
'Two values separated by a space may be used for an aysmmetric effect.'),
- type='int', unit="% (each side)", sort_index=60)
+ default=0, type='float', unit="% (each side)", sort_index=90)
+ @cache
def random_width_increase_percent(self):
return self.get_split_float_param("random_width_increase_percent", (0, 0))
@property
+ @param('random_zigzag_spacing',
+ _('Random zig-zag spacing percentage increase'),
+ tooltip=_('Percentage of stitch length added randomply Peak-to-peak distance between zig-zags.'),
+ 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", 0), 0)
+
+ @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=95)
+ 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=96)
+ 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_length_percent',
+ _('Random jitter split stitch'),
+ tooltip=_('randomizes split stitch length if random phase is emabled, stitch position if disabled.'),
+ type='float', unit="%", sort_index=97)
+ def random_split_length(self):
+ return min(max(self.get_float_param("random_split_length_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):
@@ -135,7 +156,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):
@@ -329,10 +350,6 @@ class SatinColumn(EmbroideryElement):
return self.get_float_param("zigzag_underlay_max_stitch_length_mm") or None
@property
- def use_seed(self):
- return self.get_int_param("use_seed", 0)
-
- @property
@cache
def shape(self):
# This isn't used for satins at all, but other parts of the code
@@ -601,7 +618,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))
@@ -689,7 +706,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):
@@ -752,19 +769,22 @@ 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) -> list[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_percent/100
+ offset_range = (self.random_width_increase_percent + self.random_width_decrease_percent) / 100
+ spacing_range = spacing * self.random_zigzag_spacing / 100
- 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
@@ -827,26 +847,32 @@ class SatinColumn(EmbroideryElement):
old_center = new_center
if to_travel <= 0:
-
- mismatch0 = random.uniform(-self.random_width_decrease_percent[0], self.random_width_increase_percent[0]) / 100
- mismatch1 = random.uniform(-self.random_width_decrease_percent[1], self.random_width_increase_percent[1]) / 100
- add_pair(pos0 + (pos0 - pos1) * mismatch0, pos1 + (pos1 - pos0) * mismatch1)
- to_travel = spacing * (random.uniform(1, 1 + self.random_zigzag_spacing/100))
+ if use_random:
+ roll = prng.uniformFloats(seed, cycle)
+ offset_prop = offset_proportional_min + roll[0:2] * offset_range
+ to_travel = spacing + roll[2] * spacing_range
+ 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,
@@ -860,16 +886,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,
@@ -889,27 +915,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)
@@ -928,28 +953,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_length
+ 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):
@@ -962,15 +1006,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_percent.any() and self.random_width_increase_percent.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)
@@ -981,54 +1026,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))
- random_move = 0
- for i in range(split_count):
- line = shgeo.LineString((left, right))
-
- if self.random_split_factor and i != split_count-1:
- random_move = random.uniform(-self.random_split_factor / 100, self.random_split_factor / 100)
-
- split_point = line.interpolate((i + 1 + random_move) / 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
@@ -1040,16 +1080,6 @@ class SatinColumn(EmbroideryElement):
# beziers. The boundary points between beziers serve as "checkpoints",
# allowing the user to control how the zigzags flow around corners.
- # If no seed is defined, compute one randomly using time to seed, otherwise, use stored seed
-
- if self.use_seed == 0:
- random.seed()
- x = random.randint(1, 10000)
- random.seed(x)
- self.set_param("use_seed", x)
- else:
- random.seed(self.use_seed)
-
patch = StitchGroup(color=self.color)
if self.center_walk_underlay:
diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py
index 2854adaf..6bd6a54a 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/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..fd6d4572 100644
--- a/lib/stitches/running_stitch.py
+++ b/lib/stitches/running_stitch.py
@@ -6,11 +6,48 @@
import math
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: str | None = None) -> 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: str | None = None) -> 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) -> 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 +65,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 +82,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 931c82c7..0d17ac46 100644
--- a/lib/svg/tags.py
+++ b/lib/svg/tags.py
@@ -126,9 +126,10 @@ inkstitch_attribs = [
'stroke_first',
'random_width_decrease_percent',
'random_width_increase_percent',
- 'random_split_factor',
'random_zigzag_spacing',
- 'use_seed',
+ 'random_split_phase',
+ 'random_split_length_percent',
+ 'min_random_split_length_mm',
# stitch_plan
'invisible_layers',
# All elements
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..518049cb
--- /dev/null
+++ b/lib/utils/prng.py
@@ -0,0 +1,44 @@
+from hashlib import blake2s
+from math import ceil
+from itertools import count, chain
+import numpy as np
+
+
+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.
+ # Returns 4 uniformly random uint64.
+ s = joinArgs(*args)
+ 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):
+ # 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)