# Authors: see git history # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from math import ceil import shapely.geometry as shgeo from shapely.errors import GEOSException from ..i18n import _ from ..marker import get_marker_elements from ..stitch_plan import StitchGroup from ..stitches.ripple_stitch import ripple_stitch from ..stitches.running_stitch import (bean_stitch, running_stitch, zigzag_stitch) from ..svg import parse_length_with_units from ..svg.clip import get_clip_path from ..threads import ThreadColor from ..utils import Point, cache from ..utils.param import ParamOption from .element import EmbroideryElement, param from .validation import ValidationWarning 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 TooFewSubpathsWarning(ValidationWarning): name = _("Too few subpaths") description = _("This element renders as running stitch while it has a satin column parameter.") steps_to_solve = [ _("* Convert to stroke: select the element and open the parameter dialog. Enable running stitch along path."), _("* Use as satin column: add an other rail and optionally rungs.") ] class Stroke(EmbroideryElement): name = "Stroke" element_name = _("Stroke") @property @param('satin_column', _('Running stitch along paths'), type='toggle', inverse=True) def satin_column(self): return self.get_boolean_param("satin_column") @property def color(self): color = self.get_style("stroke") if self.cutwork_needle is not None: color = ThreadColor(color, description=self.cutwork_needle, chart=self.cutwork_needle) return color @property def cutwork_needle(self): needle = self.get_int_param('cutwork_needle') or None if needle is not None: needle = f'Cut {needle}' return needle _stroke_methods = [ParamOption('running_stitch', _("Running Stitch / Bean Stitch")), ParamOption('ripple_stitch', _("Ripple Stitch")), ParamOption('zigzag_stitch', _("ZigZag Stitch")), ParamOption('manual_stitch', _("Manual Stitch"))] @property @param('stroke_method', _('Method'), type='combo', default=0, options=_stroke_methods, sort_index=0) def stroke_method(self): return self.get_param('stroke_method', 'running_stitch') @property @param('repeats', _('Repeats'), tooltip=_('Defines how many times to run down and back along the path.'), type='int', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch'), ('stroke_method', 'zigzag_stitch')], default="1", sort_index=2) def repeats(self): return max(1, self.get_int_param("repeats", 1)) @property @param( 'bean_stitch_repeats', _('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.\n\n' 'A pattern with various repeats can be created with a list of values separated by a space.'), type='str', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch'), ('stroke_method', 'manual_stitch')], default=0, sort_index=3) def bean_stitch_repeats(self): return self.get_multiple_int_param("bean_stitch_repeats", "0") @property @param('manual_pattern_placement', _('Manual stitch placement'), tooltip=_('No extra stitches will be added to the original ripple pattern ' 'and the running stitch length value will be ignored.'), type='boolean', select_items=[('stroke_method', 'ripple_stitch')], default=False, sort_index=3) def manual_pattern_placement(self): return self.get_boolean_param('manual_pattern_placement', False) @property @param('running_stitch_length_mm', _('Running stitch length'), tooltip=_('Length of stitches. Stitches can be shorter according to the stitch tolerance setting.'), unit='mm', type='float', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], default=2.5, sort_index=4) def running_stitch_length(self): return max(self.get_float_param("running_stitch_length_mm", 2.5), 0.01) @property @param('running_stitch_tolerance_mm', _('Stitch tolerance'), tooltip=_('All stitches must be within this distance from the path. ' + 'A lower tolerance means stitches will be closer together. ' + 'A higher tolerance means sharp corners may be rounded.'), unit='mm', type='float', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], default=0.2, sort_index=4) def running_stitch_tolerance(self): return max(self.get_float_param("running_stitch_tolerance_mm", 0.2), 0.01) @property @param('enable_random_stitch_length', _('Randomize stitch length'), tooltip=_('Randomize stitch length and phase instead of dividing evenly or staggering. ' 'This is recommended for closely-spaced curved fills to avoid Moiré artefacts.'), type='boolean', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], enables=['random_stitch_length_jitter_percent'], default=False, sort_index=5) def enable_random_stitch_length(self): return self.get_boolean_param('enable_random_stitch_length', False) @property @param('random_stitch_length_jitter_percent', _('Random stitch length jitter'), tooltip=_('Amount to vary the length of each stitch by when randomizing.'), unit='± %', type='float', select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], default=10, sort_index=6) def random_stitch_length_jitter(self): return max(self.get_float_param("random_stitch_length_jitter_percent", 10), 0.0) / 100 @property @param('max_stitch_length_mm', _('Max stitch length'), tooltip=_('Split stitches longer than this.'), unit='mm', type='float', select_items=[('stroke_method', 'manual_stitch')], sort_index=5) def max_stitch_length(self): max_length = self.get_float_param("max_stitch_length_mm", None) if not max_length or max_length <= 0: return return max_length @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, select_items=[('stroke_method', 'zigzag_stitch')], sort_index=6) @cache def zigzag_spacing(self): return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01) @property @param('pull_compensation_mm', _('Pull compensation'), tooltip=_('Zigzag stitches pull the fabric together, resulting in a column narrower than you draw in Inkscape. ' 'This widens the zigzag line width.'), unit='mm', type='float', default=0, select_items=[('stroke_method', 'zigzag_stitch')], sort_index=6) @cache def pull_compensation(self): return self.get_float_param("pull_compensation_mm", 0) @property @param('line_count', _('Number of lines'), tooltip=_('Number of lines from start to finish'), type='int', default=10, select_items=[('stroke_method', 'ripple_stitch')], sort_index=7) @cache def line_count(self): return max(self.get_int_param("line_count", 10), 1) @property @param('min_line_dist_mm', _('Minimum line distance'), tooltip=_('Overrides the number of lines setting.'), unit='mm', type='float', select_items=[('stroke_method', 'ripple_stitch')], sort_index=8) @cache def min_line_dist(self): min_dist = self.get_float_param("min_line_dist_mm") if min_dist is None: return return max(min_dist, 0.01) _satin_guided_pattern_options = [ ParamOption('default', _('Line count / Minimum line distance')), ParamOption('render_at_rungs', _('Render at rungs')), ParamOption('adaptive', _('Adaptive + minimum line distance')), ] @property @param('satin_guide_pattern_position', _('Pattern position'), tooltip=_('Pattern position for satin guided ripples.'), type='combo', options=_satin_guided_pattern_options, default='default', select_items=[('stroke_method', 'ripple_stitch')], sort_index=9) def satin_guide_pattern_position(self): return self.get_param('satin_guide_pattern_position', 'line_count') @property @param('staggers', _('Stagger lines this many times before repeating'), tooltip=_('Length of the cycle by which successive stitch lines are staggered. ' 'Fractional values are allowed and can have less visible diagonals than integer values. ' 'A value of 0 (default) disables staggering and instead stitches evenly.' 'For linear ripples only.'), type='int', select_items=[('stroke_method', 'ripple_stitch')], default=0, sort_index=15) def staggers(self): return self.get_float_param("staggers", 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', 'ripple_stitch')], sort_index=16) @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', 'ripple_stitch')], sort_index=17) @cache def skip_end(self): return abs(self.get_int_param("skip_end", 0)) @property @param('flip_copies', _('Flip every second line'), tooltip=_('Linear ripple: wether to flip the pattern every second line or not.'), type='boolean', select_items=[('stroke_method', 'ripple_stitch')], default=True, sort_index=18) def flip_copies(self): return self.get_boolean_param('flip_copies', True) @property @param('exponent', _('Line distance exponent'), tooltip=_('Increase density towards one side.'), type='float', default=1, select_items=[('stroke_method', 'ripple_stitch')], sort_index=19) @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', 'ripple_stitch')], sort_index=20) @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', 'ripple_stitch')], sort_index=21) @cache def reverse(self): return self.get_boolean_param("reverse", False) _reverse_rails_options = [ParamOption('automatic', _('Automatic')), ParamOption('none', _("Don't reverse")), ParamOption('first', _('Reverse first rail')), ParamOption('second', _('Reverse second rail')), ParamOption('both', _('Reverse both rails')) ] @property @param( 'reverse_rails', _('Reverse rails'), tooltip=_('Reverse satin ripple rails. ' + 'Default: automatically detect and fix a reversed rail.'), type='combo', options=_reverse_rails_options, default='automatic', select_items=[('stroke_method', 'ripple_stitch')], sort_index=22) def reverse_rails(self): return self.get_param('reverse_rails', 'automatic') @property @param('grid_size_mm', _('Grid size'), tooltip=_('Render as grid. Use with care and watch your stitch density.'), type='float', default=0, unit='mm', select_items=[('stroke_method', 'ripple_stitch')], sort_index=23) @cache def grid_size(self): return abs(self.get_float_param("grid_size_mm", 0)) @property @param('grid_first', _('Stitch grid first'), tooltip=_('Reverse the stitch paths, so that the grid will be stitched first'), type='boolean', default=False, select_items=[('stroke_method', 'ripple_stitch')], sort_index=24) @cache def grid_first(self): return self.get_boolean_param("grid_first", False) @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', 'ripple_stitch')], sort_index=25) 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', unit='%', default=100, select_items=[('stroke_method', 'ripple_stitch')], sort_index=26) 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', unit='%', default=0.0, select_items=[('stroke_method', 'ripple_stitch')], sort_index=27) 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', 'ripple_stitch')], sort_index=30) @cache def rotate_ripples(self): return self.get_boolean_param("rotate_ripples", True) @property @param('join_style', _('Join style'), tooltip=_('Join style for non circular ripples.'), type='dropdown', default=0, options=(_("flat"), _("point")), select_items=[('stroke_method', 'ripple_stitch')], sort_index=31) @cache def join_style(self): return self.get_int_param('join_style', 0) @property @param('random_seed', _('Random seed'), tooltip=_('Use a specific seed for randomized attributes. Uses the element ID if empty.'), select_items=[('stroke_method', 'running_stitch'), ('stroke_method', 'ripple_stitch')], type='random_seed', default='', sort_index=100) @cache def random_seed(self) -> str: seed = self.get_param('random_seed', '') if not seed: seed = self.node.get_id() or '' # TODO(#1696): When inplementing grouped clones, join this with the IDs of any shadow roots, # letting each instance without a specified seed get a different default. return seed @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): return self._get_paths() @property def unclipped_paths(self): return self._get_paths(False) def _get_paths(self, clipped=True): path = self.parse_path() flattened = self.flatten(path) if clipped: flattened = self._get_clipped_path(flattened) if flattened is None: return [] # 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]]]] if self.stroke_method == 'manual_stitch': return [self.strip_control_points(subpath) for subpath in path] else: return flattened @property @cache def shape(self): return self.as_multi_line_string().convex_hull @cache def as_multi_line_string(self): line_strings = [shgeo.LineString(path) for path in self.paths if len(path) > 1] return shgeo.MultiLineString(line_strings) @property def first_stitch(self): return shgeo.Point(self.as_multi_line_string().geoms[0].coords[0]) def _get_clipped_path(self, paths): clip_path = get_clip_path(self.node) if clip_path is None: return paths # path to linestrings line_strings = [shgeo.LineString(path) for path in paths] try: intersection = clip_path.intersection(shgeo.MultiLineString(line_strings)) except GEOSException: return paths coords = [] if intersection.is_empty: return None elif isinstance(intersection, shgeo.MultiLineString): for c in [intersection for intersection in intersection.geoms if isinstance(intersection, shgeo.LineString)]: coords.append(c.coords) elif isinstance(intersection, shgeo.LineString): coords.append(intersection.coords) else: return paths return coords def get_ripple_target(self): command = self.get_command('target_point') if command: return shgeo.Point(*command.target_point) else: return self.shape.centroid def simple_satin(self, path, zigzag_spacing, stroke_width, pull_compensation): "zig-zag along the path at the specified spacing and wdith" # `self.zigzag_spacing` is the length for a zig and a zag # together (a V shape). Start with running stitch at half # that length: stitch_group = self.running_stitch(path, zigzag_spacing / 2.0, self.running_stitch_tolerance, False, 0, "") stitch_group.stitches = zigzag_stitch(stitch_group.stitches, zigzag_spacing, stroke_width, pull_compensation) return stitch_group def running_stitch(self, path, stitch_length, tolerance, enable_random_stitch_length, random_sigma, random_seed): # running stitch with repeats stitches = running_stitch(path, stitch_length, tolerance, enable_random_stitch_length, random_sigma, random_seed) repeated_stitches = [] # go back and forth along the path as specified by self.repeats for i in range(self.repeats): if i % 2 == 1: # reverse every other pass this_path = stitches[::-1] else: this_path = stitches[:] repeated_stitches.extend(this_path) return StitchGroup( self.color, stitches=repeated_stitches, lock_stitches=self.lock_stitches, force_lock_stitches=self.force_lock_stitches ) def apply_max_stitch_length(self, path): # apply max distances max_len_path = [path[0]] for points in zip(path[:-1], path[1:]): line = shgeo.LineString(points) dist = line.length if dist > self.max_stitch_length: num_subsections = ceil(dist / self.max_stitch_length) additional_points = [Point(coord.x, coord.y) for coord in [line.interpolate((i/num_subsections), normalized=True) for i in range(1, num_subsections + 1)]] max_len_path.extend(additional_points) max_len_path.append(points[1]) return max_len_path def ripple_stitch(self): return StitchGroup( color=self.color, tags=["ripple_stitch"], stitches=ripple_stitch(self), lock_stitches=self.lock_stitches, force_lock_stitches=self.force_lock_stitches) def do_bean_repeats(self, stitches): return bean_stitch(stitches, self.bean_stitch_repeats) def to_stitch_groups(self, last_stitch_group, next_element=None): # noqa: C901 stitch_groups = [] # ripple stitch if self.stroke_method == 'ripple_stitch': stitch_group = self.ripple_stitch() if stitch_group: if any(self.bean_stitch_repeats): stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches) stitch_groups.append(stitch_group) else: for path in self.paths: path = [Point(x, y) for x, y in path] # manual stitch if self.stroke_method == 'manual_stitch': if self.max_stitch_length: path = self.apply_max_stitch_length(path) if self.force_lock_stitches: lock_stitches = self.lock_stitches else: # manual stitch disables lock stitches unless they force them lock_stitches = (None, None) stitch_group = StitchGroup( color=self.color, stitches=path, lock_stitches=lock_stitches, force_lock_stitches=self.force_lock_stitches ) # apply bean stitch settings if any(self.bean_stitch_repeats): stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches) # simple satin elif self.stroke_method == 'zigzag_stitch': stitch_group = self.simple_satin(path, self.zigzag_spacing, self.stroke_width, self.pull_compensation) # running stitch else: stitch_group = self.running_stitch(path, self.running_stitch_length, self.running_stitch_tolerance, self.enable_random_stitch_length, self.random_stitch_length_jitter, self.random_seed) # bean stitch if any(self.bean_stitch_repeats): stitch_group.stitches = self.do_bean_repeats(stitch_group.stitches) if stitch_group: stitch_groups.append(stitch_group) return stitch_groups @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] @cache def get_anchor_line(self): anchor_lines = get_marker_elements(self.node, "anchor-line", False, True, False) # No or empty guide line if not anchor_lines or not anchor_lines['stroke']: return None # ignore multiple anchor lines return anchor_lines['stroke'][0].geoms[0] def _representative_point(self): # if we just take the center of a line string we could end up on some point far away from the actual line try: coords = list(self.shape.coords) except NotImplementedError: # linear rings to not have a coordinate sequence coords = list(self.shape.exterior.coords) return coords[int(len(coords)/2)] def validation_warnings(self): # satin column warning if self.get_boolean_param("satin_column", False): yield TooFewSubpathsWarning(self._representative_point()) # 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._representative_point()) stroke_width, units = parse_length_with_units(self.get_style("stroke-width", "1"))