summaryrefslogtreecommitdiff
path: root/lib/elements/stroke.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/elements/stroke.py')
-rw-r--r--lib/elements/stroke.py351
1 files changed, 301 insertions, 50 deletions
diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py
index 763167ad..6edd2c9e 100644
--- a/lib/elements/stroke.py
+++ b/lib/elements/stroke.py
@@ -7,16 +7,38 @@ import sys
import shapely.geometry
-from .element import EmbroideryElement, param
+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 ..svg import parse_length_with_units
+from ..stitches.ripple_stitch import ripple_stitch
+from ..svg import get_node_transform, parse_length_with_units
from ..utils import Point, cache
+from .element import EmbroideryElement, param
+from .validation import ValidationWarning
warned_about_legacy_running_stitch = False
+class IgnoreSkipValues(ValidationWarning):
+ name = _("Ignore skip")
+ description = _("Skip values are ignored, because there was no line left to embroider.")
+ steps_to_solve = [
+ _('* Open the params dialog with this object selected'),
+ _('* Reduce Skip values or increase number of lines'),
+ ]
+
+
+class MultipleGuideLineWarning(ValidationWarning):
+ name = _("Multiple Guide Lines")
+ description = _("This object has multiple guide lines, but only the first one will be used.")
+ steps_to_solve = [
+ _("* Remove all guide lines, except for one.")
+ ]
+
+
class Stroke(EmbroideryElement):
element_name = _("Stroke")
@@ -34,15 +56,36 @@ class Stroke(EmbroideryElement):
return self.get_style("stroke-dasharray") is not None
@property
- @param('running_stitch_length_mm',
- _('Running stitch length'),
- tooltip=_('Length of stitches in running stitch mode.'),
- unit='mm',
- type='float',
- default=1.5,
- sort_index=3)
- def running_stitch_length(self):
- return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
+ @param('stroke_method',
+ _('Method'),
+ type='dropdown',
+ default=0,
+ # 0: run/simple satin, 1: manual, 2: ripple
+ options=[_("Running Stitch"), _("Ripple")],
+ sort_index=0)
+ def stroke_method(self):
+ return self.get_int_param('stroke_method', 0)
+
+ @property
+ @param('manual_stitch',
+ _('Manual stitch placement'),
+ tooltip=_("Stitch every node in the path. All other options are ignored."),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 0)],
+ sort_index=1)
+ def manual_stitch_mode(self):
+ return self.get_boolean_param('manual_stitch')
+
+ @property
+ @param('repeats',
+ _('Repeats'),
+ tooltip=_('Defines how many times to run down and back along the path.'),
+ type='int',
+ default="1",
+ sort_index=2)
+ def repeats(self):
+ return max(1, self.get_int_param("repeats", 1))
@property
@param(
@@ -50,34 +93,196 @@ class Stroke(EmbroideryElement):
_('Bean stitch number of repeats'),
tooltip=_('Backtrack each stitch this many times. '
'A value of 1 would triple each stitch (forward, back, forward). '
- 'A value of 2 would quintuple each stitch, etc. Only applies to running stitch.'),
+ 'A value of 2 would quintuple each stitch, etc.'),
type='int',
default=0,
- sort_index=2)
+ sort_index=3)
def bean_stitch_repeats(self):
return self.get_int_param("bean_stitch_repeats", 0)
@property
+ @param('running_stitch_length_mm',
+ _('Running stitch length'),
+ tooltip=_('Length of stitches in running stitch mode.'),
+ unit='mm',
+ type='float',
+ default=1.5,
+ sort_index=4)
+ def running_stitch_length(self):
+ return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
+
+ @property
@param('zigzag_spacing_mm',
_('Zig-zag spacing (peak-to-peak)'),
tooltip=_('Length of stitches in zig-zag mode.'),
unit='mm',
type='float',
default=0.4,
- sort_index=3)
+ select_items=[('stroke_method', 0)],
+ sort_index=5)
@cache
def zigzag_spacing(self):
return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01)
@property
- @param('repeats',
- _('Repeats'),
- tooltip=_('Defines how many times to run down and back along the path.'),
+ @param('line_count',
+ _('Number of lines'),
+ tooltip=_('Number of lines from start to finish'),
type='int',
- default="1",
- sort_index=1)
- def repeats(self):
- return self.get_int_param("repeats", 1)
+ default=10,
+ select_items=[('stroke_method', 1)],
+ sort_index=5)
+ @cache
+ def line_count(self):
+ return max(self.get_int_param("line_count", 10), 1)
+
+ def get_line_count(self):
+ if self.is_closed:
+ return self.line_count + 1
+ return self.line_count
+
+ @property
+ @param('skip_start',
+ _('Skip first lines'),
+ tooltip=_('Skip this number of lines at the beginning.'),
+ type='int',
+ default=0,
+ select_items=[('stroke_method', 1)],
+ sort_index=6)
+ @cache
+ def skip_start(self):
+ return abs(self.get_int_param("skip_start", 0))
+
+ @property
+ @param('skip_end',
+ _('Skip last lines'),
+ tooltip=_('Skip this number of lines at the end'),
+ type='int',
+ default=0,
+ select_items=[('stroke_method', 1)],
+ sort_index=7)
+ @cache
+ def skip_end(self):
+ return abs(self.get_int_param("skip_end", 0))
+
+ def _adjust_skip(self, skip):
+ if self.skip_start + self.skip_end >= self.line_count:
+ return 0
+ else:
+ return skip
+
+ def get_skip_start(self):
+ return self._adjust_skip(self.skip_start)
+
+ def get_skip_end(self):
+ return self._adjust_skip(self.skip_end)
+
+ @property
+ @param('exponent',
+ _('Line distance exponent'),
+ tooltip=_('Increase density towards one side.'),
+ type='float',
+ default=1,
+ select_items=[('stroke_method', 1)],
+ sort_index=8)
+ @cache
+ def exponent(self):
+ return max(self.get_float_param("exponent", 1), 0.1)
+
+ @property
+ @param('flip_exponent',
+ _('Flip exponent'),
+ tooltip=_('Reverse exponent effect.'),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 1)],
+ sort_index=9)
+ @cache
+ def flip_exponent(self):
+ return self.get_boolean_param("flip_exponent", False)
+
+ @property
+ @param('reverse',
+ _('Reverse'),
+ tooltip=_('Flip start and end point'),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 1)],
+ sort_index=10)
+ @cache
+ def reverse(self):
+ return self.get_boolean_param("reverse", False)
+
+ @property
+ @param('grid_size',
+ _('Grid size'),
+ tooltip=_('Render as grid. Use with care and watch your stitch density.'),
+ type='float',
+ default=0,
+ unit='mm',
+ select_items=[('stroke_method', 1)],
+ sort_index=11)
+ @cache
+ def grid_size(self):
+ return abs(self.get_float_param("grid_size", 0))
+
+ @property
+ @param('scale_axis',
+ _('Scale axis'),
+ tooltip=_('Scale axis for satin guided ripple stitches.'),
+ type='dropdown',
+ default=0,
+ # 0: xy, 1: x, 2: y, 3: none
+ options=[_("X Y"), _("X"), _("Y"), _("None")],
+ select_items=[('stroke_method', 1)],
+ sort_index=12)
+ def scale_axis(self):
+ return self.get_int_param('scale_axis', 0)
+
+ @property
+ @param('scale_start',
+ _('Starting scale'),
+ tooltip=_('How big the first copy of the line should be, in percent.') + " " + _('Used only for ripple stitch with a guide line.'),
+ type='float',
+ default=100,
+ select_items=[('stroke_method', 1)],
+ sort_index=13)
+ def scale_start(self):
+ return self.get_float_param('scale_start', 100.0)
+
+ @property
+ @param('scale_end',
+ _('Ending scale'),
+ tooltip=_('How big the last copy of the line should be, in percent.') + " " + _('Used only for ripple stitch with a guide line.'),
+ type='float',
+ default=0.0,
+ select_items=[('stroke_method', 1)],
+ sort_index=14)
+ def scale_end(self):
+ return self.get_float_param('scale_end', 0.0)
+
+ @property
+ @param('rotate_ripples',
+ _('Rotate'),
+ tooltip=_('Rotate satin guided ripple stitches'),
+ type='boolean',
+ default=True,
+ select_items=[('stroke_method', 1)],
+ sort_index=15)
+ @cache
+ def rotate_ripples(self):
+ return self.get_boolean_param("rotate_ripples", True)
+
+ @property
+ @cache
+ def is_closed(self):
+ # returns true if the outline of a single line stroke is a closed shape
+ # (with a small tolerance)
+ lines = self.as_multi_line_string().geoms
+ if len(lines) == 1:
+ coords = lines[0].coords
+ return Point(*coords[0]).distance(Point(*coords[-1])) < 0.05
+ return False
@property
def paths(self):
@@ -86,7 +291,7 @@ class Stroke(EmbroideryElement):
# manipulate invalid path
if len(flattened[0]) == 1:
- return [[[flattened[0][0][0], flattened[0][0][1]], [flattened[0][0][0]+1.0, flattened[0][0][1]]]]
+ return [[[flattened[0][0][0], flattened[0][0][1]], [flattened[0][0][0] + 1.0, flattened[0][0][1]]]]
if self.manual_stitch_mode:
return [self.strip_control_points(subpath) for subpath in path]
@@ -96,22 +301,22 @@ class Stroke(EmbroideryElement):
@property
@cache
def shape(self):
- line_strings = [shapely.geometry.LineString(path) for path in self.paths]
-
- # Using convex_hull here is an important optimization. Otherwise
- # complex paths cause operations on the shape to take a long time.
- # This especially happens when importing machine embroidery files.
- return shapely.geometry.MultiLineString(line_strings).convex_hull
+ return self.as_multi_line_string().convex_hull
- @property
- @param('manual_stitch',
- _('Manual stitch placement'),
- tooltip=_("Stitch every node in the path. Stitch length and zig-zag spacing are ignored."),
- type='boolean',
- default=False,
- sort_index=0)
- def manual_stitch_mode(self):
- return self.get_boolean_param('manual_stitch')
+ @cache
+ def as_multi_line_string(self):
+ line_strings = [shapely.geometry.LineString(path) for path in self.paths]
+ return shapely.geometry.MultiLineString(line_strings)
+
+ def get_ripple_target(self):
+ command = self.get_command('ripple_target')
+ if command:
+ pos = [float(command.use.get("x", 0)), float(command.use.get("y", 0))]
+ transform = get_node_transform(command.use)
+ pos = Transform(transform).apply_to_point(pos)
+ return Point(*pos)
+ else:
+ return self.shape.centroid
def is_running_stitch(self):
# using stroke width <= 0.5 pixels to indicate running stitch is deprecated in favor of dashed lines
@@ -166,6 +371,10 @@ class Stroke(EmbroideryElement):
for i in range(len(patch) - 1):
start = patch.stitches[i]
end = patch.stitches[i + 1]
+ # sometimes the stitch results into zero length which cause a division by zero error
+ # ignoring this leads to a slightly bad result, but that is better than no output
+ if (end - start).length() == 0:
+ continue
segment_direction = (end - start).unit()
zigzag_direction = segment_direction.rotate_left()
@@ -193,23 +402,65 @@ class Stroke(EmbroideryElement):
return StitchGroup(self.color, stitches)
- def to_stitch_groups(self, last_patch):
- patches = []
+ def ripple_stitch(self):
+ return StitchGroup(
+ color=self.color,
+ tags=["ripple_stitch"],
+ stitches=ripple_stitch(self))
- for path in self.paths:
- path = [Point(x, y) for x, y in path]
- if self.manual_stitch_mode:
- patch = StitchGroup(color=self.color, stitches=path, stitch_as_is=True)
- elif self.is_running_stitch():
- patch = self.running_stitch(path, self.running_stitch_length)
-
- if self.bean_stitch_repeats > 0:
- patch.stitches = bean_stitch(patch.stitches, self.bean_stitch_repeats)
+ def do_bean_repeats(self, stitches):
+ return bean_stitch(stitches, self.bean_stitch_repeats)
- else:
- patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width)
+ def to_stitch_groups(self, last_patch):
+ patches = []
+ # ripple stitch
+ if self.stroke_method == 1:
+ patch = self.ripple_stitch()
if patch:
+ if self.bean_stitch_repeats > 0:
+ patch.stitches = self.do_bean_repeats(patch.stitches)
patches.append(patch)
+ else:
+ for path in self.paths:
+ path = [Point(x, y) for x, y in path]
+ # manual stitch
+ if self.manual_stitch_mode:
+ patch = StitchGroup(color=self.color, stitches=path, stitch_as_is=True)
+ # running stitch
+ elif self.is_running_stitch():
+ patch = self.running_stitch(path, self.running_stitch_length)
+ if self.bean_stitch_repeats > 0:
+ patch.stitches = self.do_bean_repeats(patch.stitches)
+ # simple satin
+ else:
+ patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width)
+
+ if patch:
+ patches.append(patch)
return patches
+
+ @cache
+ def get_guide_line(self):
+ guide_lines = get_marker_elements(self.node, "guide-line", False, True, True)
+ # No or empty guide line
+ # if there is a satin guide line, it will also be in stroke, so no need to check for satin here
+ if not guide_lines or not guide_lines['stroke']:
+ return None
+
+ # use the satin guide line if there is one, else use stroke
+ # ignore multiple guide lines
+ if len(guide_lines['satin']) >= 1:
+ return guide_lines['satin'][0]
+ return guide_lines['stroke'][0]
+
+ def validation_warnings(self):
+ if self.stroke_method == 1 and self.skip_start + self.skip_end >= self.line_count:
+ yield IgnoreSkipValues(self.shape.centroid)
+
+ # guided fill warnings
+ if self.stroke_method == 1:
+ guide_lines = get_marker_elements(self.node, "guide-line", False, True, True)
+ if sum(len(x) for x in guide_lines.values()) > 1:
+ yield MultipleGuideLineWarning(self.shape.centroid)