diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 44a686436..3610ec6b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -171,7 +171,7 @@ jobs: # with --no-binary argument may fix notary issues as well shapely speedups error issue pip install -U lxml --no-binary lxml pip uninstall --yes shapely - pip install -v -U Shapely --no-binary Shapely + pip install -v -U Shapely==1.8.1 --no-binary Shapely pip install pyinstaller echo "${{ env.pythonLocation }}/bin" >> $GITHUB_PATH diff --git a/lib/commands.py b/lib/commands.py index 1d2357592..a7affb6d8 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -26,6 +26,9 @@ COMMANDS = { # L10N command attached to an object "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"), @@ -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 7113bf3fd..40741caa7 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,10 +88,93 @@ 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)'), @@ -64,22 +182,12 @@ class Stroke(EmbroideryElement): 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() @@ -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 e50d97d00..b60183e51 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 8b2738bc7..b0ff64fce 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 847a1bcd7..91a998497 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 000000000..88d1b8d03 --- /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 d78ba678e..ce57de4fa 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') diff --git a/requirements.txt b/requirements.txt index 4ff2cc0a1..b64be645d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ backports.functools_lru_cache wxPython networkx -shapely==1.8.0 +shapely==1.8.2 lxml appdirs numpy<=1.17.4 diff --git a/symbols/inkstitch.svg b/symbols/inkstitch.svg index 0f0ff15c1..2a80a0527 100644 --- a/symbols/inkstitch.svg +++ b/symbols/inkstitch.svg @@ -324,6 +324,22 @@ id="inkstitch-autorun_end-path" d="M -1.5977169,-4.8605484 c -0.00319,1.523e-4 -0.00643,6.284e-4 -0.00955,0.00139 l -1.738218,0.6114515 c -0.044131,0.013274 -0.04771,0.07406 -0.00546,0.092381 l 1.7286718,0.6522048 c 0.042543,0.011464 0.077832,-0.033916 0.055977,-0.072004 -0.075631,-0.1076723 -0.1279528,-0.2256474 -0.1583813,-0.3478501 l 5.3580083,0.0693 C 3.7283489,-3.6574881 3.9289854,-3.5209456 4.163113,-3.51807 4.4929661,-3.51396 4.7679522,-3.7755203 4.7721242,-4.1036793 4.7763142,-4.4318766 4.5079699,-4.70559 4.178155,-4.7096844 3.9431472,-4.7126044 3.7385104,-4.5784741 3.6388043,-4.38223 l -5.3566689,-0.065225 c 0.033509,-0.1204507 0.089391,-0.2361787 0.1679695,-0.3396805 0.019712,-0.034678 -0.00793,-0.077126 -0.047786,-0.073375 z m -2.6203001,0.2119742 c -0.3227531,0.0041 -0.5844387,0.2625539 -0.5884958,0.5856093 -0.00295,0.2329983 0.129063,0.4383262 0.3236143,0.5394287 l -0.065546,5.2964025 c -0.1188053,-0.03344 -0.233209,-0.088553 -0.3359005,-0.1657553 -0.018028,-0.014169 -0.043442,-0.014169 -0.061451,0 -0.016535,0.013426 -0.022582,0.035878 -0.015004,0.055721 l 0.6130875,1.7310434 c 0.013338,0.043896 0.074426,0.047476 0.092855,0.00543 l 0.6608737,-1.7201931 c 0.021243,-0.048142 -0.037912,-0.091505 -0.077832,-0.057054 -0.1085476,0.075489 -0.2269701,0.1273635 -0.3495646,0.1576047 l 0.069641,-5.2950505 c 0.1995845,-0.095713 0.3397855,-0.2989653 0.3427327,-0.5326301 0.00412,-0.3281781 -0.2641734,-0.5964641 -0.5939692,-0.6005585 -0.00519,-7.62e-5 -0.00991,-7.62e-5 -0.015023,0 z m 8.4425639,1.3302136 c -0.020458,-1.333e-4 -0.038925,0.012283 -0.046428,0.03125 l -0.6608735,1.714742 c -0.021243,0.04818 0.037931,0.091524 0.077851,0.057054 0.1072273,-0.074556 0.2244441,-0.1271727 0.3454502,-0.1576044 L 3.8750009,3.6234841 C 3.6760287,3.7194257 3.5366315,3.9202022 3.5336461,4.15341 3.529436,4.4816071 3.7964416,4.7512453 4.1262565,4.7553397 4.4560331,4.759449 4.726962,4.4979085 4.731134,4.1697112 4.7341354,3.934523 4.5996979,3.7276145 4.4020654,3.6275594 L 4.4703294,-1.66612 c 0.1197239,0.033383 0.2338597,0.08802 0.3372785,0.1657745 0.039309,0.032355 0.096166,-0.00834 0.077832,-0.055721 L 4.2709744,-3.2871101 c -0.00748,-0.018949 -0.02595,-0.031384 -0.046428,-0.031251 z M 1.5114302,3.6180947 c -0.037451,6.856e-4 -0.060627,0.040868 -0.042351,0.073356 0.075823,0.1079391 0.1280103,0.2267139 0.1584006,0.3492023 L -3.7305473,3.971372 c -0.096108,-0.1988532 -0.2989079,-0.338138 -0.5338966,-0.3410517 -0.3297958,-0.0041 -0.5994042,0.2615256 -0.6035188,0.5896847 -0.00413,0.3281972 0.2627956,0.6019488 0.5926104,0.6060241 0.2345678,0.00292 0.4394534,-0.1304295 0.5393508,-0.3261024 l 5.3580084,0.063873 C 1.5885549,4.684593 1.5314859,4.7996924 1.4526979,4.90348 1.4299049,4.942272 1.4668789,4.9887 1.5100529,4.975503 L 3.2496085,4.3640511 c 0.043002,-0.015959 0.043002,-0.076441 0,-0.0924 L 1.5250561,3.6194659 c -0.00446,-0.00112 -0.00905,-0.00154 -0.013665,-0.00139 Z" /> + + Ripple stitch target point + + + @@ -458,5 +474,13 @@ width="100%" height="100%" transform="translate(37.830849,113.28861)" /> + diff --git a/templates/auto_run.xml b/templates/auto_run.xml index eb732571a..4a524b2dc 100644 --- a/templates/auto_run.xml +++ b/templates/auto_run.xml @@ -1,6 +1,6 @@ - Autoroute Running Stitch + Auto-Route Running Stitch org.inkstitch.auto_run auto_run @@ -12,7 +12,7 @@ - + true @@ -21,7 +21,7 @@ false - + diff --git a/templates/selection_to_guide_line.xml b/templates/selection_to_guide_line.xml index 677e62c44..1a3ac0f95 100644 --- a/templates/selection_to_guide_line.xml +++ b/templates/selection_to_guide_line.xml @@ -6,7 +6,7 @@ all - +