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