diff options
Diffstat (limited to 'lib')
37 files changed, 2453 insertions, 1033 deletions
diff --git a/lib/commands.py b/lib/commands.py index c9da782a..1d235759 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -27,6 +27,12 @@ COMMANDS = { "fill_end": N_("Fill stitch ending position"), # L10N command attached to an object + "run_start": N_("Auto-route running stitch starting position"), + + # L10N command attached to an object + "run_end": N_("Auto-route running stitch ending position"), + + # L10N command attached to an object "satin_start": N_("Auto-route satin stitch starting position"), # L10N command attached to an object @@ -54,7 +60,8 @@ COMMANDS = { "stop_position": N_("Jump destination for Stop commands (a.k.a. \"Frame Out position\")."), } -OBJECT_COMMANDS = ["fill_start", "fill_end", "satin_start", "satin_end", "stop", "trim", "ignore_object", "satin_cut_point"] +OBJECT_COMMANDS = ["fill_start", "fill_end", "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"] @@ -288,7 +295,7 @@ def add_group(document, node, command): return group -def add_connector(document, symbol, element): +def add_connector(document, symbol, command, element): # I'd like it if I could position the connector endpoint nicely but inkscape just # moves it to the element's center immediately after the extension runs. start_pos = (symbol.get('x'), symbol.get('y')) @@ -304,12 +311,14 @@ def add_connector(document, symbol, element): "style": "stroke:#000000;stroke-width:1px;stroke-opacity:0.5;fill:none;", CONNECTION_START: "#%s" % symbol.get('id'), CONNECTION_END: "#%s" % element.node.get('id'), - CONNECTOR_TYPE: "polyline", # l10n: the name of the line that connects a command to the object it applies to INKSCAPE_LABEL: _("connector") }) + if command not in FREE_MOVEMENT_OBJECT_COMMANDS: + path.attrib[CONNECTOR_TYPE] = "polyline" + symbol.getparent().insert(0, path) @@ -383,7 +392,7 @@ def add_commands(element, commands): group = add_group(svg, element.node, command) pos = get_command_pos(element, i, len(commands)) symbol = add_symbol(svg, group, command, pos) - add_connector(svg, symbol, element) + add_connector(svg, symbol, command, element) def add_layer_commands(layer, commands): diff --git a/lib/elements/__init__.py b/lib/elements/__init__.py index 2e4c31a7..00933f36 100644 --- a/lib/elements/__init__.py +++ b/lib/elements/__init__.py @@ -3,11 +3,10 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from .auto_fill import AutoFill from .clone import Clone from .element import EmbroideryElement from .empty_d_object import EmptyDObject -from .fill import Fill +from .fill_stitch import FillStitch from .image import ImageObject from .polyline import Polyline from .satin_column import SatinColumn diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py deleted file mode 100644 index fbbd86d3..00000000 --- a/lib/elements/auto_fill.py +++ /dev/null @@ -1,291 +0,0 @@ -# Authors: see git history -# -# Copyright (c) 2010 Authors -# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. - -import math -import sys -import traceback - -from shapely import geometry as shgeo - -from .element import param -from .fill import Fill -from .validation import ValidationWarning -from ..i18n import _ -from ..stitch_plan import StitchGroup -from ..stitches import auto_fill -from ..svg.tags import INKSCAPE_LABEL -from ..utils import cache, version - - -class SmallShapeWarning(ValidationWarning): - name = _("Small Fill") - description = _("This fill object is so small that it would probably look better as running stitch or satin column. " - "For very small shapes, fill stitch is not possible, and Ink/Stitch will use running stitch around " - "the outline instead.") - - -class ExpandWarning(ValidationWarning): - name = _("Expand") - description = _("The expand parameter for this fill object cannot be applied. " - "Ink/Stitch will ignore it and will use original size instead.") - - -class UnderlayInsetWarning(ValidationWarning): - name = _("Inset") - description = _("The underlay inset parameter for this fill object cannot be applied. " - "Ink/Stitch will ignore it and will use the original size instead.") - - -class AutoFill(Fill): - element_name = _("AutoFill") - - @property - @param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True) - def auto_fill(self): - return self.get_boolean_param('auto_fill', True) - - @property - @cache - def outline(self): - return self.shape.boundary[0] - - @property - @cache - def outline_length(self): - return self.outline.length - - @property - def flip(self): - return False - - @property - @param('running_stitch_length_mm', - _('Running stitch length (traversal between sections)'), - tooltip=_('Length of stitches around the outline of the fill region used when moving from section to section.'), - unit='mm', - type='float', - default=1.5) - def running_stitch_length(self): - return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) - - @property - @param('fill_underlay', _('Underlay'), type='toggle', group=_('AutoFill Underlay'), default=True) - def fill_underlay(self): - return self.get_boolean_param("fill_underlay", default=True) - - @property - @param('fill_underlay_angle', - _('Fill angle'), - tooltip=_('Default: fill angle + 90 deg. Insert comma-seperated list for multiple layers.'), - unit='deg', - group=_('AutoFill Underlay'), - type='float') - @cache - def fill_underlay_angle(self): - underlay_angles = self.get_param('fill_underlay_angle', None) - default_value = [self.angle + math.pi / 2.0] - if underlay_angles is not None: - underlay_angles = underlay_angles.strip().split(',') - try: - underlay_angles = [math.radians(float(angle)) for angle in underlay_angles] - except (TypeError, ValueError): - return default_value - else: - underlay_angles = default_value - - return underlay_angles - - @property - @param('fill_underlay_row_spacing_mm', - _('Row spacing'), - tooltip=_('default: 3x fill row spacing'), - unit='mm', - group=_('AutoFill Underlay'), - type='float') - @cache - def fill_underlay_row_spacing(self): - return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3 - - @property - @param('fill_underlay_max_stitch_length_mm', - _('Max stitch length'), - tooltip=_('default: equal to fill max stitch length'), - unit='mm', - group=_('AutoFill Underlay'), type='float') - @cache - def fill_underlay_max_stitch_length(self): - return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length - - @property - @param('fill_underlay_inset_mm', - _('Inset'), - tooltip=_('Shrink the shape before doing underlay, to prevent underlay from showing around the outside of the fill.'), - unit='mm', - group=_('AutoFill Underlay'), - type='float', - default=0) - def fill_underlay_inset(self): - return self.get_float_param('fill_underlay_inset_mm', 0) - - @property - @param( - 'fill_underlay_skip_last', - _('Skip last stitch in each row'), - tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. ' - 'Skipping it decreases stitch count and density.'), - group=_('AutoFill Underlay'), - type='boolean', - default=False) - def fill_underlay_skip_last(self): - return self.get_boolean_param("fill_underlay_skip_last", False) - - @property - @param('expand_mm', - _('Expand'), - tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes.'), - unit='mm', - type='float', - default=0) - def expand(self): - return self.get_float_param('expand_mm', 0) - - @property - @param('underpath', - _('Underpath'), - tooltip=_('Travel inside the shape when moving from section to section. Underpath ' - 'stitches avoid traveling in the direction of the row angle so that they ' - 'are not visible. This gives them a jagged appearance.'), - type='boolean', - default=True) - def underpath(self): - return self.get_boolean_param('underpath', True) - - @property - @param( - 'underlay_underpath', - _('Underpath'), - tooltip=_('Travel inside the shape when moving from section to section. Underpath ' - 'stitches avoid traveling in the direction of the row angle so that they ' - 'are not visible. This gives them a jagged appearance.'), - group=_('AutoFill Underlay'), - type='boolean', - default=True) - def underlay_underpath(self): - return self.get_boolean_param('underlay_underpath', True) - - def shrink_or_grow_shape(self, amount, validate=False): - if amount: - shape = self.shape.buffer(amount) - # changing the size can empty the shape - # in this case we want to use the original shape rather than returning an error - if shape.is_empty and not validate: - return self.shape - if not isinstance(shape, shgeo.MultiPolygon): - shape = shgeo.MultiPolygon([shape]) - return shape - else: - return self.shape - - @property - def underlay_shape(self): - return self.shrink_or_grow_shape(-self.fill_underlay_inset) - - @property - def fill_shape(self): - return self.shrink_or_grow_shape(self.expand) - - def get_starting_point(self, last_patch): - # If there is a "fill_start" Command, then use that; otherwise pick - # the point closest to the end of the last patch. - - if self.get_command('fill_start'): - return self.get_command('fill_start').target_point - elif last_patch: - return last_patch.stitches[-1] - else: - return None - - def get_ending_point(self): - if self.get_command('fill_end'): - return self.get_command('fill_end').target_point - else: - return None - - def to_stitch_groups(self, last_patch): - stitch_groups = [] - - starting_point = self.get_starting_point(last_patch) - ending_point = self.get_ending_point() - - try: - if self.fill_underlay: - for i in range(len(self.fill_underlay_angle)): - underlay = StitchGroup( - color=self.color, - tags=("auto_fill", "auto_fill_underlay"), - stitches=auto_fill( - self.underlay_shape, - self.fill_underlay_angle[i], - self.fill_underlay_row_spacing, - self.fill_underlay_row_spacing, - self.fill_underlay_max_stitch_length, - self.running_stitch_length, - self.staggers, - self.fill_underlay_skip_last, - starting_point, - underpath=self.underlay_underpath)) - stitch_groups.append(underlay) - - starting_point = underlay.stitches[-1] - - stitch_group = StitchGroup( - color=self.color, - tags=("auto_fill", "auto_fill_top"), - stitches=auto_fill( - self.fill_shape, - self.angle, - self.row_spacing, - self.end_row_spacing, - self.max_stitch_length, - self.running_stitch_length, - self.staggers, - self.skip_last, - starting_point, - ending_point, - self.underpath)) - stitch_groups.append(stitch_group) - except Exception: - if hasattr(sys, 'gettrace') and sys.gettrace(): - # if we're debugging, let the exception bubble up - raise - - # for an uncaught exception, give a little more info so that they can create a bug report - message = "" - message += _("Error during autofill! This means that there is a problem with Ink/Stitch.") - message += "\n\n" - # L10N this message is followed by a URL: https://github.com/inkstitch/inkstitch/issues/new - message += _("If you'd like to help us make Ink/Stitch better, please paste this whole message into a new issue at: ") - message += "https://github.com/inkstitch/inkstitch/issues/new\n\n" - message += version.get_inkstitch_version() + "\n\n" - message += traceback.format_exc() - - self.fatal(message) - - return stitch_groups - - -def validation_warnings(self): - if self.shape.area < 20: - label = self.node.get(INKSCAPE_LABEL) or self.node.get("id") - yield SmallShapeWarning(self.shape.centroid, label) - - if self.shrink_or_grow_shape(self.expand, True).is_empty: - yield ExpandWarning(self.shape.centroid) - - if self.shrink_or_grow_shape(-self.fill_underlay_inset, True).is_empty: - yield UnderlayInsetWarning(self.shape.centroid) - - for warning in super(AutoFill, self).validation_warnings(): - yield warning diff --git a/lib/elements/clone.py b/lib/elements/clone.py index f408917d..d9185012 100644 --- a/lib/elements/clone.py +++ b/lib/elements/clone.py @@ -5,19 +5,14 @@ from math import atan, degrees -from ..commands import is_command, is_command_symbol +from ..commands import is_command_symbol from ..i18n import _ from ..svg.path import get_node_transform from ..svg.svg import find_elements -from ..svg.tags import (EMBROIDERABLE_TAGS, INKSTITCH_ATTRIBS, - SVG_POLYLINE_TAG, SVG_USE_TAG, XLINK_HREF) +from ..svg.tags import (EMBROIDERABLE_TAGS, INKSTITCH_ATTRIBS, SVG_USE_TAG, + XLINK_HREF) from ..utils import cache -from .auto_fill import AutoFill from .element import EmbroideryElement, param -from .fill import Fill -from .polyline import Polyline -from .satin_column import SatinColumn -from .stroke import Stroke from .validation import ObjectTypeWarning, ValidationWarning @@ -68,28 +63,8 @@ class Clone(EmbroideryElement): return self.get_float_param('angle', 0) def clone_to_element(self, node): - # we need to determine if the source element is polyline, stroke, fill or satin - element = EmbroideryElement(node) - - if node.tag == SVG_POLYLINE_TAG: - return [Polyline(node)] - - elif element.get_boolean_param("satin_column") and self.get_clone_style("stroke", self.node): - return [SatinColumn(node)] - else: - elements = [] - if element.get_style("fill", "black") and not element.get_style("stroke", 1) == "0": - if element.get_boolean_param("auto_fill", True): - elements.append(AutoFill(node)) - else: - elements.append(Fill(node)) - if element.get_style("stroke", self.node) is not None: - if not is_command(element.node): - elements.append(Stroke(node)) - if element.get_boolean_param("stroke_first", False): - elements.reverse() - - return elements + from .utils import node_to_elements + return node_to_elements(node, True) def to_stitch_groups(self, last_patch=None): patches = [] diff --git a/lib/elements/element.py b/lib/elements/element.py index 05bfd353..3648760b 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -20,7 +20,7 @@ from ..utils import Point, cache class Param(object): def __init__(self, name, description, unit=None, values=[], type=None, group=None, inverse=False, - options=[], default=None, tooltip=None, sort_index=0): + options=[], default=None, tooltip=None, sort_index=0, select_items=None): self.name = name self.description = description self.unit = unit @@ -32,6 +32,7 @@ class Param(object): self.default = default self.tooltip = tooltip self.sort_index = sort_index + self.select_items = select_items def __repr__(self): return "Param(%s)" % vars(self) @@ -86,8 +87,11 @@ class EmbroideryElement(object): return params def replace_legacy_param(self, param): - value = self.node.get(param, "").strip() - self.set_param(param[10:], value) + # remove "embroider_" prefix + new_param = param[10:] + if new_param in INKSTITCH_ATTRIBS: + value = self.node.get(param, "").strip() + self.set_param(param[10:], value) del self.node.attrib[param] @cache @@ -202,7 +206,7 @@ class EmbroideryElement(object): # L10N options to allow lock stitch before and after objects options=[_("Both"), _("Before"), _("After"), _("Neither")], default=0, - sort_index=4) + sort_index=10) @cache def ties(self): return self.get_int_param("ties", 0) @@ -214,7 +218,7 @@ class EmbroideryElement(object): 'even if the distance to the next object is shorter than defined by the collapse length value in the Ink/Stitch preferences.'), type='boolean', default=False, - sort_index=5) + sort_index=10) @cache def force_lock_stitches(self): return self.get_boolean_param('force_lock_stitches', False) @@ -263,6 +267,11 @@ class EmbroideryElement(object): return apply_transforms(self.path, self.node) @property + @cache + def paths(self): + return self.flatten(self.parse_path()) + + @property def shape(self): raise NotImplementedError("INTERNAL ERROR: %s must implement shape()", self.__class__) diff --git a/lib/elements/empty_d_object.py b/lib/elements/empty_d_object.py index 3c24f333..8f3b08de 100644 --- a/lib/elements/empty_d_object.py +++ b/lib/elements/empty_d_object.py @@ -23,5 +23,9 @@ class EmptyDObject(EmbroideryElement): label = self.node.get(INKSCAPE_LABEL) or self.node.get("id") yield EmptyD((0, 0), label) + @property + def shape(self): + return + def to_stitch_groups(self, last_patch): return [] diff --git a/lib/elements/fill.py b/lib/elements/fill.py deleted file mode 100644 index 51a6d703..00000000 --- a/lib/elements/fill.py +++ /dev/null @@ -1,205 +0,0 @@ -# Authors: see git history -# -# Copyright (c) 2010 Authors -# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. - -import logging -import math -import re - -from shapely import geometry as shgeo -from shapely.validation import explain_validity - -from .element import EmbroideryElement, param -from .validation import ValidationError -from ..i18n import _ -from ..stitch_plan import StitchGroup -from ..stitches import legacy_fill -from ..svg import PIXELS_PER_MM -from ..utils import cache - - -class UnconnectedError(ValidationError): - name = _("Unconnected") - description = _("Fill: This object is made up of unconnected shapes. This is not allowed because " - "Ink/Stitch doesn't know what order to stitch them in. Please break this " - "object up into separate shapes.") - steps_to_solve = [ - _('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects'), - ] - - -class InvalidShapeError(ValidationError): - name = _("Border crosses itself") - description = _("Fill: Shape is not valid. This can happen if the border crosses over itself.") - steps_to_solve = [ - _('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects') - ] - - -class Fill(EmbroideryElement): - element_name = _("Fill") - - def __init__(self, *args, **kwargs): - super(Fill, self).__init__(*args, **kwargs) - - @property - @param('auto_fill', - _('Manually routed fill stitching'), - tooltip=_('AutoFill is the default method for generating fill stitching.'), - type='toggle', - inverse=True, - default=True) - def auto_fill(self): - return self.get_boolean_param('auto_fill', True) - - @property - @param('angle', - _('Angle of lines of stitches'), - tooltip=_('The angle increases in a counter-clockwise direction. 0 is horizontal. Negative angles are allowed.'), - unit='deg', - type='float', - default=0) - @cache - def angle(self): - return math.radians(self.get_float_param('angle', 0)) - - @property - def color(self): - # SVG spec says the default fill is black - return self.get_style("fill", "#000000") - - @property - @param( - 'skip_last', - _('Skip last stitch in each row'), - tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. ' - 'Skipping it decreases stitch count and density.'), - type='boolean', - default=False) - def skip_last(self): - return self.get_boolean_param("skip_last", False) - - @property - @param( - 'flip', - _('Flip fill (start right-to-left)'), - tooltip=_('The flip option can help you with routing your stitch path. ' - 'When you enable flip, stitching goes from right-to-left instead of left-to-right.'), - type='boolean', - default=False) - def flip(self): - return self.get_boolean_param("flip", False) - - @property - @param('row_spacing_mm', - _('Spacing between rows'), - tooltip=_('Distance between rows of stitches.'), - unit='mm', - type='float', - default=0.25) - def row_spacing(self): - return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM) - - @property - def end_row_spacing(self): - return self.get_float_param("end_row_spacing_mm") - - @property - @param('max_stitch_length_mm', - _('Maximum fill stitch length'), - tooltip=_('The length of each stitch in a row. Shorter stitch may be used at the start or end of a row.'), - unit='mm', - type='float', - default=3.0) - def max_stitch_length(self): - return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM) - - @property - @param('staggers', - _('Stagger rows this many times before repeating'), - tooltip=_('Setting this dictates how many rows apart the stitches will be before they fall in the same column position.'), - type='int', - default=4) - def staggers(self): - return max(self.get_int_param("staggers", 4), 1) - - @property - @cache - def paths(self): - paths = self.flatten(self.parse_path()) - # ensure path length - for i, path in enumerate(paths): - if len(path) < 3: - paths[i] = [(path[0][0], path[0][1]), (path[0][0]+1.0, path[0][1]), (path[0][0], path[0][1]+1.0)] - return paths - - @property - @cache - def shape(self): - # shapely's idea of "holes" are to subtract everything in the second set - # from the first. So let's at least make sure the "first" thing is the - # biggest path. - paths = self.paths - paths.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True) - # Very small holes will cause a shape to be rendered as an outline only - # they are too small to be rendered and only confuse the auto_fill algorithm. - # So let's ignore them - if shgeo.Polygon(paths[0]).area > 5 and shgeo.Polygon(paths[-1]).area < 5: - paths = [path for path in paths if shgeo.Polygon(path).area > 3] - - polygon = shgeo.MultiPolygon([(paths[0], paths[1:])]) - - # There is a great number of "crossing border" errors on fill shapes - # If the polygon fails, we can try to run buffer(0) on the polygon in the - # hope it will fix at least some of them - if not self.shape_is_valid(polygon): - why = explain_validity(polygon) - message = re.match(r".+?(?=\[)", why) - if message.group(0) == "Self-intersection": - buffered = polygon.buffer(0) - # if we receive a multipolygon, only use the first one of it - if type(buffered) == shgeo.MultiPolygon: - buffered = buffered[0] - # we do not want to break apart into multiple objects (possibly in the future?!) - # best way to distinguish the resulting polygon is to compare the area size of the two - # and make sure users will not experience significantly altered shapes without a warning - if type(buffered) == shgeo.Polygon and math.isclose(polygon.area, buffered.area, abs_tol=0.5): - polygon = shgeo.MultiPolygon([buffered]) - - return polygon - - def shape_is_valid(self, shape): - # Shapely will log to stdout to complain about the shape unless we make - # it shut up. - logger = logging.getLogger('shapely.geos') - level = logger.level - logger.setLevel(logging.CRITICAL) - - valid = shape.is_valid - - logger.setLevel(level) - - return valid - - def validation_errors(self): - if not self.shape_is_valid(self.shape): - why = explain_validity(self.shape) - message, x, y = re.findall(r".+?(?=\[)|-?\d+(?:\.\d+)?", why) - - # I Wish this weren't so brittle... - if "Hole lies outside shell" in message: - yield UnconnectedError((x, y)) - else: - yield InvalidShapeError((x, y)) - - def to_stitch_groups(self, last_patch): - stitch_lists = legacy_fill(self.shape, - self.angle, - self.row_spacing, - self.end_row_spacing, - self.max_stitch_length, - self.flip, - self.staggers, - self.skip_last) - return [StitchGroup(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists] diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py new file mode 100644 index 00000000..f1a75e2f --- /dev/null +++ b/lib/elements/fill_stitch.py @@ -0,0 +1,637 @@ +# Authors: see git history +# +# Copyright (c) 2010 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import logging +import math +import re +import sys +import traceback + +from shapely import geometry as shgeo +from shapely.validation import explain_validity + +from ..i18n import _ +from ..marker import get_marker_elements +from ..stitch_plan import StitchGroup +from ..stitches import contour_fill, auto_fill, legacy_fill, guided_fill +from ..svg import PIXELS_PER_MM +from ..svg.tags import INKSCAPE_LABEL +from ..utils import cache, version +from .element import EmbroideryElement, param +from .validation import ValidationError, ValidationWarning + + +class SmallShapeWarning(ValidationWarning): + name = _("Small Fill") + description = _("This fill object is so small that it would probably look better as running stitch or satin column. " + "For very small shapes, fill stitch is not possible, and Ink/Stitch will use running stitch around " + "the outline instead.") + + +class ExpandWarning(ValidationWarning): + name = _("Expand") + description = _("The expand parameter for this fill object cannot be applied. " + "Ink/Stitch will ignore it and will use original size instead.") + + +class UnderlayInsetWarning(ValidationWarning): + name = _("Inset") + description = _("The underlay inset parameter for this fill object cannot be applied. " + "Ink/Stitch will ignore it and will use the original size instead.") + + +class MissingGuideLineWarning(ValidationWarning): + name = _("Missing Guideline") + description = _('This object is set to "Guided Fill", but has no guide line.') + steps_to_solve = [ + _('* Create a stroke object'), + _('* Select this object and run Extensions > Ink/Stitch > Edit > Selection to guide line') + ] + + +class DisjointGuideLineWarning(ValidationWarning): + name = _("Disjointed Guide Line") + description = _("The guide line of this object isn't within the object borders. " + "The guide line works best, if it is within the target element.") + steps_to_solve = [ + _('* Move the guide line into the element') + ] + + +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 UnconnectedError(ValidationError): + name = _("Unconnected") + description = _("Fill: This object is made up of unconnected shapes. This is not allowed because " + "Ink/Stitch doesn't know what order to stitch them in. Please break this " + "object up into separate shapes.") + steps_to_solve = [ + _('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects'), + ] + + +class InvalidShapeError(ValidationError): + name = _("Border crosses itself") + description = _("Fill: Shape is not valid. This can happen if the border crosses over itself.") + steps_to_solve = [ + _('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects') + ] + + +class FillStitch(EmbroideryElement): + element_name = _("FillStitch") + + @property + @param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True, sort_index=1) + def auto_fill(self): + return self.get_boolean_param('auto_fill', True) + + @property + @param('fill_method', _('Fill method'), type='dropdown', default=0, + options=[_("Auto Fill"), _("Contour Fill"), _("Guided Fill"), _("Legacy Fill")], sort_index=2) + def fill_method(self): + return self.get_int_param('fill_method', 0) + + @property + @param('contour_strategy', _('Contour Fill Strategy'), type='dropdown', default=0, + options=[_("Inner to Outer"), _("Single spiral"), _("Double spiral")], select_items=[('fill_method', 1)], sort_index=3) + def contour_strategy(self): + return self.get_int_param('contour_strategy', 0) + + @property + @param('join_style', _('Join Style'), type='dropdown', default=0, + options=[_("Round"), _("Mitered"), _("Beveled")], select_items=[('fill_method', 1)], sort_index=4) + def join_style(self): + return self.get_int_param('join_style', 0) + + @property + @param('avoid_self_crossing', _('Avoid self-crossing'), type='boolean', default=False, select_items=[('fill_method', 1)], sort_index=5) + def avoid_self_crossing(self): + return self.get_boolean_param('avoid_self_crossing', False) + + @property + @param('clockwise', _('Clockwise'), type='boolean', default=True, select_items=[('fill_method', 1)], sort_index=5) + def clockwise(self): + return self.get_boolean_param('clockwise', True) + + @property + @param('angle', + _('Angle of lines of stitches'), + tooltip=_('The angle increases in a counter-clockwise direction. 0 is horizontal. Negative angles are allowed.'), + unit='deg', + type='float', + sort_index=6, + select_items=[('fill_method', 0), ('fill_method', 3)], + default=0) + @cache + def angle(self): + return math.radians(self.get_float_param('angle', 0)) + + @property + def color(self): + # SVG spec says the default fill is black + return self.get_style("fill", "#000000") + + @property + @param( + 'skip_last', + _('Skip last stitch in each row'), + tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. ' + 'Skipping it decreases stitch count and density.'), + type='boolean', + sort_index=6, + select_items=[('fill_method', 0), ('fill_method', 2), + ('fill_method', 3)], + default=False) + def skip_last(self): + return self.get_boolean_param("skip_last", False) + + @property + @param( + 'flip', + _('Flip fill (start right-to-left)'), + tooltip=_('The flip option can help you with routing your stitch path. ' + 'When you enable flip, stitching goes from right-to-left instead of left-to-right.'), + type='boolean', + sort_index=7, + select_items=[('fill_method', 3)], + default=False) + def flip(self): + return self.get_boolean_param("flip", False) + + @property + @param('row_spacing_mm', + _('Spacing between rows'), + tooltip=_('Distance between rows of stitches.'), + unit='mm', + sort_index=6, + type='float', + default=0.25) + def row_spacing(self): + return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM) + + @property + def end_row_spacing(self): + return self.get_float_param("end_row_spacing_mm") + + @property + @param('max_stitch_length_mm', + _('Maximum fill stitch length'), + tooltip=_( + 'The length of each stitch in a row. Shorter stitch may be used at the start or end of a row.'), + unit='mm', + sort_index=6, + type='float', + default=3.0) + def max_stitch_length(self): + return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM) + + @property + @param('staggers', + _('Stagger rows this many times before repeating'), + tooltip=_('Setting this dictates how many rows apart the stitches will be before they fall in the same column position.'), + type='int', + sort_index=6, + select_items=[('fill_method', 0), ('fill_method', 3)], + default=4) + def staggers(self): + return max(self.get_int_param("staggers", 4), 1) + + @property + @cache + def paths(self): + paths = self.flatten(self.parse_path()) + # ensure path length + for i, path in enumerate(paths): + if len(path) < 3: + paths[i] = [(path[0][0], path[0][1]), (path[0][0] + 1.0, path[0][1]), (path[0][0], path[0][1] + 1.0)] + return paths + + @property + @cache + def shape(self): + # shapely's idea of "holes" are to subtract everything in the second set + # from the first. So let's at least make sure the "first" thing is the + # biggest path. + paths = self.paths + paths.sort(key=lambda point_list: shgeo.Polygon( + point_list).area, reverse=True) + # Very small holes will cause a shape to be rendered as an outline only + # they are too small to be rendered and only confuse the auto_fill algorithm. + # So let's ignore them + if shgeo.Polygon(paths[0]).area > 5 and shgeo.Polygon(paths[-1]).area < 5: + paths = [path for path in paths if shgeo.Polygon(path).area > 3] + + polygon = shgeo.MultiPolygon([(paths[0], paths[1:])]) + + # There is a great number of "crossing border" errors on fill shapes + # If the polygon fails, we can try to run buffer(0) on the polygon in the + # hope it will fix at least some of them + if not self.shape_is_valid(polygon): + why = explain_validity(polygon) + message = re.match(r".+?(?=\[)", why) + if message.group(0) == "Self-intersection": + buffered = polygon.buffer(0) + # if we receive a multipolygon, only use the first one of it + if type(buffered) == shgeo.MultiPolygon: + buffered = buffered[0] + # we do not want to break apart into multiple objects (possibly in the future?!) + # best way to distinguish the resulting polygon is to compare the area size of the two + # and make sure users will not experience significantly altered shapes without a warning + if type(buffered) == shgeo.Polygon and math.isclose(polygon.area, buffered.area, abs_tol=0.5): + polygon = shgeo.MultiPolygon([buffered]) + + return polygon + + def shape_is_valid(self, shape): + # Shapely will log to stdout to complain about the shape unless we make + # it shut up. + logger = logging.getLogger('shapely.geos') + level = logger.level + logger.setLevel(logging.CRITICAL) + + valid = shape.is_valid + + logger.setLevel(level) + + return valid + + def validation_errors(self): + if not self.shape_is_valid(self.shape): + why = explain_validity(self.shape) + message, x, y = re.findall(r".+?(?=\[)|-?\d+(?:\.\d+)?", why) + + # I Wish this weren't so brittle... + if "Hole lies outside shell" in message: + yield UnconnectedError((x, y)) + else: + yield InvalidShapeError((x, y)) + + def validation_warnings(self): + if self.shape.area < 20: + label = self.node.get(INKSCAPE_LABEL) or self.node.get("id") + yield SmallShapeWarning(self.shape.centroid, label) + + if self.shrink_or_grow_shape(self.expand, True).is_empty: + yield ExpandWarning(self.shape.centroid) + + if self.shrink_or_grow_shape(-self.fill_underlay_inset, True).is_empty: + yield UnderlayInsetWarning(self.shape.centroid) + + # guided fill warnings + if self.fill_method == 2: + guide_lines = self._get_guide_lines(True) + if not guide_lines or guide_lines[0].is_empty: + yield MissingGuideLineWarning(self.shape.centroid) + elif len(guide_lines) > 1: + yield MultipleGuideLineWarning(self.shape.centroid) + elif guide_lines[0].disjoint(self.shape): + yield DisjointGuideLineWarning(self.shape.centroid) + return None + + for warning in super(FillStitch, self).validation_warnings(): + yield warning + + @property + @cache + def outline(self): + return self.shape.boundary[0] + + @property + @cache + def outline_length(self): + return self.outline.length + + @property + @param('running_stitch_length_mm', + _('Running stitch length (traversal between sections)'), + tooltip=_('Length of stitches around the outline of the fill region used when moving from section to section.'), + unit='mm', + type='float', + default=1.5, + select_items=[('fill_method', 0), ('fill_method', 2)], + sort_index=6) + def running_stitch_length(self): + return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) + + @property + @param('fill_underlay', _('Underlay'), type='toggle', group=_('Fill Underlay'), default=True) + def fill_underlay(self): + return self.get_boolean_param("fill_underlay", default=True) + + @property + @param('fill_underlay_angle', + _('Fill angle'), + tooltip=_('Default: fill angle + 90 deg. Insert comma-seperated list for multiple layers.'), + unit='deg', + group=_('Fill Underlay'), + type='float') + @cache + def fill_underlay_angle(self): + underlay_angles = self.get_param('fill_underlay_angle', None) + default_value = [self.angle + math.pi / 2.0] + if underlay_angles is not None: + underlay_angles = underlay_angles.strip().split(',') + try: + underlay_angles = [math.radians( + float(angle)) for angle in underlay_angles] + except (TypeError, ValueError): + return default_value + else: + underlay_angles = default_value + + return underlay_angles + + @property + @param('fill_underlay_row_spacing_mm', + _('Row spacing'), + tooltip=_('default: 3x fill row spacing'), + unit='mm', + group=_('Fill Underlay'), + type='float') + @cache + def fill_underlay_row_spacing(self): + return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3 + + @property + @param('fill_underlay_max_stitch_length_mm', + _('Max stitch length'), + tooltip=_('default: equal to fill max stitch length'), + unit='mm', + group=_('Fill Underlay'), type='float') + @cache + def fill_underlay_max_stitch_length(self): + return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length + + @property + @param('fill_underlay_inset_mm', + _('Inset'), + tooltip=_('Shrink the shape before doing underlay, to prevent underlay from showing around the outside of the fill.'), + unit='mm', + group=_('Fill Underlay'), + type='float', + default=0) + def fill_underlay_inset(self): + return self.get_float_param('fill_underlay_inset_mm', 0) + + @property + @param( + 'fill_underlay_skip_last', + _('Skip last stitch in each row'), + tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. ' + 'Skipping it decreases stitch count and density.'), + group=_('Fill Underlay'), + type='boolean', + default=False) + def fill_underlay_skip_last(self): + return self.get_boolean_param("fill_underlay_skip_last", False) + + @property + @param('expand_mm', + _('Expand'), + tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes.'), + unit='mm', + type='float', + default=0, + sort_index=5, + select_items=[('fill_method', 0), ('fill_method', 2)]) + def expand(self): + return self.get_float_param('expand_mm', 0) + + @property + @param('underpath', + _('Underpath'), + tooltip=_('Travel inside the shape when moving from section to section. Underpath ' + 'stitches avoid traveling in the direction of the row angle so that they ' + 'are not visible. This gives them a jagged appearance.'), + type='boolean', + default=True, + select_items=[('fill_method', 0), ('fill_method', 2)], + sort_index=6) + def underpath(self): + return self.get_boolean_param('underpath', True) + + @property + @param( + 'underlay_underpath', + _('Underpath'), + tooltip=_('Travel inside the shape when moving from section to section. Underpath ' + 'stitches avoid traveling in the direction of the row angle so that they ' + 'are not visible. This gives them a jagged appearance.'), + group=_('Fill Underlay'), + type='boolean', + default=True) + def underlay_underpath(self): + return self.get_boolean_param('underlay_underpath', True) + + def shrink_or_grow_shape(self, amount, validate=False): + if amount: + shape = self.shape.buffer(amount) + # changing the size can empty the shape + # in this case we want to use the original shape rather than returning an error + if shape.is_empty and not validate: + return self.shape + if not isinstance(shape, shgeo.MultiPolygon): + shape = shgeo.MultiPolygon([shape]) + return shape + else: + return self.shape + + @property + def underlay_shape(self): + return self.shrink_or_grow_shape(-self.fill_underlay_inset) + + @property + def fill_shape(self): + return self.shrink_or_grow_shape(self.expand) + + def get_starting_point(self, last_patch): + # If there is a "fill_start" Command, then use that; otherwise pick + # the point closest to the end of the last patch. + + if self.get_command('fill_start'): + return self.get_command('fill_start').target_point + elif last_patch: + return last_patch.stitches[-1] + else: + return None + + def get_ending_point(self): + if self.get_command('fill_end'): + return self.get_command('fill_end').target_point + else: + return None + + def to_stitch_groups(self, last_patch): + # backwards compatibility: legacy_fill used to be inkstitch:auto_fill == False + if not self.auto_fill or self.fill_method == 3: + return self.do_legacy_fill() + else: + stitch_groups = [] + start = self.get_starting_point(last_patch) + end = self.get_ending_point() + + try: + if self.fill_underlay: + underlay_stitch_groups, start = self.do_underlay(start) + stitch_groups.extend(underlay_stitch_groups) + if self.fill_method == 0: + stitch_groups.extend(self.do_auto_fill(last_patch, start, end)) + if self.fill_method == 1: + stitch_groups.extend(self.do_contour_fill(last_patch, start)) + elif self.fill_method == 2: + stitch_groups.extend(self.do_guided_fill(last_patch, start, end)) + except Exception: + self.fatal_fill_error() + + return stitch_groups + + def do_legacy_fill(self): + stitch_lists = legacy_fill(self.shape, + self.angle, + self.row_spacing, + self.end_row_spacing, + self.max_stitch_length, + self.flip, + self.staggers, + self.skip_last) + return [StitchGroup(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists] + + def do_underlay(self, starting_point): + stitch_groups = [] + for i in range(len(self.fill_underlay_angle)): + underlay = StitchGroup( + color=self.color, + tags=("auto_fill", "auto_fill_underlay"), + stitches=auto_fill( + self.underlay_shape, + self.fill_underlay_angle[i], + self.fill_underlay_row_spacing, + self.fill_underlay_row_spacing, + self.fill_underlay_max_stitch_length, + self.running_stitch_length, + self.staggers, + self.fill_underlay_skip_last, + starting_point, + underpath=self.underlay_underpath)) + stitch_groups.append(underlay) + + starting_point = underlay.stitches[-1] + return [stitch_groups, starting_point] + + def do_auto_fill(self, last_patch, starting_point, ending_point): + stitch_group = StitchGroup( + color=self.color, + tags=("auto_fill", "auto_fill_top"), + stitches=auto_fill( + self.fill_shape, + self.angle, + self.row_spacing, + self.end_row_spacing, + self.max_stitch_length, + self.running_stitch_length, + self.staggers, + self.skip_last, + starting_point, + ending_point, + self.underpath)) + return [stitch_group] + + def do_contour_fill(self, last_patch, starting_point): + if not starting_point: + starting_point = (0, 0) + starting_point = shgeo.Point(starting_point) + + stitch_groups = [] + for polygon in self.fill_shape.geoms: + tree = contour_fill.offset_polygon(polygon, self.row_spacing, self.join_style + 1, self.clockwise) + + stitches = [] + if self.contour_strategy == 0: + stitches = contour_fill.inner_to_outer( + tree, + self.row_spacing, + self.max_stitch_length, + starting_point, + self.avoid_self_crossing + ) + elif self.contour_strategy == 1: + stitches = contour_fill.single_spiral( + tree, + self.max_stitch_length, + starting_point + ) + elif self.contour_strategy == 2: + stitches = contour_fill.double_spiral( + tree, + self.max_stitch_length, + starting_point + ) + + stitch_group = StitchGroup( + color=self.color, + tags=("auto_fill", "auto_fill_top"), + stitches=stitches) + stitch_groups.append(stitch_group) + + return stitch_groups + + def do_guided_fill(self, last_patch, starting_point, ending_point): + guide_line = self._get_guide_lines() + + # No guide line: fallback to normal autofill + if not guide_line: + return self.do_auto_fill(last_patch, starting_point, ending_point) + + stitch_group = StitchGroup( + color=self.color, + tags=("guided_fill", "auto_fill_top"), + stitches=guided_fill( + self.fill_shape, + guide_line.geoms[0], + self.angle, + self.row_spacing, + self.max_stitch_length, + self.running_stitch_length, + self.skip_last, + starting_point, + ending_point, + self.underpath)) + return [stitch_group] + + @cache + def _get_guide_lines(self, multiple=False): + guide_lines = get_marker_elements(self.node, "guide-line", False, True) + # No or empty guide line + if not guide_lines or not guide_lines['stroke']: + return None + + if multiple: + return guide_lines['stroke'] + else: + return guide_lines['stroke'][0] + + def fatal_fill_error(self): + if hasattr(sys, 'gettrace') and sys.gettrace(): + # if we're debugging, let the exception bubble up + raise + + # for an uncaught exception, give a little more info so that they can create a bug report + message = "" + message += _("Error during autofill! This means that there is a problem with Ink/Stitch.") + message += "\n\n" + # L10N this message is followed by a URL: https://github.com/inkstitch/inkstitch/issues/new + message += _("If you'd like to help us make Ink/Stitch better, please paste this whole message into a new issue at: ") + message += "https://github.com/inkstitch/inkstitch/issues/new\n\n" + message += version.get_inkstitch_version() + "\n\n" + message += traceback.format_exc() + + self.fatal(message) diff --git a/lib/elements/pattern.py b/lib/elements/marker.py index 4b92d366..574ce91e 100644 --- a/lib/elements/pattern.py +++ b/lib/elements/marker.py @@ -10,24 +10,23 @@ from .element import EmbroideryElement from .validation import ObjectTypeWarning -class PatternWarning(ObjectTypeWarning): - name = _("Pattern Element") +class MarkerWarning(ObjectTypeWarning): + name = _("Marker Element") description = _("This element will not be embroidered. " - "It will appear as a pattern applied to objects in the same group as it. " - "Objects in sub-groups will be ignored.") + "It will be applied to objects in the same group. Objects in sub-groups will be ignored.") steps_to_solve = [ - _("To disable pattern mode, remove the pattern marker:"), + _("Turn back to normal embroidery element mode, remove the marker:"), _('* Open the Fill and Stroke panel (Objects > Fill and Stroke)'), _('* Go to the Stroke style tab'), _('* Under "Markers" choose the first (empty) option in the first dropdown list.') ] -class PatternObject(EmbroideryElement): +class MarkerObject(EmbroideryElement): def validation_warnings(self): repr_point = next(inkex.Path(self.parse_path()).end_points) - yield PatternWarning(repr_point) + yield MarkerWarning(repr_point) def to_stitch_groups(self, last_patch): return [] diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index e923aac0..5086c705 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -47,19 +47,9 @@ class Polyline(EmbroideryElement): return self.get_boolean_param("polyline") @property - def points(self): - # example: "1,2 0,0 1.5,3 4,2" - - points = self.node.get('points').strip() - points = points.split(" ") - points = [[float(coord) for coord in point.split(",")] for point in points] - - return points - - @property @cache def shape(self): - return shgeo.LineString(self.points) + return shgeo.LineString(self.path) @property def path(self): @@ -68,9 +58,12 @@ class Polyline(EmbroideryElement): # svg transforms that is in our superclass, we'll convert the polyline # to a degenerate cubic superpath in which the bezier handles are on # the segment endpoints. - path = self.node.get_path() + if self.node.get('points', None): + path = self.node.get_path() + else: + # Set path to (0, 0) for empty polylines + path = 'M 0 0' path = Path(path).to_superpath() - return path @property @@ -99,7 +92,7 @@ class Polyline(EmbroideryElement): return stitches def validation_warnings(self): - yield PolylineWarning(self.points[0]) + yield PolylineWarning(self.path[0][0][0]) def to_stitch_groups(self, last_patch): patch = StitchGroup(color=self.color) diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index a30f16d4..83080003 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -216,10 +216,7 @@ class SatinColumn(EmbroideryElement): # This isn't used for satins at all, but other parts of the code # may need to know the general shape of a satin column. - flattened = self.flatten(self.parse_path()) - line_strings = [shgeo.LineString(path) for path in flattened] - - return shgeo.MultiLineString(line_strings) + return shgeo.MultiLineString(self.flattened_rails).convex_hull @property @cache diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 307c78b8..7113bf3f 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -87,7 +87,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] @@ -97,12 +97,13 @@ class Stroke(EmbroideryElement): @property @cache def shape(self): + return self.as_multi_line_string().convex_hull + + @cache + def as_multi_line_string(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 shapely.geometry.MultiLineString(line_strings) @property @param('manual_stitch', @@ -167,6 +168,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() diff --git a/lib/elements/utils.py b/lib/elements/utils.py index 99df7002..dafde759 100644 --- a/lib/elements/utils.py +++ b/lib/elements/utils.py @@ -4,36 +4,36 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from ..commands import is_command -from ..patterns import is_pattern +from ..marker import has_marker from ..svg.tags import (EMBROIDERABLE_TAGS, SVG_IMAGE_TAG, SVG_PATH_TAG, SVG_POLYLINE_TAG, SVG_TEXT_TAG) -from .auto_fill import AutoFill +from .fill_stitch import FillStitch from .clone import Clone, is_clone from .element import EmbroideryElement from .empty_d_object import EmptyDObject -from .fill import Fill from .image import ImageObject -from .pattern import PatternObject +from .marker import MarkerObject from .polyline import Polyline from .satin_column import SatinColumn from .stroke import Stroke from .text import TextObject -def node_to_elements(node): # noqa: C901 +def node_to_elements(node, clone_to_element=False): # noqa: C901 if node.tag == SVG_POLYLINE_TAG: return [Polyline(node)] - elif is_clone(node): + elif is_clone(node) and not clone_to_element: + # clone_to_element: get an actual embroiderable element once a clone has been defined as a clone return [Clone(node)] elif node.tag == SVG_PATH_TAG and not node.get('d', ''): return [EmptyDObject(node)] - elif is_pattern(node): - return [PatternObject(node)] + elif has_marker(node): + return [MarkerObject(node)] - elif node.tag in EMBROIDERABLE_TAGS: + elif node.tag in EMBROIDERABLE_TAGS or is_clone(node): element = EmbroideryElement(node) if element.get_boolean_param("satin_column") and element.get_style("stroke"): @@ -41,10 +41,7 @@ def node_to_elements(node): # noqa: C901 else: elements = [] if element.get_style("fill", "black") and not element.get_style('fill-opacity', 1) == "0": - if element.get_boolean_param("auto_fill", True): - elements.append(AutoFill(node)) - else: - elements.append(Fill(node)) + elements.append(FillStitch(node)) if element.get_style("stroke"): if not is_command(element.node): elements.append(Stroke(node)) diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index b6e0d1d1..56949b50 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -6,6 +6,7 @@ from lib.extensions.troubleshoot import Troubleshoot from .apply_threadlist import ApplyThreadlist +from .auto_run import AutoRun from .auto_satin import AutoSatin from .break_apart import BreakApart from .cleanup import Cleanup @@ -39,6 +40,7 @@ from .print_pdf import Print from .remove_embroidery_settings import RemoveEmbroiderySettings from .reorder import Reorder from .selection_to_pattern import SelectionToPattern +from .selection_to_guide_line import SelectionToGuideLine from .simulator import Simulator from .stitch_plan_preview import StitchPlanPreview from .zip import Zip @@ -52,6 +54,7 @@ __all__ = extensions = [StitchPlanPreview, Zip, Flip, SelectionToPattern, + SelectionToGuideLine, ObjectCommands, ObjectCommandsToggleVisibility, LayerCommands, @@ -61,6 +64,7 @@ __all__ = extensions = [StitchPlanPreview, ConvertToStroke, CutSatin, AutoSatin, + AutoRun, Lettering, LetteringGenerateJson, LetteringRemoveKerning, diff --git a/lib/extensions/auto_run.py b/lib/extensions/auto_run.py new file mode 100644 index 00000000..02997fd0 --- /dev/null +++ b/lib/extensions/auto_run.py @@ -0,0 +1,65 @@ +# Authors: see git history +# +# Copyright (c) 2010 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import inkex + +from ..elements import Stroke +from ..i18n import _ +from ..stitches.auto_run import autorun +from .commands import CommandsExtension + + +class AutoRun(CommandsExtension): + COMMANDS = ["trim"] + + def __init__(self, *args, **kwargs): + CommandsExtension.__init__(self, *args, **kwargs) + + self.arg_parser.add_argument("-b", "--break_up", dest="break_up", type=inkex.Boolean, default=True) + self.arg_parser.add_argument("-p", "--preserve_order", dest="preserve_order", type=inkex.Boolean, default=False) + self.arg_parser.add_argument("-o", "--options", dest="options", type=str, default="") + self.arg_parser.add_argument("-i", "--info", dest="help", type=str, default="") + + def effect(self): + elements = self.check_selection() + if not elements: + return + + starting_point = self.get_starting_point() + ending_point = self.get_ending_point() + + break_up = self.options.break_up + + autorun(elements, self.options.preserve_order, break_up, starting_point, ending_point, self.options.trim) + + def get_starting_point(self): + return self.get_command_point("run_start") + + def get_ending_point(self): + return self.get_command_point("run_end") + + def get_command_point(self, command_type): + command = None + for stroke in self.elements: + command = stroke.get_command(command_type) + # return the first occurence directly + if command: + return command.target_point + + def check_selection(self): + if not self.get_elements(): + return + + if not self.svg.selection: + # L10N auto-route running stitch columns extension + inkex.errormsg(_("Please select one or more stroke elements.")) + return False + + elements = [element for element in self.elements if isinstance(element, Stroke)] + if len(elements) == 0: + inkex.errormsg(_("Please select at least one stroke element.")) + return False + + return elements diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 75a07c5a..cf94714c 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -8,16 +8,17 @@ import os import re from collections.abc import MutableMapping -import inkex from lxml import etree from lxml.etree import Comment from stringcase import snakecase +import inkex + from ..commands import is_command, layer_commands from ..elements import EmbroideryElement, nodes_to_elements from ..elements.clone import is_clone from ..i18n import _ -from ..patterns import is_pattern +from ..marker import has_marker from ..svg import generate_unique_id from ..svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS, INKSCAPE_GROUPMODE, NOT_EMBROIDERABLE_TAGS, SVG_CLIPPATH_TAG, SVG_DEFS_TAG, @@ -169,10 +170,10 @@ class InkstitchExtension(inkex.Effect): if selected: if node.tag == SVG_GROUP_TAG: pass - elif (node.tag in EMBROIDERABLE_TAGS or is_clone(node)) and not is_pattern(node): + elif (node.tag in EMBROIDERABLE_TAGS or is_clone(node)) and not has_marker(node): nodes.append(node) - # add images, text and patterns for the troubleshoot extension - elif troubleshoot and (node.tag in NOT_EMBROIDERABLE_TAGS or is_pattern(node)): + # add images, text and elements with a marker for the troubleshoot extension + elif troubleshoot and (node.tag in NOT_EMBROIDERABLE_TAGS or has_marker(node)): nodes.append(node) return nodes diff --git a/lib/extensions/break_apart.py b/lib/extensions/break_apart.py index 5bfd88a4..581e49bc 100644 --- a/lib/extensions/break_apart.py +++ b/lib/extensions/break_apart.py @@ -83,7 +83,7 @@ class BreakApart(InkstitchExtension): if diff.geom_type == 'MultiPolygon': polygons.remove(other) polygons.remove(polygon) - for p in diff: + for p in diff.geoms: polygons.append(p) # it is possible, that a polygons overlap with multiple # polygons, this means, we need to start all over again diff --git a/lib/extensions/cleanup.py b/lib/extensions/cleanup.py index a38818b8..4c350d62 100644 --- a/lib/extensions/cleanup.py +++ b/lib/extensions/cleanup.py @@ -5,7 +5,7 @@ from inkex import NSS, Boolean, errormsg -from ..elements import Fill, Stroke +from ..elements import FillStitch, Stroke from ..i18n import _ from .base import InkstitchExtension @@ -38,7 +38,7 @@ class Cleanup(InkstitchExtension): return for element in self.elements: - if (isinstance(element, Fill) and self.rm_fill and element.shape.area < self.fill_threshold): + if (isinstance(element, FillStitch) and self.rm_fill and element.shape.area < self.fill_threshold): element.node.getparent().remove(element.node) count += 1 if (isinstance(element, Stroke) and self.rm_stroke and diff --git a/lib/extensions/object_commands.py b/lib/extensions/object_commands.py index a3ad6128..4d692cae 100644 --- a/lib/extensions/object_commands.py +++ b/lib/extensions/object_commands.py @@ -34,6 +34,6 @@ class ObjectCommands(CommandsExtension): seen_nodes = set() for element in self.elements: - if element.node not in seen_nodes: + if element.node not in seen_nodes and element.shape: add_commands(element, commands) seen_nodes.add(element.node) diff --git a/lib/extensions/params.py b/lib/extensions/params.py index c96b9691..e50d97d0 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -9,13 +9,13 @@ import os import sys from collections import defaultdict from copy import copy -from itertools import groupby +from itertools import groupby, zip_longest import wx from wx.lib.scrolledpanel import ScrolledPanel from ..commands import is_command, is_command_symbol -from ..elements import (AutoFill, Clone, EmbroideryElement, Fill, Polyline, +from ..elements import (FillStitch, Clone, EmbroideryElement, Polyline, SatinColumn, Stroke) from ..elements.clone import is_clone from ..gui import PresetsPanel, SimulatorPreview, WarningPanel @@ -25,6 +25,11 @@ from ..utils import get_resource_dir from .base import InkstitchExtension +def grouper(iterable_obj, count, fillvalue=None): + args = [iter(iterable_obj)] * count + return zip_longest(*args, fillvalue=fillvalue) + + class ParamsTab(ScrolledPanel): def __init__(self, *args, **kwargs): self.params = kwargs.pop('params', []) @@ -38,6 +43,8 @@ class ParamsTab(ScrolledPanel): self.dependent_tabs = [] self.parent_tab = None self.param_inputs = {} + self.choice_widgets = defaultdict(list) + self.dict_of_choices = {} self.paired_tab = None self.disable_notify_pair = False @@ -46,14 +53,16 @@ class ParamsTab(ScrolledPanel): if toggles: self.toggle = toggles[0] self.params.remove(self.toggle) - self.toggle_checkbox = wx.CheckBox(self, label=self.toggle.description) + self.toggle_checkbox = wx.CheckBox( + self, label=self.toggle.description) value = any(self.toggle.values) if self.toggle.inverse: value = not value self.toggle_checkbox.SetValue(value) - self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.update_toggle_state) + self.toggle_checkbox.Bind( + wx.EVT_CHECKBOX, self.update_toggle_state) self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.changed) self.param_inputs[self.toggle.name] = self.toggle_checkbox @@ -66,7 +75,8 @@ class ParamsTab(ScrolledPanel): self.settings_grid.AddGrowableCol(1, 2) self.settings_grid.SetFlexibleDirection(wx.HORIZONTAL) - self.pencil_icon = wx.Image(os.path.join(get_resource_dir("icons"), "pencil_20x20.png")).ConvertToBitmap() + self.pencil_icon = wx.Image(os.path.join(get_resource_dir( + "icons"), "pencil_20x20.png")).ConvertToBitmap() self.__set_properties() self.__do_layout() @@ -76,7 +86,6 @@ class ParamsTab(ScrolledPanel): # end wxGlade def pair(self, tab): - # print self.name, "paired with", tab.name self.paired_tab = tab self.update_description() @@ -98,7 +107,6 @@ class ParamsTab(ScrolledPanel): def update_toggle_state(self, event=None, notify_pair=True): enable = self.enabled() - # print self.name, "update_toggle_state", enable for child in self.settings_grid.GetChildren(): widget = child.GetWindow() if widget: @@ -113,8 +121,20 @@ class ParamsTab(ScrolledPanel): if event: event.Skip() + def update_choice_state(self, event=None): + input = event.GetEventObject() + selection = input.GetSelection() + + param = self.inputs_to_params[input] + + self.update_choice_widgets((param, selection)) + self.settings_grid.Layout() + self.Layout() + + if event: + event.Skip() + def pair_changed(self, value): - # print self.name, "pair_changed", value new_value = not value if self.enabled() != new_value: @@ -169,7 +189,6 @@ class ParamsTab(ScrolledPanel): def apply(self): values = self.get_values() for node in self.nodes: - # print >> sys.stderr, "apply: ", self.name, node.id, values for name, value in values.items(): node.set_param(name, value) @@ -207,19 +226,25 @@ class ParamsTab(ScrolledPanel): if len(self.nodes) == 1: description = _("These settings will be applied to 1 object.") else: - description = _("These settings will be applied to %d objects.") % len(self.nodes) + description = _( + "These settings will be applied to %d objects.") % len(self.nodes) if any(len(param.values) > 1 for param in self.params): - description += "\n • " + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.") + description += "\n • " + \ + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.") if self.dependent_tabs: if len(self.dependent_tabs) == 1: - description += "\n • " + _("Disabling this tab will disable the following %d tabs.") % len(self.dependent_tabs) + description += "\n • " + \ + _("Disabling this tab will disable the following %d tabs.") % len( + self.dependent_tabs) else: - description += "\n • " + _("Disabling this tab will disable the following tab.") + description += "\n • " + \ + _("Disabling this tab will disable the following tab.") if self.paired_tab: - description += "\n • " + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name + description += "\n • " + \ + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name self.description_text = description @@ -245,35 +270,70 @@ class ParamsTab(ScrolledPanel): # end wxGlade pass - def __do_layout(self): + # choice tuple is None or contains ("choice widget param name", "actual selection") + def update_choice_widgets(self, choice_tuple=None): + if choice_tuple is None: # update all choices + for choice in self.dict_of_choices.values(): + self.update_choice_widgets( + (choice["param"].name, choice["widget"].GetSelection())) + else: + choice = self.dict_of_choices[choice_tuple[0]] + last_selection = choice["last_initialized_choice"] + current_selection = choice["widget"].GetSelection() + if last_selection != -1 and last_selection != current_selection: # Hide the old widgets + for widget in self.choice_widgets[(choice["param"].name, last_selection)]: + widget.Hide() + # self.settings_grid.Detach(widget) + + for widgets in grouper(self.choice_widgets[choice_tuple], 4): + widgets[0].Show(True) + widgets[1].Show(True) + widgets[2].Show(True) + widgets[3].Show(True) + choice["last_initialized_choice"] = current_selection + + def __do_layout(self, only_settings_grid=False): # noqa: C901 + # just to add space around the settings box = wx.BoxSizer(wx.VERTICAL) - summary_box = wx.StaticBox(self, wx.ID_ANY, label=_("Inkscape objects")) + summary_box = wx.StaticBox( + self, wx.ID_ANY, label=_("Inkscape objects")) sizer = wx.StaticBoxSizer(summary_box, wx.HORIZONTAL) self.description = wx.StaticText(self) self.update_description() self.description.SetLabel(self.description_text) self.description_container = box self.Bind(wx.EVT_SIZE, self.resized) - sizer.Add(self.description, proportion=0, flag=wx.EXPAND | wx.ALL, border=5) + sizer.Add(self.description, proportion=0, + flag=wx.EXPAND | wx.ALL, border=5) box.Add(sizer, proportion=0, flag=wx.ALL, border=5) if self.toggle: toggle_sizer = wx.BoxSizer(wx.HORIZONTAL) - toggle_sizer.Add(self.create_change_indicator(self.toggle.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border=5) - toggle_sizer.Add(self.toggle_checkbox, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) + toggle_sizer.Add(self.create_change_indicator( + self.toggle.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border=5) + toggle_sizer.Add(self.toggle_checkbox, proportion=0, + flag=wx.ALIGN_CENTER_VERTICAL) box.Add(toggle_sizer, proportion=0, flag=wx.BOTTOM, border=10) for param in self.params: - self.settings_grid.Add(self.create_change_indicator(param.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) - + col1 = self.create_change_indicator(param.name) description = wx.StaticText(self, label=param.description) description.SetToolTip(param.tooltip) - self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL | wx.TOP, border=5) - if param.type == 'boolean': + if param.select_items is not None: + col1.Hide() + description.Hide() + for item in param.select_items: + self.choice_widgets[item].extend([col1, description]) + # else: + self.settings_grid.Add( + col1, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) + self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND | + wx.RIGHT | wx.ALIGN_CENTER_VERTICAL | wx.TOP, border=5) + if param.type == 'boolean': if len(param.values) > 1: input = wx.CheckBox(self, style=wx.CHK_3STATE) input.Set3StateValue(wx.CHK_UNDETERMINED) @@ -287,8 +347,12 @@ class ParamsTab(ScrolledPanel): input = wx.Choice(self, wx.ID_ANY, choices=param.options) input.SetSelection(int(param.values[0])) input.Bind(wx.EVT_CHOICE, self.changed) + input.Bind(wx.EVT_CHOICE, self.update_choice_state) + self.dict_of_choices[param.name] = { + "param": param, "widget": input, "last_initialized_choice": 1} elif len(param.values) > 1: - input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(str(value) for value in param.values), style=wx.CB_DROPDOWN) + input = wx.ComboBox(self, wx.ID_ANY, choices=sorted( + str(value) for value in param.values), style=wx.CB_DROPDOWN) input.Bind(wx.EVT_COMBOBOX, self.changed) input.Bind(wx.EVT_TEXT, self.changed) else: @@ -298,27 +362,42 @@ class ParamsTab(ScrolledPanel): self.param_inputs[param.name] = input - self.settings_grid.Add(input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL | wx.EXPAND | wx.LEFT, border=40) - self.settings_grid.Add(wx.StaticText(self, label=param.unit or ""), proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) + col4 = wx.StaticText(self, label=param.unit or "") + + if param.select_items is not None: + input.Hide() + col4.Hide() + for item in param.select_items: + self.choice_widgets[item].extend([input, col4]) + # else: + self.settings_grid.Add( + input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL | wx.EXPAND | wx.LEFT, border=40) + self.settings_grid.Add( + col4, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) self.inputs_to_params = {v: k for k, v in self.param_inputs.items()} box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10) self.SetSizer(box) + self.update_choice_widgets() self.Layout() def create_change_indicator(self, param): - indicator = wx.Button(self, style=wx.BORDER_NONE | wx.BU_NOTEXT, size=(28, 28)) - indicator.SetToolTip(_('Click to force this parameter to be saved when you click "Apply and Quit"')) - indicator.Bind(wx.EVT_BUTTON, lambda event: self.enable_change_indicator(param)) + indicator = wx.Button(self, style=wx.BORDER_NONE | + wx.BU_NOTEXT, size=(28, 28)) + indicator.SetToolTip( + _('Click to force this parameter to be saved when you click "Apply and Quit"')) + indicator.Bind( + wx.EVT_BUTTON, lambda event: self.enable_change_indicator(param)) self.param_change_indicators[param] = indicator return indicator def enable_change_indicator(self, param): self.param_change_indicators[param].SetBitmapLabel(self.pencil_icon) - self.param_change_indicators[param].SetToolTip(_('This parameter will be saved when you click "Apply and Quit"')) + self.param_change_indicators[param].SetToolTip( + _('This parameter will be saved when you click "Apply and Quit"')) self.changed_inputs.add(self.param_inputs[param]) @@ -344,7 +423,8 @@ class SettingsFrame(wx.Frame): _("Embroidery Params") ) - icon = wx.Icon(os.path.join(get_resource_dir("icons"), "inkstitch256x256.png")) + icon = wx.Icon(os.path.join( + get_resource_dir("icons"), "inkstitch256x256.png")) self.SetIcon(icon) self.notebook = wx.Notebook(self, wx.ID_ANY) @@ -362,7 +442,8 @@ class SettingsFrame(wx.Frame): self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) self.Bind(wx.EVT_CLOSE, self.cancel) - self.use_last_button = wx.Button(self, wx.ID_ANY, _("Use Last Settings")) + self.use_last_button = wx.Button( + self, wx.ID_ANY, _("Use Last Settings")) self.use_last_button.Bind(wx.EVT_BUTTON, self.use_last) self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) @@ -481,7 +562,8 @@ class SettingsFrame(wx.Frame): for tab in self.tabs: self.notebook.AddPage(tab, tab.name) sizer_1.Add(self.warning_panel, 0, flag=wx.EXPAND | wx.ALL, border=10) - sizer_1.Add(self.notebook, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + sizer_1.Add(self.notebook, 1, wx.EXPAND | + wx.LEFT | wx.TOP | wx.RIGHT, 10) sizer_1.Add(self.presets_panel, 0, flag=wx.EXPAND | wx.ALL, border=10) sizer_3.Add(self.cancel_button, 0, wx.RIGHT, 5) sizer_3.Add(self.use_last_button, 0, wx.RIGHT | wx.BOTTOM, 5) @@ -520,8 +602,7 @@ class Params(InkstitchExtension): classes.append(Clone) else: if element.get_style("fill", 'black') and not element.get_style("fill-opacity", 1) == "0": - classes.append(AutoFill) - classes.append(Fill) + classes.append(FillStitch) if element.get_style("stroke") is not None: classes.append(Stroke) if element.get_style("stroke-dasharray") is None: @@ -548,7 +629,8 @@ class Params(InkstitchExtension): else: getter = 'get_param' - values = [item for item in (getattr(node, getter)(param.name, param.default) for node in nodes) if item is not None] + values = [item for item in (getattr(node, getter)( + param.name, param.default) for node in nodes) if item is not None] return values @@ -614,7 +696,8 @@ class Params(InkstitchExtension): for group, params in self.group_params(params): tab_name = group or cls.element_name - tab = ParamsTab(parent, id=wx.ID_ANY, name=tab_name, params=list(params), nodes=nodes) + tab = ParamsTab(parent, id=wx.ID_ANY, name=tab_name, + params=list(params), nodes=nodes) new_tabs.append(tab) if group == "": @@ -634,14 +717,16 @@ class Params(InkstitchExtension): def effect(self): try: app = wx.App() - frame = SettingsFrame(tabs_factory=self.create_tabs, on_cancel=self.cancel) + frame = SettingsFrame( + tabs_factory=self.create_tabs, on_cancel=self.cancel) # position left, center current_screen = wx.Display.GetFromPoint(wx.GetMousePosition()) display = wx.Display(current_screen) display_size = display.GetClientArea() frame_size = frame.GetSize() - frame.SetPosition((int(display_size[0]), int(display_size[3]/2 - frame_size[1]/2))) + frame.SetPosition((int(display_size[0]), int( + display_size[3]/2 - frame_size[1]/2))) frame.Show() app.MainLoop() diff --git a/lib/extensions/reorder.py b/lib/extensions/reorder.py index 83ecfe26..956c0615 100644 --- a/lib/extensions/reorder.py +++ b/lib/extensions/reorder.py @@ -17,7 +17,7 @@ class Reorder(InkstitchExtension): objects = self.svg.selection if not objects: - errormsg(_("Please select at least to elements to reorder.")) + errormsg(_("Please select at least two elements to reorder.")) return for obj in objects: diff --git a/lib/extensions/selection_to_guide_line.py b/lib/extensions/selection_to_guide_line.py new file mode 100644 index 00000000..a0d32601 --- /dev/null +++ b/lib/extensions/selection_to_guide_line.py @@ -0,0 +1,26 @@ +# Authors: see git history +# +# Copyright (c) 2021 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import inkex + +from ..i18n import _ +from ..marker import set_marker +from ..svg.tags import EMBROIDERABLE_TAGS +from .base import InkstitchExtension + + +class SelectionToGuideLine(InkstitchExtension): + + def effect(self): + if not self.get_elements(): + return + + if not self.svg.selected: + inkex.errormsg(_("Please select at least one object to be marked as a guide line.")) + return + + for pattern in self.get_nodes(): + if pattern.tag in EMBROIDERABLE_TAGS: + set_marker(pattern, 'start', 'guide-line') diff --git a/lib/marker.py b/lib/marker.py index 4f262abe..56a43c3b 100644 --- a/lib/marker.py +++ b/lib/marker.py @@ -7,10 +7,12 @@ from copy import deepcopy from os import path import inkex +from shapely import geometry as shgeo +from .svg.tags import EMBROIDERABLE_TAGS from .utils import cache, get_bundled_dir -MARKER = ['pattern'] +MARKER = ['pattern', 'guide-line'] def ensure_marker(svg, marker): @@ -33,5 +35,45 @@ def set_marker(node, position, marker): style = node.get('style') or '' style = style.split(";") style = [i for i in style if not i.startswith('marker-%s' % position)] - style.append('marker-%s:url(#inkstitch-pattern-marker)' % position) + style.append('marker-%s:url(#inkstitch-%s-marker)' % (position, marker)) node.set('style', ";".join(style)) + + +def get_marker_elements(node, marker, get_fills=True, get_strokes=True): + from .elements import EmbroideryElement + from .elements.stroke import Stroke + + fills = [] + strokes = [] + xpath = "./parent::svg:g/*[contains(@style, 'marker-start:url(#inkstitch-%s-marker)')]" % marker + markers = node.xpath(xpath, namespaces=inkex.NSS) + for marker in markers: + if marker.tag not in EMBROIDERABLE_TAGS: + continue + + element = EmbroideryElement(marker) + fill = element.get_style('fill') + stroke = element.get_style('stroke') + + if get_fills and fill is not None: + fill = Stroke(marker).paths + linear_rings = [shgeo.LinearRing(path) for path in fill] + for ring in linear_rings: + fills.append(shgeo.Polygon(ring)) + + if get_strokes and stroke is not None: + stroke = Stroke(marker).paths + line_strings = [shgeo.LineString(path) for path in stroke] + strokes.append(shgeo.MultiLineString(line_strings)) + + return {'fill': fills, 'stroke': strokes} + + +def has_marker(node, marker=list()): + if not marker: + marker = MARKER + for m in marker: + style = node.get('style') or '' + if "marker-start:url(#inkstitch-%s-marker)" % m in style: + return True + return False diff --git a/lib/patterns.py b/lib/patterns.py index da22f21b..aca6155c 100644 --- a/lib/patterns.py +++ b/lib/patterns.py @@ -3,25 +3,17 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -import inkex from shapely import geometry as shgeo +from .marker import get_marker_elements from .stitch_plan import Stitch -from .svg.tags import EMBROIDERABLE_TAGS from .utils import Point -def is_pattern(node): - if node.tag not in EMBROIDERABLE_TAGS: - return False - style = node.get('style') or '' - return "marker-start:url(#inkstitch-pattern-marker)" in style - - def apply_patterns(patches, node): - patterns = _get_patterns(node) - _apply_fill_patterns(patterns['fill_patterns'], patches) - _apply_stroke_patterns(patterns['stroke_patterns'], patches) + patterns = get_marker_elements(node, "pattern") + _apply_fill_patterns(patterns['fill'], patches) + _apply_stroke_patterns(patterns['stroke'], patches) def _apply_stroke_patterns(patterns, patches): @@ -64,43 +56,13 @@ def _apply_fill_patterns(patterns, patches): patch.stitches = patch_points -def _get_patterns(node): - from .elements import EmbroideryElement - from .elements.stroke import Stroke - - fills = [] - strokes = [] - xpath = "./parent::svg:g/*[contains(@style, 'marker-start:url(#inkstitch-pattern-marker)')]" - patterns = node.xpath(xpath, namespaces=inkex.NSS) - for pattern in patterns: - if pattern.tag not in EMBROIDERABLE_TAGS: - continue - - element = EmbroideryElement(pattern) - fill = element.get_style('fill') - stroke = element.get_style('stroke') - - if fill is not None: - fill_pattern = Stroke(pattern).paths - linear_rings = [shgeo.LinearRing(path) for path in fill_pattern] - for ring in linear_rings: - fills.append(shgeo.Polygon(ring)) - - if stroke is not None: - stroke_pattern = Stroke(pattern).paths - line_strings = [shgeo.LineString(path) for path in stroke_pattern] - strokes.append(shgeo.MultiLineString(line_strings)) - - return {'fill_patterns': fills, 'stroke_patterns': strokes} - - def _get_pattern_points(first, second, pattern): points = [] intersection = shgeo.LineString([first, second]).intersection(pattern) if isinstance(intersection, shgeo.Point): points.append(Point(intersection.x, intersection.y)) if isinstance(intersection, shgeo.MultiPoint): - for point in intersection: + for point in intersection.geoms: points.append(Point(point.x, point.y)) # sort points after their distance to first points.sort(key=lambda point: point.distance(first)) diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py index 4de88733..8b2738bc 100644 --- a/lib/stitches/__init__.py +++ b/lib/stitches/__init__.py @@ -5,6 +5,7 @@ from .auto_fill import auto_fill from .fill import legacy_fill +from .guided_fill import guided_fill from .running_stitch import * # Can't put this here because we get a circular import :( diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 160d927e..65b1e06d 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -16,8 +16,7 @@ from shapely.strtree import STRtree from ..debug import debug from ..stitch_plan import Stitch from ..svg import PIXELS_PER_MM -from ..utils.geometry import Point as InkstitchPoint -from ..utils.geometry import line_string_to_point_list +from ..utils.geometry import Point as InkstitchPoint, line_string_to_point_list, ensure_multi_line_string from .fill import intersect_region_with_grating, stitch_row from .running_stitch import running_stitch @@ -59,9 +58,10 @@ def auto_fill(shape, starting_point, ending_point=None, underpath=True): - fill_stitch_graph = [] try: - fill_stitch_graph = build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point, ending_point) + rows = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) + segments = [segment for row in rows for segment in row] + fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) except ValueError: # Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node return fallback(shape, running_stitch_length) @@ -88,9 +88,10 @@ def which_outline(shape, coords): # fail sometimes. point = shgeo.Point(*coords) - outlines = list(shape.boundary) + outlines = ensure_multi_line_string(shape.boundary).geoms outline_indices = list(range(len(outlines))) - closest = min(outline_indices, key=lambda index: outlines[index].distance(point)) + closest = min(outline_indices, + key=lambda index: outlines[index].distance(point)) return closest @@ -101,12 +102,12 @@ def project(shape, coords, outline_index): This returns the distance along the outline at which the point resides. """ - outline = list(shape.boundary)[outline_index] + outline = ensure_multi_line_string(shape.boundary).geoms[outline_index] return outline.project(shgeo.Point(*coords)) @debug.time -def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point=None, ending_point=None): +def build_fill_stitch_graph(shape, segments, starting_point=None, ending_point=None): """build a graph representation of the grating segments This function builds a specialized graph (as in graph theory) that will @@ -141,10 +142,6 @@ def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting debug.add_layer("auto-fill fill stitch") - # Convert the shape into a set of parallel line segments. - rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) - segments = [segment for row in rows_of_segments for segment in row] - graph = networkx.MultiGraph() # First, add the grating segments as edges. We'll use the coordinates @@ -152,7 +149,7 @@ def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting for segment in segments: # networkx allows us to label nodes with arbitrary data. We'll # mark this one as a grating segment. - graph.add_edge(*segment, key="segment", underpath_edges=[]) + graph.add_edge(segment[0], segment[-1], key="segment", underpath_edges=[], geometry=shgeo.LineString(segment)) tag_nodes_with_outline_and_projection(graph, shape, graph.nodes()) add_edges_between_outline_nodes(graph, duplicate_every_other=True) @@ -174,7 +171,7 @@ def insert_node(graph, shape, point): point = tuple(point) outline = which_outline(shape, point) projection = project(shape, point, outline) - projected_point = list(shape.boundary)[outline].interpolate(projection) + projected_point = ensure_multi_line_string(shape.boundary).geoms[outline].interpolate(projection) node = (projected_point.x, projected_point.y) edges = [] @@ -199,7 +196,8 @@ def tag_nodes_with_outline_and_projection(graph, shape, nodes): def add_boundary_travel_nodes(graph, shape): - for outline_index, outline in enumerate(shape.boundary): + outlines = ensure_multi_line_string(shape.boundary).geoms + for outline_index, outline in enumerate(outlines): prev = None for point in outline.coords: point = shgeo.Point(point) @@ -230,7 +228,8 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False): outline. """ - nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] + # returns a list of tuples: [(node, {data}), (node, {data}) ...] + nodes = list(graph.nodes(data=True)) nodes.sort(key=lambda node: (node[1]['outline'], node[1]['projection'])) for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['outline']): @@ -261,7 +260,10 @@ def fallback(shape, running_stitch_length): matter. """ - return running_stitch(line_string_to_point_list(shape.boundary[0]), running_stitch_length) + boundary = ensure_multi_line_string(shape.boundary) + outline = boundary.geoms[0] + + return running_stitch(line_string_to_point_list(outline), running_stitch_length) @debug.time @@ -325,7 +327,7 @@ def get_segments(graph): segments = [] for start, end, key, data in graph.edges(keys=True, data=True): if key == 'segment': - segments.append(shgeo.LineString((start, end))) + segments.append(data["geometry"]) return segments @@ -363,7 +365,8 @@ def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges): # segments that _might_ intersect ls. Refining the result is # necessary but the STRTree still saves us a ton of time. if segment.crosses(ls): - start, end = segment.coords + start = segment.coords[0] + end = segment.coords[-1] fill_stitch_graph[start][end]['segment']['underpath_edges'].append(edge) # The weight of a travel edge is the length of the line segment. @@ -384,19 +387,10 @@ def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges): def travel_grating(shape, angle, row_spacing): - rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing) - segments = list(chain(*rows_of_segments)) - - return shgeo.MultiLineString(segments) + rows = intersect_region_with_grating(shape, angle, row_spacing) + segments = [segment for row in rows for segment in row] - -def ensure_multi_line_string(thing): - """Given either a MultiLineString or a single LineString, return a MultiLineString""" - - if isinstance(thing, shgeo.LineString): - return shgeo.MultiLineString([thing]) - else: - return thing + return shgeo.MultiLineString(list(segments)) def build_travel_edges(shape, fill_angle): @@ -443,7 +437,7 @@ def build_travel_edges(shape, fill_angle): debug.log_line_strings(grating3, "grating3") endpoints = [coord for mls in (grating1, grating2, grating3) - for ls in mls + for ls in mls.geoms for coord in ls.coords] diagonal_edges = ensure_multi_line_string(grating1.symmetric_difference(grating2)) @@ -451,7 +445,7 @@ def build_travel_edges(shape, fill_angle): # without this, floating point inaccuracies prevent the intersection points from lining up perfectly. vertical_edges = ensure_multi_line_string(snap(grating3.difference(grating1), diagonal_edges, 0.005)) - return endpoints, chain(diagonal_edges, vertical_edges) + return endpoints, chain(diagonal_edges.geoms, vertical_edges.geoms) def nearest_node(nodes, point, attr=None): diff --git a/lib/stitches/auto_run.py b/lib/stitches/auto_run.py new file mode 100644 index 00000000..847a1bcd --- /dev/null +++ b/lib/stitches/auto_run.py @@ -0,0 +1,284 @@ +# Authors: see git history +# +# Copyright (c) 2022 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +from collections import defaultdict + +import networkx as nx +from shapely.geometry import LineString, MultiLineString, MultiPoint, Point +from shapely.ops import nearest_points, substring, unary_union + +import inkex + +from ..commands import add_commands +from ..elements import Stroke +from ..i18n import _ +from ..svg import PIXELS_PER_MM, generate_unique_id +from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS +from .utils.autoroute import (add_elements_to_group, add_jumps, + create_new_group, find_path, + get_starting_and_ending_nodes, + preserve_original_groups, + remove_original_elements) + + +class LineSegments: + ''' + Takes elements and splits them into segments. + + Attributes: + _lines -- a list of LineStrings from the subpaths of the Stroke elements + _elements -- a list of Stroke elements for each corresponding line in _lines + _intersection_points -- a dictionary with intersection points {line_index: [intersection_points]} + segments -- (public) a list of segments and corresponding elements [[segment, element], ...] + ''' + + def __init__(self, elements): + self._lines = [] + self._elements = [] + self._intersection_points = defaultdict(list) + self.segments = [] + + self._process_elements(elements) + self._get_intersection_points() + self._get_segments() + + def _process_elements(self, elements): + for element in elements: + lines = element.as_multi_line_string().geoms + + for line in lines: + # split at self-intersections if necessary + unary_lines = unary_union(line) + if isinstance(unary_lines, MultiLineString): + for unary_line in unary_lines.geoms: + self._lines.append(unary_line) + self._elements.append(element) + else: + self._lines.append(line) + self._elements.append(element) + + def _get_intersection_points(self): + for i, line1 in enumerate(self._lines): + for j in range(i + 1, len(self._lines)): + line2 = self._lines[j] + distance = line1.distance(line2) + if distance > 50: + continue + if not distance == 0: + # add nearest points + near = nearest_points(line1, line2) + self._add_point(i, near[0]) + self._add_point(j, near[1]) + # add intersections + intersections = line1.intersection(line2) + if isinstance(intersections, Point): + self._add_point(i, intersections) + self._add_point(j, intersections) + elif isinstance(intersections, MultiPoint): + for point in intersections.geoms: + self._add_point(i, point) + self._add_point(j, point) + elif isinstance(intersections, LineString): + for point in intersections.coords: + self._add_point(i, Point(*point)) + self._add_point(j, Point(*point)) + + def _add_point(self, element, point): + self._intersection_points[element].append(point) + + def _get_segments(self): + ''' + Splits elements into segments at intersection and "almost intersecions". + The split method would make this very easy (it can split a MultiString with + MultiPoints) but sadly it fails too often, while snap moves the points away + from where we want them. So we need to calculate the distance along the line + and finally split it into segments with shapelys substring method. + ''' + self.segments = [] + for i, line in enumerate(self._lines): + length = line.length + points = self._intersection_points[i] + + distances = [0, length] + for point in points: + distances.append(line.project(point)) + distances = sorted(set(distances)) + + for j in range(len(distances) - 1): + start = distances[j] + end = distances[j + 1] + + if end - start > 0.1: + seg = substring(line, start, end) + self.segments.append([seg, self._elements[i]]) + + +def autorun(elements, preserve_order=False, break_up=None, starting_point=None, ending_point=None, trim=False): + graph = build_graph(elements, preserve_order, break_up) + graph = add_jumps(graph, elements, preserve_order) + + starting_point, ending_point = get_starting_and_ending_nodes( + graph, elements, preserve_order, starting_point, ending_point) + + path = find_path(graph, starting_point, ending_point) + path = add_path_attribs(path) + + new_elements, trims, original_parents = path_to_elements(graph, path, trim) + + if preserve_order: + preserve_original_groups(new_elements, original_parents) + else: + parent = elements[0].node.getparent() + insert_index = parent.index(elements[0].node) + group = create_new_group(parent, insert_index, _("Auto-Run")) + add_elements_to_group(new_elements, group) + + if trim: + add_trims(new_elements, trims) + + remove_original_elements(elements) + + +def build_graph(elements, preserve_order, break_up): + if preserve_order: + graph = nx.DiGraph() + else: + graph = nx.Graph() + + if not break_up: + segments = [] + for element in elements: + line_strings = [[line, element] for line in element.as_multi_line_string().geoms] + segments.extend(line_strings) + else: + segments = LineSegments(elements).segments + + for segment, element in segments: + for c1, c2 in zip(segment.coords[:-1], segment.coords[1:]): + start = Point(*c1) + end = Point(*c2) + + graph.add_node(str(start), point=start) + graph.add_node(str(end), point=end) + graph.add_edge(str(start), str(end), element=element) + + if preserve_order: + # The graph is a directed graph, but we want to allow travel in + # any direction, so we add the edge in the opposite direction too. + graph.add_edge(str(end), str(start), element=element) + + return graph + + +def add_path_attribs(path): + # find_path() will have duplicated some of the edges in the graph. We don't + # want to sew the same running stitch twice. If a running stitch section appears + # twice in the path, we'll sew the first occurrence as a simple running stitch without + # the original running stitch repetitions and bean stitch settings. + seen = set() + for i, point in reversed(list(enumerate(path))): + if point in seen: + path[i] = (*point, "underpath") + else: + path[i] = (*point, "autorun") + seen.add(point) + seen.add((point[1], point[0])) + return path + + +def path_to_elements(graph, path, trim): # noqa: C901 + element_list = [] + original_parents = [] + trims = [] + + d = "" + position = 0 + path_direction = "autorun" + just_trimmed = False + el = None + for start, end, direction in path: + element = graph[start][end].get('element') + start_coord = graph.nodes[start]['point'] + end_coord = graph.nodes[end]['point'] + if element: + el = element + + if just_trimmed: + if direction == "underpath": + # no sense in doing underpath after we trim + continue + else: + just_trimmed = False + + # create a new element if direction (purpose) changes + if direction != path_direction: + if d: + element_list.append(create_element(d, position, path_direction, el)) + original_parents.append(el.node.getparent()) + d = "" + position += 1 + path_direction = direction + + if d == "": + d = "M %s %s, %s %s" % (start_coord.x, start_coord.y, end_coord.x, end_coord.y) + else: + d += ", %s %s" % (end_coord.x, end_coord.y) + elif el and d: + # this is a jump, so complete the element whose path we've been building + element_list.append(create_element(d, position, path_direction, el)) + original_parents.append(el.node.getparent()) + d = "" + + if trim and start_coord.distance(end_coord) > 0.75 * PIXELS_PER_MM: + trims.append(position) + just_trimmed = True + + position += 1 + + if d: + element_list.append(create_element(d, position, path_direction, el)) + original_parents.append(el.node.getparent()) + + return element_list, trims, original_parents + + +def create_element(path, position, direction, element): + if not path: + return + + style = inkex.Style(element.node.get("style")) + style = style + inkex.Style("stroke-dasharray:0.5,0.5;fill:none;") + el_id = "%s_%s_" % (direction, position) + + index = position + 1 + if direction == "autorun": + label = _("AutoRun %d") % index + else: + label = _("AutoRun Underpath %d") % index + + stitch_length = element.node.get(INKSTITCH_ATTRIBS['running_stitch_length_mm'], '') + bean = element.node.get(INKSTITCH_ATTRIBS['bean_stitch_repeats'], 0) + repeats = int(element.node.get(INKSTITCH_ATTRIBS['repeats'], 1)) + if repeats % 2 == 0: + repeats -= 1 + + node = inkex.PathElement() + node.set("id", generate_unique_id(element.node, el_id)) + node.set(INKSCAPE_LABEL, label) + node.set("d", path) + node.set("style", str(style)) + if stitch_length: + node.set(INKSTITCH_ATTRIBS['running_stitch_length_mm'], stitch_length) + if direction == "autorun": + node.set(INKSTITCH_ATTRIBS['repeats'], str(repeats)) + if bean: + node.set(INKSTITCH_ATTRIBS['bean_stitch_repeats'], bean) + + return Stroke(node) + + +def add_trims(elements, trim_indices): + for i in trim_indices: + add_commands(elements[i], ["trim"]) diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py index 2b7f0906..ba5c8698 100644 --- a/lib/stitches/auto_satin.py +++ b/lib/stitches/auto_satin.py @@ -6,19 +6,24 @@ import math from itertools import chain -import inkex import networkx as nx from shapely import geometry as shgeo from shapely.geometry import Point as ShapelyPoint +import inkex + from ..commands import add_commands from ..elements import SatinColumn, Stroke from ..i18n import _ -from ..svg import (PIXELS_PER_MM, generate_unique_id, get_correction_transform, - line_strings_to_csp) -from ..svg.tags import (INKSCAPE_LABEL, INKSTITCH_ATTRIBS) +from ..svg import PIXELS_PER_MM, generate_unique_id, line_strings_to_csp +from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS from ..utils import Point as InkstitchPoint from ..utils import cache, cut +from .utils.autoroute import (add_elements_to_group, add_jumps, + create_new_group, find_path, + get_starting_and_ending_nodes, + preserve_original_groups, + remove_original_elements) class SatinSegment(object): @@ -177,7 +182,7 @@ class SatinSegment(object): class JumpStitch(object): """A jump stitch between two points.""" - def __init__(self, start, end): + def __init__(self, start, end, source_element, destination_element): """Initialize a JumpStitch. Arguments: @@ -186,6 +191,8 @@ class JumpStitch(object): self.start = start self.end = end + self.source_element = source_element + self.destination_element = destination_element def is_sequential(self, other): # Don't bother joining jump stitches. @@ -196,6 +203,15 @@ class JumpStitch(object): def length(self): return self.start.distance(self.end) + def as_line_string(self): + return shgeo.LineString((self.start, self.end)) + + def should_trim(self): + actual_jump = self.as_line_string().difference(self.source_element.shape) + actual_jump = actual_jump.difference(self.destination_element.shape) + + return actual_jump.length > PIXELS_PER_MM + class RunningStitch(object): """Running stitch along a path.""" @@ -326,7 +342,7 @@ def auto_satin(elements, preserve_order=False, starting_point=None, ending_point if preserve_order: preserve_original_groups(new_elements, original_parents) else: - group = create_new_group(parent, index) + group = create_new_group(parent, index, _("Auto-Route")) add_elements_to_group(new_elements, group) name_elements(new_elements, preserve_order) @@ -358,8 +374,8 @@ def build_graph(elements, preserve_order=False): for segment in segments: # This is necessary because shapely points aren't hashable and thus # can't be used as nodes directly. - graph.add_node(str(segment.start_point), point=segment.start_point) - graph.add_node(str(segment.end_point), point=segment.end_point) + graph.add_node(str(segment.start_point), point=segment.start_point, element=element) + graph.add_node(str(segment.end_point), point=segment.end_point, element=element) graph.add_edge(str(segment.start_point), str( segment.end_point), segment=segment, element=element) @@ -373,168 +389,6 @@ def build_graph(elements, preserve_order=False): return graph -def get_starting_and_ending_nodes(graph, elements, preserve_order, starting_point, ending_point): - """Find or choose the starting and ending graph nodes. - - If points were passed, we'll find the nearest graph nodes. Since we split - every satin up into 1mm-chunks, we'll be at most 1mm away which is good - enough. - - If we weren't given starting and ending points, we'll pic kthe far left and - right nodes. - - returns: - (starting graph node, ending graph node) - """ - - nodes = [] - - nodes.append(find_node(graph, starting_point, - min, preserve_order, elements[0])) - nodes.append(find_node(graph, ending_point, - max, preserve_order, elements[-1])) - - return nodes - - -def find_node(graph, point, extreme_function, constrain_to_satin=False, satin=None): - if constrain_to_satin: - nodes = get_nodes_on_element(graph, satin) - else: - nodes = graph.nodes() - - if point is None: - return extreme_function(nodes, key=lambda node: graph.nodes[node]['point'].x) - else: - point = shgeo.Point(*point) - return min(nodes, key=lambda node: graph.nodes[node]['point'].distance(point)) - - -def get_nodes_on_element(graph, element): - nodes = set() - - for start_node, end_node, element_for_edge in graph.edges(data='element'): - if element_for_edge is element: - nodes.add(start_node) - nodes.add(end_node) - - return nodes - - -def add_jumps(graph, elements, preserve_order): - """Add jump stitches between elements as necessary. - - Jump stitches are added to ensure that all elements can be reached. Only the - minimal number and length of jumps necessary will be added. - """ - - if preserve_order: - # For each sequential pair of elements, find the shortest possible jump - # stitch between them and add it. The directions of these new edges - # will enforce stitching the satins in order. - - for element1, element2 in zip(elements[:-1], elements[1:]): - potential_edges = [] - - nodes1 = get_nodes_on_element(graph, element1) - nodes2 = get_nodes_on_element(graph, element2) - - for node1 in nodes1: - for node2 in nodes2: - point1 = graph.nodes[node1]['point'] - point2 = graph.nodes[node2]['point'] - potential_edges.append((point1, point2)) - - if potential_edges: - edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1])) - graph.add_edge(str(edge[0]), str(edge[1]), jump=True) - else: - # networkx makes this super-easy! k_edge_agumentation tells us what edges - # we need to add to ensure that the graph is fully connected. We give it a - # set of possible edges that it can consider adding (avail). Each edge has - # a weight, which we'll set as the length of the jump stitch. The - # algorithm will minimize the total length of jump stitches added. - for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): - graph.add_edge(*jump, jump=True) - - -def possible_jumps(graph): - """All possible jump stitches in the graph with their lengths. - - Returns: a generator of tuples: (node1, node2, length) - """ - - # We'll take the easy approach and list all edges that aren't already in - # the graph. networkx's algorithm is pretty efficient at ignoring - # pointless options like jumping between two points on the same satin. - - for start, end in nx.complement(graph).edges(): - start_point = graph.nodes[start]['point'] - end_point = graph.nodes[end]['point'] - yield (start, end, start_point.distance(end_point)) - - -def find_path(graph, starting_node, ending_node): - """Find a path through the graph that sews every satin.""" - - # This is done in two steps. First, we find the shortest path from the - # start to the end. We remove it from the graph, and proceed to step 2. - # - # Then, we traverse the path node by node. At each node, we follow any - # branchings with a depth-first search. We travel down each branch of - # the tree, inserting each seen branch into the tree. When the DFS - # hits a dead-end, as it back-tracks, we also add the seen edges _again_. - # Repeat until there are no more edges left in the graph. - # - # Visiting the edges again on the way back allows us to set up - # "underpathing". As we stitch down each branch, we'll do running stitch. - # Then when we come back up, we'll do satin stitch, covering the previous - # running stitch. - path = nx.shortest_path(graph, starting_node, ending_node) - - # Copy the graph so that we can remove the edges as we visit them. - # This also converts the directed graph into an undirected graph in the - # case that "preserve_order" is set. This way we avoid going back and - # forth on each satin twice due to the satin edges being in the graph - # twice (forward and reverse). - graph = nx.Graph(graph) - graph.remove_edges_from(list(zip(path[:-1], path[1:]))) - - final_path = [] - prev = None - for node in path: - if prev is not None: - final_path.append((prev, node)) - prev = node - - for n1, n2, edge_type in list(nx.dfs_labeled_edges(graph, node)): - if n1 == n2: - # dfs_labeled_edges gives us (start, start, "forward") for - # the starting node for some reason - continue - - if edge_type == "forward": - final_path.append((n1, n2)) - graph.remove_edge(n1, n2) - elif edge_type == "reverse": - final_path.append((n2, n1)) - elif edge_type == "nontree": - # a "nontree" happens when there exists an edge from n1 to n2 - # but n2 has already been visited. It's a dead-end that runs - # into part of the graph that we've already traversed. We - # do still need to make sure that satin is sewn, so we travel - # down and back on this edge. - # - # It's possible for a given "nontree" edge to be listed more - # than once so we'll deduplicate. - if (n1, n2) in graph.edges: - final_path.append((n1, n2)) - final_path.append((n2, n1)) - graph.remove_edge(n1, n2) - - return final_path - - def reversed_path(path): """Generator for a version of the path travelling in the opposite direction. @@ -563,7 +417,10 @@ def path_to_operations(graph, path): segment = segment.reversed() operations.append(segment) else: - operations.append(JumpStitch(graph.nodes[start]['point'], graph.nodes[end]['point'])) + operations.append(JumpStitch(graph.nodes[start]['point'], + graph.nodes[end]['point'], + graph.nodes[start]['element'], + graph.nodes[end]['element'])) # find_path() will have duplicated some of the edges in the graph. We don't # want to sew the same satin twice. If a satin section appears twice in the @@ -616,59 +473,12 @@ def operations_to_elements_and_trims(operations, preserve_order): elements.append(operation.to_element()) original_parent_nodes.append(operation.original_node.getparent()) elif isinstance(operation, (JumpStitch)): - if elements and operation.length > 0.75 * PIXELS_PER_MM: + if elements and operation.should_trim(): trims.append(len(elements) - 1) return elements, list(set(trims)), original_parent_nodes -def remove_original_elements(elements): - for element in elements: - for command in element.commands: - command_group = command.use.getparent() - if command_group is not None and command_group.get('id').startswith('command_group'): - remove_from_parent(command_group) - else: - remove_from_parent(command.connector) - remove_from_parent(command.use) - remove_from_parent(element.node) - - -def remove_from_parent(node): - if node.getparent() is not None: - node.getparent().remove(node) - - -def preserve_original_groups(elements, original_parent_nodes): - """Ensure that all elements are contained in the original SVG group elements. - - When preserve_order is True, no SatinColumn or Stroke elements will be - reordered in the XML tree. This makes it possible to preserve original SVG - group membership. We'll ensure that each newly-created Element is added - to the group that contained the original SatinColumn that spawned it. - """ - - for element, parent in zip(elements, original_parent_nodes): - if parent is not None: - parent.append(element.node) - element.node.set('transform', get_correction_transform(parent, child=True)) - - -def create_new_group(parent, insert_index): - group = inkex.Group(attrib={ - INKSCAPE_LABEL: _("Auto-Satin"), - "transform": get_correction_transform(parent, child=True) - }) - parent.insert(insert_index, group) - - return group - - -def add_elements_to_group(elements, group): - for element in elements: - group.append(element.node) - - def name_elements(new_elements, preserve_order): """Give the newly-created SVG objects useful names. diff --git a/lib/stitches/contour_fill.py b/lib/stitches/contour_fill.py new file mode 100644 index 00000000..c42cc6f2 --- /dev/null +++ b/lib/stitches/contour_fill.py @@ -0,0 +1,551 @@ +from collections import namedtuple +from itertools import chain + +import networkx as nx +import numpy as np +import trimesh +from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, LineString, Point +from shapely.geometry.polygon import orient +from shapely.ops import nearest_points +from shapely.ops import polygonize + +from .running_stitch import running_stitch +from ..stitch_plan import Stitch +from ..utils import DotDict +from ..utils.geometry import cut, reverse_line_string, roll_linear_ring +from ..utils.geometry import ensure_geometry_collection, ensure_multi_polygon + + +class Tree(nx.DiGraph): + # This lets us do tree.nodes['somenode'].parent instead of the default + # tree.nodes['somenode']['parent']. + node_attr_dict_factory = DotDict + + def __init__(self, *args, **kwargs): + self.__node_num = 0 + super().__init__(**kwargs) + + def generate_node_name(self): + node = self.__node_num + self.__node_num += 1 + + return node + + +nearest_neighbor_tuple = namedtuple( + "nearest_neighbor_tuple", + [ + "nearest_point_parent", + "nearest_point_child", + "proj_distance_parent", + "child_node", + ], +) + + +def _offset_linear_ring(ring, offset, resolution, join_style, mitre_limit): + result = Polygon(ring).buffer(-offset, resolution, cap_style=2, join_style=join_style, mitre_limit=mitre_limit, single_sided=True) + result = ensure_multi_polygon(result) + rings = GeometryCollection([poly.exterior for poly in result.geoms]) + rings = rings.simplify(0.01, False) + + return _take_only_valid_linear_rings(rings) + + +def _take_only_valid_linear_rings(rings): + """ + Removes all geometries which do not form a "valid" LinearRing. + + A "valid" ring is one that does not form a straight line. + """ + + valid_rings = [] + + for ring in ensure_geometry_collection(rings).geoms: + if len(ring.coords) > 3 or (len(ring.coords) == 3 and ring.coords[0] != ring.coords[-1]): + valid_rings.append(ring) + + return GeometryCollection(valid_rings) + + +def _orient_linear_ring(ring, clockwise=True): + # Unfortunately for us, Inkscape SVGs have an inverted Y coordinate. + # Normally we don't have to care about that, but in this very specific + # case, the meaning of is_ccw is flipped. It actually tests whether + # a ring is clockwise. That makes this logic super-confusing. + if ring.is_ccw != clockwise: + return reverse_line_string(ring) + else: + return ring + + +def _orient_tree(tree, clockwise=True): + """ + Orient all linear rings in the tree. + + Since naturally holes have the opposite point ordering than non-holes we + make all lines within the tree uniform (having all the same ordering + direction) + """ + + for node in tree.nodes.values(): + node.val = _orient_linear_ring(node.val, clockwise) + + +def offset_polygon(polygon, offset, join_style, clockwise): + """ + Convert a polygon to a tree of isocontours. + + An isocontour is an offset version of the polygon's boundary. For example, + the isocontours of a circle are a set of concentric circles inside the + circle. + + This function takes a polygon (which may have holes) as input and creates + isocontours until the polygon is filled completely. The isocontours are + returned as a Tree, with a parent-child relationship indicating that the + parent isocontour contains the child isocontour. + + Arguments: + polygon - The shapely Polygon which may have holes + offset - The spacing between isocontours + join_style - Join style used when offsetting the Polygon border to create + isocontours. Can be round, mitered or bevel, as defined by + shapely: + https://shapely.readthedocs.io/en/stable/manual.html#shapely.geometry.JOIN_STYLE + clockwise - If True, isocontour points are in clockwise order; if False, counter-clockwise. + + Return Value: + Tree - see above + """ + + ordered_polygon = orient(polygon, -1) + tree = Tree() + tree.add_node('root', type='node', parent=None, val=ordered_polygon.exterior) + active_polygons = ['root'] + active_holes = [[]] + + for hole in ordered_polygon.interiors: + hole_node = tree.generate_node_name() + tree.add_node(hole_node, type="hole", val=hole) + active_holes[0].append(hole_node) + + while len(active_polygons) > 0: + current_poly = active_polygons.pop() + current_holes = active_holes.pop() + + outer, inners = _offset_polygon_and_holes(tree, current_poly, current_holes, offset, join_style) + polygons = _match_polygons_and_holes(outer, inners) + + for polygon in polygons.geoms: + new_polygon, new_holes = _convert_polygon_to_nodes(tree, polygon, parent_polygon=current_poly, child_holes=current_holes) + + if new_polygon is not None: + active_polygons.append(new_polygon) + active_holes.append(new_holes) + + for previous_hole in current_holes: + # If the previous holes are not + # contained in the new holes they + # have been merged with the + # outer polygon + if not tree.nodes[previous_hole].parent: + tree.nodes[previous_hole].parent = current_poly + tree.add_edge(current_poly, previous_hole) + + _orient_tree(tree, clockwise) + return tree + + +def _offset_polygon_and_holes(tree, poly, holes, offset, join_style): + outer = _offset_linear_ring( + tree.nodes[poly].val, + offset, + resolution=5, + join_style=join_style, + mitre_limit=10, + ) + + inners = [] + for hole in holes: + inner = _offset_linear_ring( + tree.nodes[hole].val, + -offset, # take negative offset for holes + resolution=5, + join_style=join_style, + mitre_limit=10, + ) + if not inner.is_empty: + inners.append(Polygon(inner.geoms[0])) + + return outer, inners + + +def _match_polygons_and_holes(outer, inners): + result = MultiPolygon(polygonize(outer.geoms)) + if len(inners) > 0: + result = ensure_geometry_collection(result.difference(MultiPolygon(inners))) + + return result + + +def _convert_polygon_to_nodes(tree, polygon, parent_polygon, child_holes): + polygon = orient(polygon, -1) + + if polygon.area < 0.1: + return None, None + + valid_rings = _take_only_valid_linear_rings(polygon.exterior) + + try: + exterior = valid_rings.geoms[0] + except IndexError: + return None, None + + node = tree.generate_node_name() + tree.add_node(node, type='node', parent=parent_polygon, val=exterior) + tree.add_edge(parent_polygon, node) + + hole_nodes = [] + for hole in polygon.interiors: + hole_node = tree.generate_node_name() + tree.add_node(hole_node, type="hole", val=hole) + for previous_hole in child_holes: + if Polygon(hole).contains(Polygon(tree.nodes[previous_hole].val)): + tree.nodes[previous_hole].parent = hole_node + tree.add_edge(hole_node, previous_hole) + hole_nodes.append(hole_node) + + return node, hole_nodes + + +def _get_nearest_points_closer_than_thresh(travel_line, next_line, threshold): + """ + Find the first point along travel_line that is within threshold of next_line. + + Input: + travel_line - The "parent" line for which the distance should + be minimized to enter next_line + next_line - contains the next_line which need to be entered + threshold - The distance between travel_line and next_line needs + to below threshold to be a valid point for entering + + Return value: + tuple or None + - the tuple structure is: + (point in travel_line, point in next_line) + - None is returned if there is no point that satisfies the threshold. + """ + + # We'll buffer next_line and find the intersection with travel_line. + # Then we'll return the very first point in the intersection, + # matched with a corresponding point on next_line. Fortunately for + # us, intersection of a Polygon with a LineString yields pieces of + # the LineString in the same order as the input LineString. + threshold_area = next_line.buffer(threshold) + portion_within_threshold = travel_line.intersection(threshold_area) + + if portion_within_threshold.is_empty: + return None + else: + # Projecting with 0 lets us avoid distinguishing between LineString and + # MultiLineString. + parent_point = Point(portion_within_threshold.interpolate(0)) + return nearest_points(parent_point, next_line) + + +def _create_nearest_points_list(travel_line, tree, children, threshold, threshold_hard): + """Determine the best place to enter each of parent's children + + Arguments: + travel_line - The "parent" line for which the distance should + be minimized to enter each child + children - children of travel_line that need to be entered + threshold - The distance between travel_line and a child should + to be below threshold to be a valid point for entering + threshold_hard - As a last resort, we can accept an entry point + that is this far way + + Return value: + list of nearest_neighbor_tuple - indicating where to enter each + respective child + """ + + children_nearest_points = [] + + for child in children: + result = _get_nearest_points_closer_than_thresh(travel_line, tree.nodes[child].val, threshold) + if result is None: + # where holes meet outer borders a distance + # up to 2 * used offset can arise + result = _get_nearest_points_closer_than_thresh(travel_line, tree.nodes[child].val, threshold_hard) + + proj = travel_line.project(result[0]) + children_nearest_points.append( + nearest_neighbor_tuple( + nearest_point_parent=result[0], + nearest_point_child=result[1], + proj_distance_parent=proj, + child_node=child, + ) + ) + + return children_nearest_points + + +def _find_path_inner_to_outer(tree, node, offset, starting_point, avoid_self_crossing, forward=True): + """Find a stitch path for this ring and its children. + + Strategy: A connection from parent to child is made as fast as possible to + reach the innermost child as fast as possible in order to stitch afterwards + from inner to outer. + + This function calls itself recursively to find a stitch path for each child + (and its children). + + Arguments: + tree - a Tree of isocontours (as returned by offset_polygon) + offset - offset that was passed to offset_polygon + starting_point - starting point for stitching + avoid_self_crossing - if True, tries to generate a path that does not + cross itself. + forward - if True, this ring will be stitched in its natural direction + (used internally by avoid_self_crossing) + + Return value: + LineString -- the stitching path + """ + + current_node = tree.nodes[node] + current_ring = current_node.val + + if not forward and avoid_self_crossing: + current_ring = reverse_line_string(current_ring) + + # reorder the coordinates of this ring so that it starts with + # a point nearest the starting_point + start_distance = current_ring.project(starting_point) + current_ring = roll_linear_ring(current_ring, start_distance) + current_node.val = current_ring + + # Find where along this ring to connect to each child. + nearest_points_list = _create_nearest_points_list( + current_ring, + tree, + tree[node], + threshold=1.5 * offset, + threshold_hard=2.05 * offset + ) + nearest_points_list.sort(key=lambda tup: tup.proj_distance_parent) + + result_coords = [] + if not nearest_points_list: + # We have no children, so we're at the center of a spiral. Reversing + # the innermost ring gives a nicer visual appearance. + if not avoid_self_crossing: + current_ring = reverse_line_string(current_ring) + else: + # This is a recursive algorithm. We'll stitch along this ring, pausing + # to jump to each child ring in turn and sew it before continuing on + # this ring. We'll end back where we started. + + result_coords.append(current_ring.coords[0]) + distance_so_far = 0 + for child_connection in nearest_points_list: + # Cut this ring into pieces before and after where this child will connect. + before, after = cut(current_ring, child_connection.proj_distance_parent - distance_so_far) + distance_so_far = child_connection.proj_distance_parent + + # Stitch the part leading up to this child. + if before is not None: + result_coords.extend(before.coords) + + # Stitch this child. The child will start and end in the same + # place, which should be close to our current location. + child_path = _find_path_inner_to_outer( + tree, + child_connection.child_node, + offset, + child_connection.nearest_point_child, + avoid_self_crossing, + not forward + ) + result_coords.extend(child_path.coords) + + # Skip ahead a little bit on this ring before resuming. This + # gives a nice spiral pattern, where we spiral out from the + # innermost child. + if after is not None: + skip, after = cut(after, offset) + distance_so_far += offset + + current_ring = after + + if current_ring is not None: + # skip a little at the end so we don't end exactly where we started. + remaining_length = current_ring.length + if remaining_length > offset: + current_ring, skip = cut(current_ring, current_ring.length - offset) + + result_coords.extend(current_ring.coords) + + return LineString(result_coords) + + +def inner_to_outer(tree, offset, stitch_length, starting_point, avoid_self_crossing): + """Fill a shape with spirals, from innermost to outermost.""" + + stitch_path = _find_path_inner_to_outer(tree, 'root', offset, starting_point, avoid_self_crossing) + points = [Stitch(*point) for point in stitch_path.coords] + stitches = running_stitch(points, stitch_length) + + return stitches + + +def _reorder_linear_ring(ring, start): + distances = ring - start + start_index = np.argmin(np.linalg.norm(distances, axis=1)) + return np.roll(ring, -start_index, axis=0) + + +def _interpolate_linear_rings(ring1, ring2, max_stitch_length, start=None): + """ + Interpolate between two LinearRings + + Creates a path from start_point on ring1 and around the rings, ending at a + nearby point on ring2. The path will smoothly transition from ring1 to + ring2 as it travels around the rings. + + Inspired by interpolate() from https://github.com/mikedh/pocketing/blob/master/pocketing/polygons.py + + Arguments: + ring1 -- LinearRing start point will lie on + ring2 -- LinearRing end point will lie on + max_stitch_length -- maximum stitch length (used to calculate resampling accuracy) + start -- Point on ring1 to start at, as a tuple + + Return value: Path interpolated between two LinearRings, as a LineString. + """ + + # Resample the two LinearRings so that they are the same number of points + # long. Then take the corresponding points in each ring and interpolate + # between them, gradually going more toward ring2. + # + # This is a little less accurate than the method in interpolate(), but several + # orders of magnitude faster because we're not building and querying a KDTree. + + num_points = int(20 * ring1.length / max_stitch_length) + ring1_resampled = trimesh.path.traversal.resample_path(np.array(ring1.coords), count=num_points) + ring2_resampled = trimesh.path.traversal.resample_path(np.array(ring2.coords), count=num_points) + + if start is not None: + ring1_resampled = _reorder_linear_ring(ring1_resampled, start) + ring2_resampled = _reorder_linear_ring(ring2_resampled, start) + + weights = np.linspace(0.0, 1.0, num_points).reshape((-1, 1)) + points = (ring1_resampled * (1.0 - weights)) + (ring2_resampled * weights) + result = LineString(points) + + return result.simplify(0.1, False) + + +def _check_and_prepare_tree_for_valid_spiral(tree): + """Check whether spiral fill is possible, and tweak if necessary. + + Takes a tree consisting of isocontours. If a parent has more than one child + we cannot create a spiral. However, to make the routine more robust, we + allow more than one child if only one of the children has own children. The + other children are removed in this routine then. If the routine returns true, + the tree will have been cleaned up from unwanted children. + + If even with these weaker constraints, a spiral is not possible, False is + returned. + """ + + def process_node(node): + children = set(tree[node]) + + if len(children) == 0: + return True + elif len(children) == 1: + child = children.pop() + return process_node(child) + else: + children_with_children = {child for child in children if tree[child]} + if len(children_with_children) > 1: + # Node has multiple children with children, so a perfect spiral is not possible. + # This False value will be returned all the way up the stack. + return False + elif len(children_with_children) == 1: + children_without_children = children - children_with_children + child = children_with_children.pop() + tree.remove_nodes_from(children_without_children) + return process_node(child) + else: + # None of the children has its own children, so we'll just take the longest. + longest = max(children, key=lambda child: tree[child]['val'].length) + shorter_children = children - {longest} + tree.remove_nodes_from(shorter_children) + return process_node(longest) + + return process_node('root') + + +def single_spiral(tree, stitch_length, starting_point): + """Fill a shape with a single spiral going from outside to center.""" + return _spiral_fill(tree, stitch_length, starting_point, _make_spiral) + + +def double_spiral(tree, stitch_length, starting_point): + """Fill a shape with a double spiral going from outside to center and back to outside. """ + return _spiral_fill(tree, stitch_length, starting_point, _make_fermat_spiral) + + +def _spiral_fill(tree, stitch_length, close_point, spiral_maker): + starting_point = close_point.coords[0] + + rings = _get_spiral_rings(tree) + path = spiral_maker(rings, stitch_length, starting_point) + path = [Stitch(*stitch) for stitch in path] + + return running_stitch(path, stitch_length) + + +def _get_spiral_rings(tree): + rings = [] + + node = 'root' + while True: + rings.append(tree.nodes[node].val) + + children = tree[node] + if len(children) == 0: + break + elif len(children) == 1: + node = list(children)[0] + else: + # We can only really fill a shape with a single spiral if each + # parent has only one child. We'll do our best though, because + # that is probably more helpful to the user than just refusing + # entirely. We'll pick the child that's closest to the center. + parent_center = rings[-1].centroid + node = min(children, key=lambda child: parent_center.distance(tree.nodes[child].val.centroid)) + + return rings + + +def _make_fermat_spiral(rings, stitch_length, starting_point): + forward = _make_spiral(rings[::2], stitch_length, starting_point) + back = _make_spiral(rings[1::2], stitch_length, starting_point) + back.reverse() + + return chain(forward, back) + + +def _make_spiral(rings, stitch_length, starting_point): + path = [] + + for ring1, ring2 in zip(rings[:-1], rings[1:]): + spiral_part = _interpolate_linear_rings(ring1, ring2, stitch_length, starting_point) + path.extend(spiral_part.coords) + + return path diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 21e35d83..46352d4f 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -131,8 +131,6 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non # fill regions at the same angle and spacing always line up nicely. start -= (start + normal * center) % row_spacing - rows = [] - current_row_y = start while current_row_y < end: @@ -159,15 +157,13 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non runs.reverse() runs = [tuple(reversed(run)) for run in runs] - rows.append(runs) + yield runs if end_row_spacing: current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) else: current_row_y += row_spacing - return rows - def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers, skip_last): stitches = [] @@ -221,6 +217,7 @@ def pull_runs(rows, shape, row_spacing): # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) + rows = list(rows) runs = [] count = 0 while (len(rows) > 0): diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py new file mode 100644 index 00000000..e4918e1d --- /dev/null +++ b/lib/stitches/guided_fill.py @@ -0,0 +1,183 @@ +from shapely import geometry as shgeo +from shapely.ops import linemerge, unary_union + +from .auto_fill import (build_fill_stitch_graph, + build_travel_graph, collapse_sequential_outline_edges, fallback, + find_stitch_path, graph_is_valid, travel) +from .running_stitch import running_stitch +from ..i18n import _ +from ..stitch_plan import Stitch +from ..utils.geometry import Point as InkstitchPoint, reverse_line_string + + +def guided_fill(shape, + guideline, + angle, + row_spacing, + max_stitch_length, + running_stitch_length, + skip_last, + starting_point, + ending_point=None, + underpath=True): + try: + segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing) + fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) + except ValueError: + # Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node + return fallback(shape, running_stitch_length) + + if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length): + return fallback(shape, running_stitch_length) + + travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath) + path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) + result = path_to_stitches(path, travel_graph, fill_stitch_graph, max_stitch_length, running_stitch_length, skip_last) + + return result + + +def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, running_stitch_length, skip_last): + path = collapse_sequential_outline_edges(path) + + stitches = [] + + # If the very first stitch is travel, we'll omit it in travel(), so add it here. + if not path[0].is_segment(): + stitches.append(Stitch(*path[0].nodes[0])) + + for edge in path: + if edge.is_segment(): + current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment'] + path_geometry = current_edge['geometry'] + + if edge[0] != path_geometry.coords[0]: + path_geometry = reverse_line_string(path_geometry) + + point_list = [Stitch(*point) for point in path_geometry.coords] + new_stitches = running_stitch(point_list, stitch_length) + + # need to tag stitches + + if skip_last: + del new_stitches[-1] + + stitches.extend(new_stitches) + + travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', [])) + else: + stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, skip_last)) + + return stitches + + +def extend_line(line, minx, maxx, miny, maxy): + line = line.simplify(0.01, False) + + upper_left = InkstitchPoint(minx, miny) + lower_right = InkstitchPoint(maxx, maxy) + length = (upper_left - lower_right).length() + + point1 = InkstitchPoint(*line.coords[0]) + point2 = InkstitchPoint(*line.coords[1]) + new_starting_point = point1 - (point2 - point1).unit() * length + + point3 = InkstitchPoint(*line.coords[-2]) + point4 = InkstitchPoint(*line.coords[-1]) + new_ending_point = point4 + (point4 - point3).unit() * length + + return shgeo.LineString([new_starting_point.as_tuple()] + + line.coords[1:-1] + [new_ending_point.as_tuple()]) + + +def repair_multiple_parallel_offset_curves(multi_line): + lines = linemerge(multi_line) + lines = list(lines.geoms) + max_length = -1 + max_length_idx = -1 + for idx, subline in enumerate(lines): + if subline.length > max_length: + max_length = subline.length + max_length_idx = idx + # need simplify to avoid doubled points caused by linemerge + return lines[max_length_idx].simplify(0.01, False) + + +def repair_non_simple_lines(line): + repaired = unary_union(line) + counter = 0 + # Do several iterations since we might have several concatenated selfcrossings + while repaired.geom_type != 'LineString' and counter < 4: + line_segments = [] + for line_seg in repaired.geoms: + if not line_seg.is_ring: + line_segments.append(line_seg) + + repaired = unary_union(linemerge(line_segments)) + counter += 1 + if repaired.geom_type != 'LineString': + raise ValueError( + _("Guide line (or offset copy) is self crossing!")) + else: + return repaired + + +def intersect_region_with_grating_guideline(shape, line, row_spacing, flip=False): # noqa: C901 + + row_spacing = abs(row_spacing) + (minx, miny, maxx, maxy) = shape.bounds + upper_left = InkstitchPoint(minx, miny) + rows = [] + + if line.geom_type != 'LineString' or not line.is_simple: + line = repair_non_simple_lines(line) + # extend the line towards the ends to increase probability that all offsetted curves cross the shape + line = extend_line(line, minx, maxx, miny, maxy) + + line_offsetted = line + res = line_offsetted.intersection(shape) + while isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)) or (not res.is_empty and len(res.coords) > 1): + if isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)): + runs = [line_string.coords for line_string in res.geoms if ( + not line_string.is_empty and len(line_string.coords) > 1)] + else: + runs = [res.coords] + + runs.sort(key=lambda seg: ( + InkstitchPoint(*seg[0]) - upper_left).length()) + if flip: + runs.reverse() + runs = [tuple(reversed(run)) for run in runs] + + if row_spacing > 0: + rows.append(runs) + else: + rows.insert(0, runs) + + line_offsetted = line_offsetted.parallel_offset(row_spacing, 'left', 5) + if line_offsetted.geom_type == 'MultiLineString': # if we got multiple lines take the longest + line_offsetted = repair_multiple_parallel_offset_curves(line_offsetted) + if not line_offsetted.is_simple: + line_offsetted = repair_non_simple_lines(line_offsetted) + + if row_spacing < 0: + line_offsetted = reverse_line_string(line_offsetted) + line_offsetted = line_offsetted.simplify(0.01, False) + res = line_offsetted.intersection(shape) + if row_spacing > 0 and not isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)): + if (res.is_empty or len(res.coords) == 1): + row_spacing = -row_spacing + + line_offsetted = line.parallel_offset(row_spacing, 'left', 5) + if line_offsetted.geom_type == 'MultiLineString': # if we got multiple lines take the longest + line_offsetted = repair_multiple_parallel_offset_curves( + line_offsetted) + if not line_offsetted.is_simple: + line_offsetted = repair_non_simple_lines(line_offsetted) + # using negative row spacing leads as a side effect to reversed offsetted lines - here we undo this + line_offsetted = reverse_line_string(line_offsetted) + line_offsetted = line_offsetted.simplify(0.01, False) + res = line_offsetted.intersection(shape) + + for row in rows: + yield from row diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index 2878480c..cb8acf68 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -3,11 +3,15 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. +from ..debug import debug +import math from copy import copy +from shapely.geometry import LineString """ Utility functions to produce running stitches. """ +@debug.time def running_stitch(points, stitch_length): """Generate running stitch along a path. @@ -23,56 +27,50 @@ def running_stitch(points, stitch_length): if len(points) < 2: return [] + # simplify will remove as many points as possible while ensuring that the + # resulting path stays within 0.75 pixels (0.2mm) of the original path. + path = LineString(points) + simplified = path.simplify(0.75, preserve_topology=False) + + # save the points that simplify picked and make sure we stitch them + important_points = set(simplified.coords) + important_point_indices = [i for i, point in enumerate(points) if point.as_tuple() in important_points] + output = [] - segment_start = points[0] - last_segment_direction = None - - # This tracks the distance we've traveled along the current segment so - # far. Each time we make a stitch, we add the stitch_length to this - # value. If we fall off the end of the current segment, we carry over - # the remainder to the next segment. - distance = 0.0 - - for segment_end in points[1:]: - segment = segment_end - segment_start - segment_length = segment.length() - - if segment_length == 0: - continue - - segment_direction = segment.unit() - - # corner detection - if last_segment_direction: - cos_angle_between = segment_direction * last_segment_direction - - # This checks whether the corner is sharper than 45 degrees. - if cos_angle_between < 0.5: - # Only add the corner point if it's more than 0.1mm away to - # avoid a double-stitch. - if (segment_start - output[-1]).length() > 0.1: - # add a stitch at the corner - output.append(segment_start) - - # next stitch needs to be stitch_length along this segment - distance = stitch_length - - while distance < segment_length: - output.append(segment_start + distance * segment_direction) - distance += stitch_length - - # prepare for the next segment - segment_start = segment_end - last_segment_direction = segment_direction - distance -= segment_length - - # stitch a single point if the path has a length of zero - if not output: - output.append(segment_start) - - # stitch the last point unless we're already almost there - if (segment_start - output[-1]).length() > 0.1 or len(output) == 0: - output.append(segment_start) + for start, end in zip(important_point_indices[:-1], important_point_indices[1:]): + # consider sections of the original path, each one starting and ending + # with an important point + section = points[start:end + 1] + output.append(section[0]) + + # Now split each section up evenly into stitches, each with a length no + # greater than the specified stitch_length. + section_ls = LineString(section) + section_length = section_ls.length + if section_length > stitch_length: + # a fractional stitch needs to be rounded up, which will make all + # of the stitches shorter + num_stitches = math.ceil(section_length / stitch_length) + actual_stitch_length = section_length / num_stitches + + distance = actual_stitch_length + + segment_start = section[0] + for segment_end in section[1:]: + segment = segment_end - segment_start + segment_length = segment.length() + + if distance < segment_length: + segment_direction = segment.unit() + + while distance < segment_length: + output.append(segment_start + distance * segment_direction) + distance += actual_stitch_length + + distance -= segment_length + segment_start = segment_end + + output.append(points[-1]) return output diff --git a/lib/stitches/utils/autoroute.py b/lib/stitches/utils/autoroute.py new file mode 100644 index 00000000..5acb1400 --- /dev/null +++ b/lib/stitches/utils/autoroute.py @@ -0,0 +1,221 @@ +# 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 itertools import combinations + +import networkx as nx +from shapely.geometry import Point, MultiPoint +from shapely.ops import nearest_points + +import inkex + +from ...svg import get_correction_transform +from ...svg.tags import INKSCAPE_LABEL + + +def find_path(graph, starting_node, ending_node): + """Find a path through the graph that sews every edge.""" + + # This is done in two steps. First, we find the shortest path from the + # start to the end. We remove it from the graph, and proceed to step 2. + # + # Then, we traverse the path node by node. At each node, we follow any + # branchings with a depth-first search. We travel down each branch of + # the tree, inserting each seen branch into the tree. When the DFS + # hits a dead-end, as it back-tracks, we also add the seen edges _again_. + # Repeat until there are no more edges left in the graph. + # + # Visiting the edges again on the way back allows us to set up + # "underpathing". + path = nx.shortest_path(graph, starting_node, ending_node) + + # Copy the graph so that we can remove the edges as we visit them. + # This also converts the directed graph into an undirected graph in the + # case that "preserve_order" is set. + graph = nx.Graph(graph) + graph.remove_edges_from(list(zip(path[:-1], path[1:]))) + + final_path = [] + prev = None + for node in path: + if prev is not None: + final_path.append((prev, node)) + prev = node + + for n1, n2, edge_type in list(nx.dfs_labeled_edges(graph, node)): + if n1 == n2: + # dfs_labeled_edges gives us (start, start, "forward") for + # the starting node for some reason + continue + + if edge_type == "forward": + final_path.append((n1, n2)) + graph.remove_edge(n1, n2) + elif edge_type == "reverse": + final_path.append((n2, n1)) + elif edge_type == "nontree": + # a "nontree" happens when there exists an edge from n1 to n2 + # but n2 has already been visited. It's a dead-end that runs + # into part of the graph that we've already traversed. We + # do still need to make sure that edge is sewn, so we travel + # down and back on this edge. + # + # It's possible for a given "nontree" edge to be listed more + # than once so we'll deduplicate. + if (n1, n2) in graph.edges: + final_path.append((n1, n2)) + final_path.append((n2, n1)) + graph.remove_edge(n1, n2) + + return final_path + + +def add_jumps(graph, elements, preserve_order): + """Add jump stitches between elements as necessary. + + Jump stitches are added to ensure that all elements can be reached. Only the + minimal number and length of jumps necessary will be added. + """ + + if preserve_order: + # For each sequential pair of elements, find the shortest possible jump + # stitch between them and add it. The directions of these new edges + # will enforce stitching the elements in order. + + for element1, element2 in zip(elements[:-1], elements[1:]): + potential_edges = [] + + nodes1 = get_nodes_on_element(graph, element1) + nodes2 = get_nodes_on_element(graph, element2) + + for node1 in nodes1: + for node2 in nodes2: + point1 = graph.nodes[node1]['point'] + point2 = graph.nodes[node2]['point'] + potential_edges.append((point1, point2)) + + if potential_edges: + edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1])) + graph.add_edge(str(edge[0]), str(edge[1]), jump=True) + else: + # networkx makes this super-easy! k_edge_agumentation tells us what edges + # we need to add to ensure that the graph is fully connected. We give it a + # set of possible edges that it can consider adding (avail). Each edge has + # a weight, which we'll set as the length of the jump stitch. The + # algorithm will minimize the total length of jump stitches added. + for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): + graph.add_edge(*jump, jump=True) + + return graph + + +def possible_jumps(graph): + """All possible jump stitches in the graph with their lengths. + + Returns: a generator of tuples: (node1, node2, length) + """ + + for component1, component2 in combinations(nx.connected_components(graph), 2): + points1 = MultiPoint([graph.nodes[node]['point'] for node in component1]) + points2 = MultiPoint([graph.nodes[node]['point'] for node in component2]) + + start_point, end_point = nearest_points(points1, points2) + + yield (str(start_point), str(end_point), start_point.distance(end_point)) + + +def get_starting_and_ending_nodes(graph, elements, preserve_order, starting_point, ending_point): + """Find or choose the starting and ending graph nodes. + + If points were passed, we'll find the nearest graph nodes. Since we split + every path up into 1mm-chunks, we'll be at most 1mm away which is good + enough. + + If we weren't given starting and ending points, we'll pic kthe far left and + right nodes. + + returns: + (starting graph node, ending graph node) + """ + + nodes = [] + + nodes.append(find_node(graph, starting_point, + min, preserve_order, elements[0])) + nodes.append(find_node(graph, ending_point, + max, preserve_order, elements[-1])) + + return nodes + + +def find_node(graph, point, extreme_function, constrain_to_satin=False, satin=None): + if constrain_to_satin: + nodes = get_nodes_on_element(graph, satin) + else: + nodes = graph.nodes() + + if point is None: + return extreme_function(nodes, key=lambda node: graph.nodes[node]['point'].x) + else: + point = Point(*point) + return min(nodes, key=lambda node: graph.nodes[node]['point'].distance(point)) + + +def get_nodes_on_element(graph, element): + nodes = set() + + for start_node, end_node, element_for_edge in graph.edges(data='element'): + if element_for_edge is element: + nodes.add(start_node) + nodes.add(end_node) + + return nodes + + +def remove_original_elements(elements): + for element in elements: + for command in element.commands: + command_group = command.use.getparent() + if command_group is not None and command_group.get('id').startswith('command_group'): + remove_from_parent(command_group) + else: + remove_from_parent(command.connector) + remove_from_parent(command.use) + remove_from_parent(element.node) + + +def remove_from_parent(node): + if node.getparent() is not None: + node.getparent().remove(node) + + +def create_new_group(parent, insert_index, label): + group = inkex.Group(attrib={ + INKSCAPE_LABEL: label, + "transform": get_correction_transform(parent, child=True) + }) + parent.insert(insert_index, group) + + return group + + +def preserve_original_groups(elements, original_parent_nodes): + """Ensure that all elements are contained in the original SVG group elements. + + When preserve_order is True, no elements will be reordered in the XML tree. + This makes it possible to preserve original SVG group membership. We'll + ensure that each newly-created element is added to the group that contained + the original element that spawned it. + """ + + for element, parent in zip(elements, original_parent_nodes): + if parent is not None: + parent.append(element.node) + element.node.set('transform', get_correction_transform(parent, child=True)) + + +def add_elements_to_group(elements, group): + for element in elements: + group.append(element.node) diff --git a/lib/svg/path.py b/lib/svg/path.py index c33a7a8f..6c2cbe35 100644 --- a/lib/svg/path.py +++ b/lib/svg/path.py @@ -74,6 +74,12 @@ def get_correction_transform(node, child=False): def line_strings_to_csp(line_strings): + try: + # This lets us accept a MultiLineString or a list. + line_strings = line_strings.geoms + except AttributeError: + pass + return point_lists_to_csp(ls.coords for ls in line_strings) diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 8b6f02a4..d78ba678 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -3,9 +3,10 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -import inkex from lxml import etree +import inkex + etree.register_namespace("inkstitch", "http://inkstitch.org/namespace") inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' @@ -48,55 +49,60 @@ SVG_OBJECT_TAGS = (SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG, SVG_RECT_TAG) INKSTITCH_ATTRIBS = {} inkstitch_attribs = [ - 'ties', - 'force_lock_stitches', - # clone - 'clone', - # polyline - 'polyline', - # fill - 'angle', - 'auto_fill', - 'expand_mm', - 'fill_underlay', - 'fill_underlay_angle', - 'fill_underlay_inset_mm', - 'fill_underlay_max_stitch_length_mm', - 'fill_underlay_row_spacing_mm', - 'fill_underlay_skip_last', - 'max_stitch_length_mm', - 'row_spacing_mm', - 'end_row_spacing_mm', - 'skip_last', - 'staggers', - 'underlay_underpath', - 'underpath', - 'flip', - 'expand_mm', - # stroke - 'manual_stitch', - 'bean_stitch_repeats', - 'repeats', - 'running_stitch_length_mm', - # satin column - 'satin_column', - 'running_stitch_length_mm', - 'center_walk_underlay', - 'center_walk_underlay_stitch_length_mm', - 'contour_underlay', - 'contour_underlay_stitch_length_mm', - 'contour_underlay_inset_mm', - 'zigzag_underlay', - 'zigzag_spacing_mm', - 'zigzag_underlay_inset_mm', - 'zigzag_underlay_spacing_mm', - 'zigzag_underlay_max_stitch_length_mm', - 'e_stitch', - 'pull_compensation_mm', - 'stroke_first', - # Legacy - 'trim_after', - 'stop_after' - ] + 'ties', + 'force_lock_stitches', + # clone + 'clone', + # polyline + 'polyline', + # fill + 'angle', + 'auto_fill', + 'fill_method', + 'contour_strategy', + 'join_style', + 'avoid_self_crossing', + 'clockwise', + 'expand_mm', + 'fill_underlay', + 'fill_underlay_angle', + 'fill_underlay_inset_mm', + 'fill_underlay_max_stitch_length_mm', + 'fill_underlay_row_spacing_mm', + 'fill_underlay_skip_last', + 'max_stitch_length_mm', + 'row_spacing_mm', + 'end_row_spacing_mm', + 'skip_last', + 'staggers', + 'underlay_underpath', + 'underpath', + 'flip', + 'expand_mm', + # stroke + 'manual_stitch', + 'bean_stitch_repeats', + 'repeats', + 'running_stitch_length_mm', + # satin column + 'satin_column', + 'running_stitch_length_mm', + 'center_walk_underlay', + 'center_walk_underlay_stitch_length_mm', + 'contour_underlay', + 'contour_underlay_stitch_length_mm', + 'contour_underlay_inset_mm', + 'zigzag_underlay', + 'zigzag_spacing_mm', + 'zigzag_underlay_inset_mm', + 'zigzag_underlay_spacing_mm', + 'zigzag_underlay_max_stitch_length_mm', + 'e_stitch', + 'pull_compensation_mm', + 'stroke_first', + # Legacy + 'trim_after', + 'stop_after' +] for attrib in inkstitch_attribs: INKSTITCH_ATTRIBS[attrib] = inkex.addNS(attrib, 'inkstitch') diff --git a/lib/utils/dotdict.py b/lib/utils/dotdict.py index acd575b9..12cf6e79 100644 --- a/lib/utils/dotdict.py +++ b/lib/utils/dotdict.py @@ -15,7 +15,7 @@ class DotDict(dict): def update(self, *args, **kwargs): super(DotDict, self).update(*args, **kwargs) - self.dotdictify() + self._dotdictify() def _dotdictify(self): for k, v in self.items(): diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index bce278ed..86205f02 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -5,7 +5,7 @@ import math -from shapely.geometry import LineString +from shapely.geometry import LineString, LinearRing, MultiLineString, Polygon, MultiPolygon, GeometryCollection from shapely.geometry import Point as ShapelyPoint @@ -39,6 +39,62 @@ def cut(line, distance, normalized=False): LineString([(cp.x, cp.y)] + coords[i:])] +def roll_linear_ring(ring, distance, normalized=False): + """Make a linear ring start at a different point. + + Example: A B C D E F G A -> D E F G A B C + + Same linear ring, different ordering of the coordinates. + """ + + if not isinstance(ring, LinearRing): + # In case they handed us a LineString + ring = LinearRing(ring) + + pieces = cut(LinearRing(ring), distance, normalized=False) + + if None in pieces: + # We cut exactly at the start or end. + return ring + + # The first and last point in a linear ring are duplicated, so we omit one + # copy + return LinearRing(pieces[1].coords[:] + pieces[0].coords[1:]) + + +def reverse_line_string(line_string): + return LineString(line_string.coords[::-1]) + + +def ensure_multi_line_string(thing): + """Given either a MultiLineString or a single LineString, return a MultiLineString""" + + if isinstance(thing, LineString): + return MultiLineString([thing]) + else: + return thing + + +def ensure_geometry_collection(thing): + """Given either some kind of geometry or a GeometryCollection, return a GeometryCollection""" + + if isinstance(thing, (MultiLineString, MultiPolygon)): + return GeometryCollection(thing.geoms) + elif isinstance(thing, GeometryCollection): + return thing + else: + return GeometryCollection([thing]) + + +def ensure_multi_polygon(thing): + """Given either a MultiPolygon or a single Polygon, return a MultiPolygon""" + + if isinstance(thing, Polygon): + return MultiPolygon([thing]) + else: + return thing + + def cut_path(points, length): """Return a subsection of at the start of the path that is length units long. |
