diff options
| author | Lex Neva <lexelby@users.noreply.github.com> | 2022-05-20 12:06:31 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-05-20 12:06:31 -0400 |
| commit | 8ab4abf190778867a8eccde08733c45f3760b2d0 (patch) | |
| tree | f5b63f5e192c36d8c6bf47e4c30c68d8b1a73728 /lib | |
| parent | 1316e8132e58361f42cb4315c586e0e2cccfc64c (diff) | |
| parent | 47123198760f8740acda0799d3b22f14b3f69550 (diff) | |
Merge pull request #1548 from inkstitch/feature_guided_fill
Feature guided fill
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/elements/__init__.py | 3 | ||||
| -rw-r--r-- | lib/elements/auto_fill.py | 291 | ||||
| -rw-r--r-- | lib/elements/clone.py | 35 | ||||
| -rw-r--r-- | lib/elements/element.py | 19 | ||||
| -rw-r--r-- | lib/elements/fill.py | 205 | ||||
| -rw-r--r-- | lib/elements/fill_stitch.py | 637 | ||||
| -rw-r--r-- | lib/elements/stroke.py | 4 | ||||
| -rw-r--r-- | lib/elements/utils.py | 15 | ||||
| -rw-r--r-- | lib/extensions/__init__.py | 2 | ||||
| -rw-r--r-- | lib/extensions/base.py | 3 | ||||
| -rw-r--r-- | lib/extensions/break_apart.py | 2 | ||||
| -rw-r--r-- | lib/extensions/cleanup.py | 4 | ||||
| -rw-r--r-- | lib/extensions/params.py | 163 | ||||
| -rw-r--r-- | lib/extensions/selection_to_guide_line.py | 26 | ||||
| -rw-r--r-- | lib/stitches/__init__.py | 1 | ||||
| -rw-r--r-- | lib/stitches/auto_fill.py | 60 | ||||
| -rw-r--r-- | lib/stitches/contour_fill.py | 551 | ||||
| -rw-r--r-- | lib/stitches/fill.py | 7 | ||||
| -rw-r--r-- | lib/stitches/guided_fill.py | 183 | ||||
| -rw-r--r-- | lib/stitches/running_stitch.py | 96 | ||||
| -rw-r--r-- | lib/svg/path.py | 6 | ||||
| -rw-r--r-- | lib/svg/tags.py | 108 | ||||
| -rw-r--r-- | lib/utils/dotdict.py | 2 | ||||
| -rw-r--r-- | lib/utils/geometry.py | 58 |
24 files changed, 1756 insertions, 725 deletions
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/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/stroke.py b/lib/elements/stroke.py index 6f8d8bb1..7113bf3f 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -168,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 960f5b07..dafde759 100644 --- a/lib/elements/utils.py +++ b/lib/elements/utils.py @@ -7,11 +7,10 @@ from ..commands import is_command 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 .marker import MarkerObject from .polyline import Polyline @@ -20,11 +19,12 @@ 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', ''): @@ -33,7 +33,7 @@ def node_to_elements(node): # noqa: C901 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 d0cfa911..56949b50 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -40,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 @@ -53,6 +54,7 @@ __all__ = extensions = [StitchPlanPreview, Zip, Flip, SelectionToPattern, + SelectionToGuideLine, ObjectCommands, ObjectCommandsToggleVisibility, LayerCommands, diff --git a/lib/extensions/base.py b/lib/extensions/base.py index a5f1209a..cf94714c 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -8,11 +8,12 @@ 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 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/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/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/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/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/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. |
