diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/commands.py | 6 | ||||
| -rw-r--r-- | lib/elements/stroke.py | 239 | ||||
| -rw-r--r-- | lib/extensions/params.py | 1 | ||||
| -rw-r--r-- | lib/stitches/__init__.py | 3 | ||||
| -rw-r--r-- | lib/stitches/auto_run.py | 2 | ||||
| -rw-r--r-- | lib/stitches/ripple_stitch.py | 173 | ||||
| -rw-r--r-- | lib/svg/tags.py | 10 |
7 files changed, 382 insertions, 52 deletions
diff --git a/lib/commands.py b/lib/commands.py index 1d235759..a7affb6d 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -27,6 +27,9 @@ COMMANDS = { "fill_end": N_("Fill stitch ending position"), # L10N command attached to an object + "ripple_target": N_("Ripple stitch target position"), + + # L10N command attached to an object "run_start": N_("Auto-route running stitch starting position"), # L10N command attached to an object @@ -60,7 +63,8 @@ COMMANDS = { "stop_position": N_("Jump destination for Stop commands (a.k.a. \"Frame Out position\")."), } -OBJECT_COMMANDS = ["fill_start", "fill_end", "run_start", "run_end", "satin_start", "satin_end", "stop", "trim", "ignore_object", "satin_cut_point"] +OBJECT_COMMANDS = ["fill_start", "fill_end", "ripple_target", "run_start", "run_end", "satin_start", "satin_end", + "stop", "trim", "ignore_object", "satin_cut_point"] FREE_MOVEMENT_OBJECT_COMMANDS = ["run_start", "run_end", "satin_start", "satin_end"] LAYER_COMMANDS = ["ignore_layer"] GLOBAL_COMMANDS = ["origin", "stop_position"] diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 7113bf3f..40741caa 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -7,16 +7,30 @@ import sys import shapely.geometry -from .element import EmbroideryElement, param +from inkex import Transform + from ..i18n import _ 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 .satin_column import SatinColumn +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 = [ + _('* Reduce values of Skip first and last lines or'), + _('* Increase number of lines accordinly in the params dialog.'), + ] + + class Stroke(EmbroideryElement): element_name = _("Stroke") @@ -34,15 +48,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( @@ -53,34 +88,107 @@ class Stroke(EmbroideryElement): 'A value of 2 would quintuple each stitch, etc. Only applies to running stitch.'), 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('line_count', + _('Number of lines'), + tooltip=_('Number of lines from start to finish'), + type='int', + 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) + + @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)) + + @property + @param('flip', + _('Flip'), + tooltip=_('Flip outer to inner'), + type='boolean', + default=False, + select_items=[('stroke_method', 1)], + sort_index=8) + @cache + def flip(self): + return self.get_boolean_param("flip", False) + + @property + @param('render_grid', + _('Grid distance'), + tooltip=_('Render as grid. Works only with satin type ripple stitches.'), + type='float', + default=0, + select_items=[('stroke_method', 1)], + sort_index=8) + @cache + def render_grid(self): + return abs(self.get_float_param("render_grid", 0)) + + @property + @param('exponent', + _('Line distance exponent'), + tooltip=_('Increse density towards one side.'), + type='float', + default=1, + select_items=[('stroke_method', 1)], + sort_index=9) + @cache + def exponent(self): + return max(self.get_float_param("exponent", 1), 0.1) + + @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.'), - type='int', - default="1", - sort_index=1) - def repeats(self): - repeats = self.get_int_param("repeats", 1) - return max(1, repeats) - - @property def paths(self): path = self.parse_path() flattened = self.flatten(path) @@ -102,18 +210,17 @@ class Stroke(EmbroideryElement): @cache def as_multi_line_string(self): line_strings = [shapely.geometry.LineString(path) for path in self.paths] - return shapely.geometry.MultiLineString(line_strings) - @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') + 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 @@ -199,23 +306,61 @@ class Stroke(EmbroideryElement): return StitchGroup(self.color, stitches) + def do_bean_repeats(self, stitches): + return bean_stitch(stitches, self.bean_stitch_repeats) + def to_stitch_groups(self, last_patch): patches = [] - 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) - - else: - patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width) - + # ripple stitch + if self.stroke_method == 1: + lines = self.as_multi_line_string() + points = [] + if len(lines.geoms) > 1: + # if render_grid has a number use this, otherwise use running_stitch_length + length = self.render_grid or self.running_stitch_length + # use satin column points for satin like build ripple stitches + points = SatinColumn(self.node).plot_points_on_rails(length, 0) + point_target = self.get_ripple_target() + patch = StitchGroup( + color=self.color, + tags=["ripple_stitch"], + stitches=ripple_stitch( + self.as_multi_line_string(), + point_target, + self.line_count, + points, + self.running_stitch_length, + self.repeats, + self.flip, + self.skip_start, + self.skip_end, + self.render_grid, + self.exponent)) 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 + + def validation_warnings(self): + if self.stroke_method == 1 and self.skip_start + self.skip_end >= self.line_count: + yield IgnoreSkipValues(self.shape.centroid) diff --git a/lib/extensions/params.py b/lib/extensions/params.py index e50d97d0..b60183e5 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -129,6 +129,7 @@ class ParamsTab(ScrolledPanel): self.update_choice_widgets((param, selection)) self.settings_grid.Layout() + self.Fit() self.Layout() if event: diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py index 8b2738bc..b0ff64fc 100644 --- a/lib/stitches/__init__.py +++ b/lib/stitches/__init__.py @@ -9,4 +9,5 @@ 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 +# from .auto_satin import auto_satin +# from .ripple_stitch import ripple_stitch diff --git a/lib/stitches/auto_run.py b/lib/stitches/auto_run.py index 847a1bcd..91a99849 100644 --- a/lib/stitches/auto_run.py +++ b/lib/stitches/auto_run.py @@ -132,7 +132,7 @@ def autorun(elements, preserve_order=False, break_up=None, starting_point=None, else: parent = elements[0].node.getparent() insert_index = parent.index(elements[0].node) - group = create_new_group(parent, insert_index, _("Auto-Run")) + group = create_new_group(parent, insert_index, _("Auto-Route")) add_elements_to_group(new_elements, group) if trim: diff --git a/lib/stitches/ripple_stitch.py b/lib/stitches/ripple_stitch.py new file mode 100644 index 00000000..88d1b8d0 --- /dev/null +++ b/lib/stitches/ripple_stitch.py @@ -0,0 +1,173 @@ +from collections import defaultdict + +from shapely.geometry import LineString, Point + +from ..utils.geometry import line_string_to_point_list +from .running_stitch import running_stitch + + +def ripple_stitch(lines, target, line_count, points, max_stitch_length, repeats, flip, skip_start, skip_end, render_grid, exponent): + ''' + Ripple stitch is allowed to cross itself and doesn't care about an equal distance of lines + It is meant to be used with light (not dense) stitching + It will ignore holes in a closed shape. Closed shapes will be filled with a spiral + Open shapes will be stitched back and forth. + If there is only one (open) line or a closed shape the target point will be used. + If more sublines are present interpolation will take place between the first two. + ''' + + # sort geoms by size + lines = sorted(lines.geoms, key=lambda linestring: linestring.length, reverse=True) + outline = lines[0] + + # ignore skip_start and skip_end if both toghether are greater or equal to line_count + if skip_start + skip_end >= line_count: + skip_start = skip_end = 0 + + if is_closed(outline): + rippled_line = do_circular_ripple(outline, target, line_count, repeats, flip, max_stitch_length, skip_start, skip_end, exponent) + else: + rippled_line = do_linear_ripple(lines, points, target, line_count - 1, repeats, flip, skip_start, skip_end, render_grid, exponent) + + return running_stitch(line_string_to_point_list(rippled_line), max_stitch_length) + + +def do_circular_ripple(outline, target, line_count, repeats, flip, max_stitch_length, skip_start, skip_end, exponent): + # for each point generate a line going to the target point + lines = target_point_lines_normalized_distances(outline, target, flip, max_stitch_length) + + # create a list of points for each line + points = get_interpolation_points(lines, line_count, exponent, "circular") + + # connect the lines to a spiral towards the target + coords = [] + for i in range(skip_start, line_count - skip_end): + for j in range(len(lines)): + coords.append(Point(points[j][i].x, points[j][i].y)) + + coords = repeat_coords(coords, repeats) + + return LineString(coords) + + +def do_linear_ripple(lines, points, target, line_count, repeats, flip, skip_start, skip_end, render_grid, exponent): + if len(lines) == 1: + helper_lines = target_point_lines(lines[0], target, flip) + else: + helper_lines = [] + for start, end in zip(points[0], points[1]): + if flip: + helper_lines.append(LineString([end, start])) + else: + helper_lines.append(LineString([start, end])) + + # get linear points along the lines + points = get_interpolation_points(helper_lines, line_count, exponent) + + # go back and forth along the lines - flip direction of every second line + coords = [] + for i in range(skip_start, len(points[0]) - skip_end): + for j in range(len(helper_lines)): + k = j + if i % 2 != 0: + k = len(helper_lines) - j - 1 + coords.append(Point(points[k][i].x, points[k][i].y)) + + # add helper lines as a grid + # for now only add this to satin type ripples, otherwise it could become to dense at the target point + if len(lines) > 1 and render_grid: + coords.extend(do_grid(helper_lines, line_count - skip_end)) + + coords = repeat_coords(coords, repeats) + + return LineString(coords) + + +def do_grid(lines, num_lines): + coords = [] + if num_lines % 2 == 0: + lines = reversed(lines) + for i, line in enumerate(lines): + line_coords = list(line.coords) + if (i % 2 == 0 and num_lines % 2 == 0) or (i % 2 != 0 and num_lines % 2 != 0): + coords.extend(reversed(line_coords)) + else: + coords.extend(line_coords) + return coords + + +def line_length(line): + return line.length + + +def is_closed(line): + coords = line.coords + return Point(*coords[0]).distance(Point(*coords[-1])) < 0.05 + + +def target_point_lines(outline, target, flip): + lines = [] + for point in outline.coords: + if flip: + lines.append(LineString([point, target])) + else: + lines.append(LineString([target, point])) + return lines + + +def target_point_lines_normalized_distances(outline, target, flip, max_stitch_length): + lines = [] + outline = running_stitch(line_string_to_point_list(outline), max_stitch_length) + for point in outline: + if flip: + lines.append(LineString([target, point])) + else: + lines.append(LineString([point, target])) + return lines + + +def get_interpolation_points(lines, line_count, exponent, method="linear"): + new_points = defaultdict(list) + count = len(lines) - 1 + for i, line in enumerate(lines): + steps = get_steps(line, line_count, exponent) + distance = -1 + points = [] + for j in range(line_count): + length = line.length * steps[j] + if method == "circular": + if distance == -1: + # the first line makes sure, it is going to be a spiral + distance = (line.length * steps[j+1]) * (i / count) + else: + distance += length - (line.length * steps[j-1]) + else: + distance = line.length * steps[j] + points.append(line.interpolate(distance)) + if method == "linear": + points.append(Point(*line.coords[-1])) + new_points[i] = points + return new_points + + +def get_steps(line, total_lines, exponent): + # get_steps is scribbled from the inkscape interpolate extension + # (https://gitlab.com/inkscape/extensions/-/blob/master/interp.py) + steps = [ + ((i + 1) / (total_lines)) ** exponent + for i in range(total_lines - 1) + ] + return [0] + steps + [1] + + +def repeat_coords(coords, repeats): + final_coords = [] + for i in range(repeats): + if i % 2 == 1: + # reverse every other pass + this_coords = coords[::-1] + else: + this_coords = coords[:] + + final_coords.extend(this_coords) + return final_coords diff --git a/lib/svg/tags.py b/lib/svg/tags.py index d78ba678..ce57de4f 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -63,6 +63,11 @@ inkstitch_attribs = [ 'join_style', 'avoid_self_crossing', 'clockwise', + 'line_count', + 'skip_start', + 'skip_end', + 'render_grid', + 'exponent', 'expand_mm', 'fill_underlay', 'fill_underlay_angle', @@ -80,7 +85,7 @@ inkstitch_attribs = [ 'flip', 'expand_mm', # stroke - 'manual_stitch', + 'stroke_method', 'bean_stitch_repeats', 'repeats', 'running_stitch_length_mm', @@ -102,7 +107,8 @@ inkstitch_attribs = [ 'stroke_first', # Legacy 'trim_after', - 'stop_after' + 'stop_after', + 'manual_stitch', ] for attrib in inkstitch_attribs: INKSTITCH_ATTRIBS[attrib] = inkex.addNS(attrib, 'inkstitch') |
