diff options
Diffstat (limited to 'lib/elements/stroke.py')
| -rw-r--r-- | lib/elements/stroke.py | 351 |
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) |
