summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/commands.py21
-rw-r--r--lib/elements/__init__.py3
-rw-r--r--lib/elements/auto_fill.py291
-rw-r--r--lib/elements/clone.py41
-rw-r--r--lib/elements/element.py30
-rw-r--r--lib/elements/empty_d_object.py4
-rw-r--r--lib/elements/fill.py202
-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.py96
-rw-r--r--lib/elements/stroke.py351
-rw-r--r--lib/elements/utils.py23
-rw-r--r--lib/extensions/__init__.py26
-rw-r--r--lib/extensions/apply_threadlist.py (renamed from lib/extensions/import_threadlist.py)9
-rw-r--r--lib/extensions/auto_run.py65
-rw-r--r--lib/extensions/auto_satin.py2
-rw-r--r--lib/extensions/base.py37
-rw-r--r--lib/extensions/break_apart.py4
-rw-r--r--lib/extensions/cleanup.py6
-rw-r--r--lib/extensions/commands_scale_symbols.py23
-rw-r--r--lib/extensions/convert_to_satin.py7
-rw-r--r--lib/extensions/convert_to_stroke.py2
-rw-r--r--lib/extensions/cut_satin.py2
-rw-r--r--lib/extensions/cutwork_segmentation.py191
-rw-r--r--lib/extensions/duplicate_params.py30
-rw-r--r--lib/extensions/flip.py2
-rw-r--r--lib/extensions/generate_palette.py84
-rw-r--r--lib/extensions/input.py63
-rw-r--r--lib/extensions/lettering.py16
-rw-r--r--lib/extensions/lettering_force_lock_stitches.py86
-rw-r--r--lib/extensions/letters_to_font.py81
-rw-r--r--lib/extensions/object_commands.py4
-rw-r--r--lib/extensions/object_commands_toggle_visibility.py24
-rw-r--r--lib/extensions/palette_split_text.py42
-rw-r--r--lib/extensions/palette_to_text.py50
-rw-r--r--lib/extensions/params.py164
-rw-r--r--lib/extensions/print_pdf.py14
-rw-r--r--lib/extensions/remove_embroidery_settings.py33
-rw-r--r--lib/extensions/reorder.py16
-rw-r--r--lib/extensions/selection_to_guide_line.py26
-rw-r--r--lib/extensions/selection_to_pattern.py45
-rw-r--r--lib/extensions/stitch_plan_preview.py62
-rw-r--r--lib/extensions/troubleshoot.py10
-rwxr-xr-x[-rw-r--r--]lib/gui/electron.py19
-rw-r--r--lib/i18n.py5
-rw-r--r--lib/inx/generate.py10
-rwxr-xr-x[-rw-r--r--]lib/inx/utils.py58
-rw-r--r--lib/lettering/font.py74
-rw-r--r--lib/lettering/font_variant.py29
-rw-r--r--lib/lettering/glyph.py55
-rw-r--r--lib/marker.py87
-rw-r--r--lib/patterns.py47
-rw-r--r--lib/stitch_plan/__init__.py7
-rw-r--r--lib/stitch_plan/color_block.py11
-rw-r--r--lib/stitch_plan/generate_stitch_plan.py74
-rw-r--r--lib/stitch_plan/stitch.py26
-rw-r--r--lib/stitch_plan/stitch_group.py4
-rw-r--r--lib/stitch_plan/stitch_plan.py33
-rw-r--r--lib/stitch_plan/ties.py2
-rw-r--r--lib/stitches/__init__.py4
-rw-r--r--lib/stitches/auto_fill.py60
-rw-r--r--lib/stitches/auto_run.py293
-rw-r--r--lib/stitches/auto_satin.py258
-rw-r--r--lib/stitches/contour_fill.py551
-rw-r--r--lib/stitches/fill.py11
-rw-r--r--lib/stitches/guided_fill.py183
-rw-r--r--lib/stitches/ripple_stitch.py263
-rw-r--r--lib/stitches/running_stitch.py98
-rw-r--r--lib/stitches/utils/autoroute.py221
-rw-r--r--lib/svg/guides.py16
-rw-r--r--lib/svg/path.py10
-rw-r--r--lib/svg/tags.py127
-rw-r--r--lib/svg/units.py2
-rw-r--r--lib/threads/color.py4
-rw-r--r--lib/utils/dotdict.py2
-rw-r--r--lib/utils/geometry.py67
-rw-r--r--lib/utils/inkscape.py19
-rwxr-xr-x[-rw-r--r--]lib/utils/paths.py10
-rwxr-xr-x[-rw-r--r--]lib/utils/version.py5
80 files changed, 4295 insertions, 1409 deletions
diff --git a/lib/commands.py b/lib/commands.py
index c9da782a..a7affb6d 100644
--- a/lib/commands.py
+++ b/lib/commands.py
@@ -27,6 +27,15 @@ COMMANDS = {
"fill_end": N_("Fill stitch ending position"),
# L10N command attached to an object
+ "ripple_target": N_("Ripple stitch target position"),
+
+ # L10N command attached to an object
+ "run_start": N_("Auto-route running stitch starting position"),
+
+ # 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 +63,9 @@ 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", "ripple_target", "run_start", "run_end", "satin_start", "satin_end",
+ "stop", "trim", "ignore_object", "satin_cut_point"]
+FREE_MOVEMENT_OBJECT_COMMANDS = ["run_start", "run_end", "satin_start", "satin_end"]
LAYER_COMMANDS = ["ignore_layer"]
GLOBAL_COMMANDS = ["origin", "stop_position"]
@@ -288,7 +299,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 +315,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 +396,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 a9e10d94..d9185012 100644
--- a/lib/elements/clone.py
+++ b/lib/elements/clone.py
@@ -5,21 +5,14 @@
from math import atan, degrees
-import inkex
-
-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
@@ -70,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 = []
@@ -100,7 +73,7 @@ class Clone(EmbroideryElement):
if source_node.tag not in EMBROIDERABLE_TAGS:
return []
- self.node.style = source_node.composed_style()
+ self.node.style = source_node.specified_style()
# a. a custom set fill angle
# b. calculated rotation for the cloned fill element to look exactly as it's source
@@ -128,7 +101,7 @@ class Clone(EmbroideryElement):
return patches
def get_clone_style(self, style_name, node, default=None):
- style = inkex.styles.AttrFallbackStyle(node).get(style_name) or default
+ style = node.style[style_name] or default
return style
def center(self, source_node):
diff --git a/lib/elements/element.py b/lib/elements/element.py
index f06982b2..75d22580 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,12 +206,24 @@ 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=50)
@cache
def ties(self):
return self.get_int_param("ties", 0)
@property
+ @param('force_lock_stitches',
+ _('Force lock stitches'),
+ tooltip=_('Sew lock stitches after sewing this element, '
+ '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=51)
+ @cache
+ def force_lock_stitches(self):
+ return self.get_boolean_param('force_lock_stitches', False)
+
+ @property
def path(self):
# A CSP is a "cubic superpath".
#
@@ -251,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__)
@@ -312,6 +333,7 @@ class EmbroideryElement(object):
for patch in patches:
patch.tie_modus = self.ties
+ patch.force_lock_stitches = self.force_lock_stitches
if patches:
patches[-1].trim_after = self.has_command("trim") or self.trim_after
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 442922b6..00000000
--- a/lib/elements/fill.py
+++ /dev/null
@@ -1,202 +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)
- # 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 math.isclose(polygon.area, buffered.area):
- 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 cf31c2af..b944bee5 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -6,17 +6,18 @@
from copy import deepcopy
from itertools import chain
-from inkex import paths
from shapely import affinity as shaffinity
from shapely import geometry as shgeo
from shapely.ops import nearest_points
-from .element import EmbroideryElement, param
-from .validation import ValidationError, ValidationWarning
+from inkex import paths
+
from ..i18n import _
from ..stitch_plan import StitchGroup
from ..svg import line_strings_to_csp, point_lists_to_csp
from ..utils import Point, cache, collapse_duplicate_point, cut
+from .element import EmbroideryElement, param
+from .validation import ValidationError, ValidationWarning
class SatinHasFillError(ValidationError):
@@ -51,6 +52,15 @@ class UnequalPointsError(ValidationError):
]
+class NotStitchableError(ValidationError):
+ name = _("Not stitchable satin column")
+ description = _("A satin column consists out of two rails and one or more rungs. This satin column may have a different setup.")
+ steps_to_solve = [
+ _('Make sure your satin column is not a combination of multiple satin columns.'),
+ _('Go to our website and read how a satin column should look like https://inkstitch.org/docs/stitches/satin-column/'),
+ ]
+
+
rung_message = _("Each rung should intersect both rails once.")
@@ -156,6 +166,11 @@ class SatinColumn(EmbroideryElement):
return max(self.get_float_param("center_walk_underlay_stitch_length_mm", 1.5), 0.01)
@property
+ @param('center_walk_underlay_repeats', _('Repeats'), group=_('Center-Walk Underlay'), type='int', default=2, sort_index=2)
+ def center_walk_underlay_repeats(self):
+ return max(self.get_int_param("center_walk_underlay_repeats", 2), 1)
+
+ @property
@param('zigzag_underlay', _('Zig-zag underlay'), type='toggle', group=_('Zig-zag Underlay'))
def zigzag_underlay(self):
return self.get_boolean_param("zigzag_underlay")
@@ -190,15 +205,23 @@ class SatinColumn(EmbroideryElement):
return self.get_float_param("zigzag_underlay_inset_mm") or self.contour_underlay_inset / 2.0
@property
+ @param('zigzag_underlay_max_stitch_length_mm',
+ _('Maximum stitch length'),
+ tooltip=_('Split stitch if distance of maximum stitch length is exceeded'),
+ unit='mm',
+ group=_('Zig-zag Underlay'),
+ type='float',
+ default="")
+ def zigzag_underlay_max_stitch_length(self):
+ return self.get_float_param("zigzag_underlay_max_stitch_length_mm") or None
+
+ @property
@cache
def shape(self):
# 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
@@ -411,6 +434,12 @@ class SatinColumn(EmbroideryElement):
if not intersection.is_empty and not isinstance(intersection, shgeo.Point):
yield TooManyIntersectionsError(rung.interpolate(0.5, normalized=True))
+ if not self.to_stitch_groups():
+ yield NotStitchableError(self.shape.centroid)
+
+ def _center_walk_is_odd(self):
+ return self.center_walk_underlay_repeats % 2 == 1
+
def reverse(self):
"""Return a new SatinColumn like this one but in the opposite direction.
@@ -715,24 +744,34 @@ class SatinColumn(EmbroideryElement):
def do_contour_underlay(self):
# "contour walk" underlay: do stitches up one side and down the
# other.
- forward, back = self.plot_points_on_rails(self.contour_underlay_stitch_length,
- -self.contour_underlay_inset)
+ forward, back = self.plot_points_on_rails(self.contour_underlay_stitch_length, -self.contour_underlay_inset)
+ stitches = (forward + list(reversed(back)))
+ if self._center_walk_is_odd():
+ stitches = (list(reversed(back)) + forward)
+
return StitchGroup(
color=self.color,
tags=("satin_column", "satin_column_underlay", "satin_contour_underlay"),
- stitches=(forward + list(reversed(back))))
+ stitches=stitches)
def do_center_walk(self):
# Center walk underlay is just a running stitch down and back on the
# center line between the bezier curves.
# Do it like contour underlay, but inset all the way to the center.
- forward, back = self.plot_points_on_rails(self.center_walk_underlay_stitch_length,
- -100000)
+ forward, back = self.plot_points_on_rails(self.center_walk_underlay_stitch_length, -100000)
+
+ stitches = []
+ for i in range(self.center_walk_underlay_repeats):
+ if i % 2 == 0:
+ stitches += forward
+ else:
+ stitches += list(reversed(back))
+
return StitchGroup(
color=self.color,
tags=("satin_column", "satin_column_underlay", "satin_center_walk"),
- stitches=(forward + list(reversed(back))))
+ stitches=stitches)
def do_zigzag_underlay(self):
# zigzag underlay, usually done at a much lower density than the
@@ -750,6 +789,9 @@ class SatinColumn(EmbroideryElement):
sides = self.plot_points_on_rails(self.zigzag_underlay_spacing / 2.0,
-self.zigzag_underlay_inset)
+ if self._center_walk_is_odd():
+ sides = [list(reversed(sides[0])), list(reversed(sides[1]))]
+
# This organizes the points in each side in the order that they'll be
# visited.
sides = [sides[0][::2] + list(reversed(sides[0][1::2])),
@@ -757,7 +799,14 @@ class SatinColumn(EmbroideryElement):
# This fancy bit of iterable magic just repeatedly takes a point
# from each side in turn.
+ last_point = None
for point in chain.from_iterable(zip(*sides)):
+ if last_point and self.zigzag_underlay_max_stitch_length:
+ if last_point.distance(point) > self.zigzag_underlay_max_stitch_length:
+ points, count = self._get_split_points(last_point, point, self.zigzag_underlay_max_stitch_length)
+ for point in points:
+ patch.add_stitch(point)
+ last_point = point
patch.add_stitch(point)
patch.add_tags(("satin_column", "satin_column_underlay", "satin_zigzag_underlay"))
@@ -783,6 +832,9 @@ class SatinColumn(EmbroideryElement):
for point in chain.from_iterable(zip(*sides)):
patch.add_stitch(point)
+ if self._center_walk_is_odd():
+ patch.stitches = list(reversed(patch.stitches))
+
patch.add_tags(("satin_column", "satin_column_edge"))
return patch
@@ -805,6 +857,9 @@ class SatinColumn(EmbroideryElement):
patch.add_stitch(right)
patch.add_stitch(left)
+ if self._center_walk_is_odd():
+ patch.stitches = list(reversed(patch.stitches))
+
patch.add_tags(("satin_column", "e_stitch"))
return patch
@@ -815,7 +870,7 @@ class SatinColumn(EmbroideryElement):
for i, (left, right) in enumerate(zip(*sides)):
patch.add_stitch(left)
patch.stitches[-1].add_tags(("satin_column", "satin_column_edge"))
- points, count = self._get_split_points(left, right)
+ points, count = self._get_split_points(left, right, self.max_stitch_length)
for point in points:
patch.add_stitch(point)
patch.stitches[-1].add_tags(("satin_column", "satin_split_stitch"))
@@ -825,23 +880,25 @@ class SatinColumn(EmbroideryElement):
# but it looks ugly if the points differ too much
# so let's make sure they have at least the same amount of divisions
if not i+1 >= len(sides[0]):
- points, count = self._get_split_points(right, sides[0][i+1], count)
+ points, count = self._get_split_points(right, sides[0][i+1], self.max_stitch_length, count)
for point in points:
patch.add_stitch(point)
patch.stitches[-1].add_tags(("satin_column", "satin_split_stitch"))
+ if self._center_walk_is_odd():
+ patch.stitches = list(reversed(patch.stitches))
return patch
- def _get_split_points(self, left, right, count=None):
+ def _get_split_points(self, left, right, max_stitch_length, count=None):
points = []
distance = left.distance(right)
- split_count = count or int(-(-distance // self.max_stitch_length))
+ split_count = count or int(-(-distance // max_stitch_length))
for i in range(split_count):
line = shgeo.LineString((left, right))
split_point = line.interpolate((i+1)/split_count, normalized=True)
points.append(Point(split_point.x, split_point.y))
return [points, split_count]
- def to_stitch_groups(self, last_patch):
+ def to_stitch_groups(self, last_patch=None):
# Stitch a variable-width satin column, zig-zagging between two paths.
# The algorithm will draw zigzags between each consecutive pair of
@@ -866,4 +923,7 @@ class SatinColumn(EmbroideryElement):
else:
patch += self.do_satin()
+ if not patch.stitches:
+ return []
+
return [patch]
diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py
index 763167ad..6edd2c9e 100644
--- a/lib/elements/stroke.py
+++ b/lib/elements/stroke.py
@@ -7,16 +7,38 @@ import sys
import shapely.geometry
-from .element import EmbroideryElement, param
+from inkex import Transform
+
from ..i18n import _
+from ..marker import get_marker_elements
from ..stitch_plan import StitchGroup
from ..stitches import bean_stitch, running_stitch
-from ..svg import parse_length_with_units
+from ..stitches.ripple_stitch import ripple_stitch
+from ..svg import get_node_transform, parse_length_with_units
from ..utils import Point, cache
+from .element import EmbroideryElement, param
+from .validation import ValidationWarning
warned_about_legacy_running_stitch = False
+class IgnoreSkipValues(ValidationWarning):
+ name = _("Ignore skip")
+ description = _("Skip values are ignored, because there was no line left to embroider.")
+ steps_to_solve = [
+ _('* Open the params dialog with this object selected'),
+ _('* Reduce Skip values or increase number of lines'),
+ ]
+
+
+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 Stroke(EmbroideryElement):
element_name = _("Stroke")
@@ -34,15 +56,36 @@ class Stroke(EmbroideryElement):
return self.get_style("stroke-dasharray") is not None
@property
- @param('running_stitch_length_mm',
- _('Running stitch length'),
- tooltip=_('Length of stitches in running stitch mode.'),
- unit='mm',
- type='float',
- default=1.5,
- sort_index=3)
- def running_stitch_length(self):
- return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
+ @param('stroke_method',
+ _('Method'),
+ type='dropdown',
+ default=0,
+ # 0: run/simple satin, 1: manual, 2: ripple
+ options=[_("Running Stitch"), _("Ripple")],
+ sort_index=0)
+ def stroke_method(self):
+ return self.get_int_param('stroke_method', 0)
+
+ @property
+ @param('manual_stitch',
+ _('Manual stitch placement'),
+ tooltip=_("Stitch every node in the path. All other options are ignored."),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 0)],
+ sort_index=1)
+ def manual_stitch_mode(self):
+ return self.get_boolean_param('manual_stitch')
+
+ @property
+ @param('repeats',
+ _('Repeats'),
+ tooltip=_('Defines how many times to run down and back along the path.'),
+ type='int',
+ default="1",
+ sort_index=2)
+ def repeats(self):
+ return max(1, self.get_int_param("repeats", 1))
@property
@param(
@@ -50,34 +93,196 @@ class Stroke(EmbroideryElement):
_('Bean stitch number of repeats'),
tooltip=_('Backtrack each stitch this many times. '
'A value of 1 would triple each stitch (forward, back, forward). '
- 'A value of 2 would quintuple each stitch, etc. Only applies to running stitch.'),
+ 'A value of 2 would quintuple each stitch, etc.'),
type='int',
default=0,
- sort_index=2)
+ sort_index=3)
def bean_stitch_repeats(self):
return self.get_int_param("bean_stitch_repeats", 0)
@property
+ @param('running_stitch_length_mm',
+ _('Running stitch length'),
+ tooltip=_('Length of stitches in running stitch mode.'),
+ unit='mm',
+ type='float',
+ default=1.5,
+ sort_index=4)
+ def running_stitch_length(self):
+ return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
+
+ @property
@param('zigzag_spacing_mm',
_('Zig-zag spacing (peak-to-peak)'),
tooltip=_('Length of stitches in zig-zag mode.'),
unit='mm',
type='float',
default=0.4,
- sort_index=3)
+ select_items=[('stroke_method', 0)],
+ sort_index=5)
@cache
def zigzag_spacing(self):
return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01)
@property
- @param('repeats',
- _('Repeats'),
- tooltip=_('Defines how many times to run down and back along the path.'),
+ @param('line_count',
+ _('Number of lines'),
+ tooltip=_('Number of lines from start to finish'),
type='int',
- default="1",
- sort_index=1)
- def repeats(self):
- return self.get_int_param("repeats", 1)
+ default=10,
+ select_items=[('stroke_method', 1)],
+ sort_index=5)
+ @cache
+ def line_count(self):
+ return max(self.get_int_param("line_count", 10), 1)
+
+ def get_line_count(self):
+ if self.is_closed:
+ return self.line_count + 1
+ return self.line_count
+
+ @property
+ @param('skip_start',
+ _('Skip first lines'),
+ tooltip=_('Skip this number of lines at the beginning.'),
+ type='int',
+ default=0,
+ select_items=[('stroke_method', 1)],
+ sort_index=6)
+ @cache
+ def skip_start(self):
+ return abs(self.get_int_param("skip_start", 0))
+
+ @property
+ @param('skip_end',
+ _('Skip last lines'),
+ tooltip=_('Skip this number of lines at the end'),
+ type='int',
+ default=0,
+ select_items=[('stroke_method', 1)],
+ sort_index=7)
+ @cache
+ def skip_end(self):
+ return abs(self.get_int_param("skip_end", 0))
+
+ def _adjust_skip(self, skip):
+ if self.skip_start + self.skip_end >= self.line_count:
+ return 0
+ else:
+ return skip
+
+ def get_skip_start(self):
+ return self._adjust_skip(self.skip_start)
+
+ def get_skip_end(self):
+ return self._adjust_skip(self.skip_end)
+
+ @property
+ @param('exponent',
+ _('Line distance exponent'),
+ tooltip=_('Increase density towards one side.'),
+ type='float',
+ default=1,
+ select_items=[('stroke_method', 1)],
+ sort_index=8)
+ @cache
+ def exponent(self):
+ return max(self.get_float_param("exponent", 1), 0.1)
+
+ @property
+ @param('flip_exponent',
+ _('Flip exponent'),
+ tooltip=_('Reverse exponent effect.'),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 1)],
+ sort_index=9)
+ @cache
+ def flip_exponent(self):
+ return self.get_boolean_param("flip_exponent", False)
+
+ @property
+ @param('reverse',
+ _('Reverse'),
+ tooltip=_('Flip start and end point'),
+ type='boolean',
+ default=False,
+ select_items=[('stroke_method', 1)],
+ sort_index=10)
+ @cache
+ def reverse(self):
+ return self.get_boolean_param("reverse", False)
+
+ @property
+ @param('grid_size',
+ _('Grid size'),
+ tooltip=_('Render as grid. Use with care and watch your stitch density.'),
+ type='float',
+ default=0,
+ unit='mm',
+ select_items=[('stroke_method', 1)],
+ sort_index=11)
+ @cache
+ def grid_size(self):
+ return abs(self.get_float_param("grid_size", 0))
+
+ @property
+ @param('scale_axis',
+ _('Scale axis'),
+ tooltip=_('Scale axis for satin guided ripple stitches.'),
+ type='dropdown',
+ default=0,
+ # 0: xy, 1: x, 2: y, 3: none
+ options=[_("X Y"), _("X"), _("Y"), _("None")],
+ select_items=[('stroke_method', 1)],
+ sort_index=12)
+ def scale_axis(self):
+ return self.get_int_param('scale_axis', 0)
+
+ @property
+ @param('scale_start',
+ _('Starting scale'),
+ tooltip=_('How big the first copy of the line should be, in percent.') + " " + _('Used only for ripple stitch with a guide line.'),
+ type='float',
+ default=100,
+ select_items=[('stroke_method', 1)],
+ sort_index=13)
+ def scale_start(self):
+ return self.get_float_param('scale_start', 100.0)
+
+ @property
+ @param('scale_end',
+ _('Ending scale'),
+ tooltip=_('How big the last copy of the line should be, in percent.') + " " + _('Used only for ripple stitch with a guide line.'),
+ type='float',
+ default=0.0,
+ select_items=[('stroke_method', 1)],
+ sort_index=14)
+ def scale_end(self):
+ return self.get_float_param('scale_end', 0.0)
+
+ @property
+ @param('rotate_ripples',
+ _('Rotate'),
+ tooltip=_('Rotate satin guided ripple stitches'),
+ type='boolean',
+ default=True,
+ select_items=[('stroke_method', 1)],
+ sort_index=15)
+ @cache
+ def rotate_ripples(self):
+ return self.get_boolean_param("rotate_ripples", True)
+
+ @property
+ @cache
+ def is_closed(self):
+ # returns true if the outline of a single line stroke is a closed shape
+ # (with a small tolerance)
+ lines = self.as_multi_line_string().geoms
+ if len(lines) == 1:
+ coords = lines[0].coords
+ return Point(*coords[0]).distance(Point(*coords[-1])) < 0.05
+ return False
@property
def paths(self):
@@ -86,7 +291,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]
@@ -96,22 +301,22 @@ class Stroke(EmbroideryElement):
@property
@cache
def shape(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 self.as_multi_line_string().convex_hull
- @property
- @param('manual_stitch',
- _('Manual stitch placement'),
- tooltip=_("Stitch every node in the path. Stitch length and zig-zag spacing are ignored."),
- type='boolean',
- default=False,
- sort_index=0)
- def manual_stitch_mode(self):
- return self.get_boolean_param('manual_stitch')
+ @cache
+ def as_multi_line_string(self):
+ line_strings = [shapely.geometry.LineString(path) for path in self.paths]
+ return shapely.geometry.MultiLineString(line_strings)
+
+ def get_ripple_target(self):
+ command = self.get_command('ripple_target')
+ if command:
+ pos = [float(command.use.get("x", 0)), float(command.use.get("y", 0))]
+ transform = get_node_transform(command.use)
+ pos = Transform(transform).apply_to_point(pos)
+ return Point(*pos)
+ else:
+ return self.shape.centroid
def is_running_stitch(self):
# using stroke width <= 0.5 pixels to indicate running stitch is deprecated in favor of dashed lines
@@ -166,6 +371,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()
@@ -193,23 +402,65 @@ class Stroke(EmbroideryElement):
return StitchGroup(self.color, stitches)
- def to_stitch_groups(self, last_patch):
- patches = []
+ def ripple_stitch(self):
+ return StitchGroup(
+ color=self.color,
+ tags=["ripple_stitch"],
+ stitches=ripple_stitch(self))
- for path in self.paths:
- path = [Point(x, y) for x, y in path]
- if self.manual_stitch_mode:
- patch = StitchGroup(color=self.color, stitches=path, stitch_as_is=True)
- elif self.is_running_stitch():
- patch = self.running_stitch(path, self.running_stitch_length)
-
- if self.bean_stitch_repeats > 0:
- patch.stitches = bean_stitch(patch.stitches, self.bean_stitch_repeats)
+ def do_bean_repeats(self, stitches):
+ return bean_stitch(stitches, self.bean_stitch_repeats)
- else:
- patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width)
+ def to_stitch_groups(self, last_patch):
+ patches = []
+ # ripple stitch
+ if self.stroke_method == 1:
+ patch = self.ripple_stitch()
if patch:
+ if self.bean_stitch_repeats > 0:
+ patch.stitches = self.do_bean_repeats(patch.stitches)
patches.append(patch)
+ else:
+ for path in self.paths:
+ path = [Point(x, y) for x, y in path]
+ # manual stitch
+ if self.manual_stitch_mode:
+ patch = StitchGroup(color=self.color, stitches=path, stitch_as_is=True)
+ # running stitch
+ elif self.is_running_stitch():
+ patch = self.running_stitch(path, self.running_stitch_length)
+ if self.bean_stitch_repeats > 0:
+ patch.stitches = self.do_bean_repeats(patch.stitches)
+ # simple satin
+ else:
+ patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width)
+
+ if patch:
+ patches.append(patch)
return patches
+
+ @cache
+ def get_guide_line(self):
+ guide_lines = get_marker_elements(self.node, "guide-line", False, True, True)
+ # No or empty guide line
+ # if there is a satin guide line, it will also be in stroke, so no need to check for satin here
+ if not guide_lines or not guide_lines['stroke']:
+ return None
+
+ # use the satin guide line if there is one, else use stroke
+ # ignore multiple guide lines
+ if len(guide_lines['satin']) >= 1:
+ return guide_lines['satin'][0]
+ return guide_lines['stroke'][0]
+
+ def validation_warnings(self):
+ if self.stroke_method == 1 and self.skip_start + self.skip_end >= self.line_count:
+ yield IgnoreSkipValues(self.shape.centroid)
+
+ # guided fill warnings
+ if self.stroke_method == 1:
+ guide_lines = get_marker_elements(self.node, "guide-line", False, True, True)
+ if sum(len(x) for x in guide_lines.values()) > 1:
+ yield MultipleGuideLineWarning(self.shape.centroid)
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 83a522f2..56949b50 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -5,32 +5,42 @@
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
+from .commands_scale_symbols import CommandsScaleSymbols
from .convert_to_satin import ConvertToSatin
from .convert_to_stroke import ConvertToStroke
from .cut_satin import CutSatin
+from .cutwork_segmentation import CutworkSegmentation
from .duplicate_params import DuplicateParams
from .embroider_settings import EmbroiderSettings
from .flip import Flip
+from .generate_palette import GeneratePalette
from .global_commands import GlobalCommands
-from .import_threadlist import ImportThreadlist
from .input import Input
from .install import Install
from .install_custom_palette import InstallCustomPalette
from .layer_commands import LayerCommands
from .lettering import Lettering
from .lettering_custom_font_dir import LetteringCustomFontDir
+from .lettering_force_lock_stitches import LetteringForceLockStitches
from .lettering_generate_json import LetteringGenerateJson
from .lettering_remove_kerning import LetteringRemoveKerning
+from .letters_to_font import LettersToFont
from .object_commands import ObjectCommands
+from .object_commands_toggle_visibility import ObjectCommandsToggleVisibility
from .output import Output
+from .palette_split_text import PaletteSplitText
+from .palette_to_text import PaletteToText
from .params import Params
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
@@ -44,24 +54,34 @@ __all__ = extensions = [StitchPlanPreview,
Zip,
Flip,
SelectionToPattern,
+ SelectionToGuideLine,
ObjectCommands,
+ ObjectCommandsToggleVisibility,
LayerCommands,
GlobalCommands,
+ CommandsScaleSymbols,
ConvertToSatin,
ConvertToStroke,
CutSatin,
AutoSatin,
+ AutoRun,
Lettering,
LetteringGenerateJson,
LetteringRemoveKerning,
LetteringCustomFontDir,
+ LetteringForceLockStitches,
+ LettersToFont,
Troubleshoot,
RemoveEmbroiderySettings,
Cleanup,
BreakApart,
- ImportThreadlist,
+ ApplyThreadlist,
InstallCustomPalette,
+ GeneratePalette,
+ PaletteSplitText,
+ PaletteToText,
Simulator,
Reorder,
DuplicateParams,
- EmbroiderSettings]
+ EmbroiderSettings,
+ CutworkSegmentation]
diff --git a/lib/extensions/import_threadlist.py b/lib/extensions/apply_threadlist.py
index f7fe0bcc..31861513 100644
--- a/lib/extensions/import_threadlist.py
+++ b/lib/extensions/apply_threadlist.py
@@ -14,7 +14,12 @@ from ..threads import ThreadCatalog
from .base import InkstitchExtension
-class ImportThreadlist(InkstitchExtension):
+class ApplyThreadlist(InkstitchExtension):
+ '''
+ Applies colors of a thread list to elements
+ Count of colors and elements should fit together
+ Use case: reapply colors to e.g. a dst file
+ '''
def __init__(self, *args, **kwargs):
InkstitchExtension.__init__(self, *args, **kwargs)
self.arg_parser.add_argument("-f", "--filepath", type=str, default="", dest="filepath")
@@ -23,7 +28,7 @@ class ImportThreadlist(InkstitchExtension):
def effect(self):
# Remove selection, we want all the elements in the document
- self.svg.selected.clear()
+ self.svg.selection.clear()
if not self.get_elements():
return
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/auto_satin.py b/lib/extensions/auto_satin.py
index 62fb15af..dfb1a87e 100644
--- a/lib/extensions/auto_satin.py
+++ b/lib/extensions/auto_satin.py
@@ -44,7 +44,7 @@ class AutoSatin(CommandsExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
# L10N auto-route satin columns extension
inkex.errormsg(_("Please select one or more satin columns."))
return False
diff --git a/lib/extensions/base.py b/lib/extensions/base.py
index 828e3685..cf94714c 100644
--- a/lib/extensions/base.py
+++ b/lib/extensions/base.py
@@ -8,18 +8,21 @@ 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_DEFS_TAG, SVG_GROUP_TAG)
+ NOT_EMBROIDERABLE_TAGS, SVG_CLIPPATH_TAG, SVG_DEFS_TAG,
+ SVG_GROUP_TAG, SVG_MASK_TAG)
SVG_METADATA_TAG = inkex.addNS("metadata", "svg")
@@ -119,7 +122,7 @@ class InkstitchExtension(inkex.Effect):
return current_layer
def no_elements_error(self):
- if self.svg.selected:
+ if self.svg.selection:
# l10n This was previously: "No embroiderable paths selected."
inkex.errormsg(_("Ink/Stitch doesn't know how to work with any of the objects you've selected.") + "\n")
else:
@@ -129,6 +132,10 @@ class InkstitchExtension(inkex.Effect):
def descendants(self, node, selected=False, troubleshoot=False): # noqa: C901
nodes = []
+
+ if node.tag == Comment:
+ return []
+
element = EmbroideryElement(node)
if element.has_command('ignore_object'):
@@ -141,15 +148,17 @@ class InkstitchExtension(inkex.Effect):
if (node.tag in EMBROIDERABLE_TAGS or node.tag == SVG_GROUP_TAG) and element.get_style('display', 'inline') is None:
return []
- if node.tag == SVG_DEFS_TAG:
+ # defs, masks and clippaths can contain embroiderable elements
+ # but should never be rendered directly.
+ if node.tag in [SVG_DEFS_TAG, SVG_MASK_TAG, SVG_CLIPPATH_TAG]:
return []
# command connectors with a fill color set, will glitch into the elements list
if is_command(node) or node.get(CONNECTOR_TYPE):
return []
- if self.svg.selected:
- if node.get("id") in self.svg.selected:
+ if self.svg.selection:
+ if node.get("id") in self.svg.selection:
selected = True
else:
# if the user didn't select anything that means we process everything
@@ -161,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
@@ -180,14 +189,6 @@ class InkstitchExtension(inkex.Effect):
self.no_elements_error()
return False
- def get_selected_in_order(self):
- selected = []
- for i in self.options.ids:
- path = '//*[@id="%s"]' % i
- for node in self.document.xpath(path, namespaces=inkex.NSS):
- selected.append(node)
- return selected
-
def elements_to_stitch_groups(self, elements):
patches = []
for element in elements:
diff --git a/lib/extensions/break_apart.py b/lib/extensions/break_apart.py
index b16c901d..581e49bc 100644
--- a/lib/extensions/break_apart.py
+++ b/lib/extensions/break_apart.py
@@ -27,7 +27,7 @@ class BreakApart(InkstitchExtension):
self.minimum_size = 5
def effect(self): # noqa: C901
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select one or more fill areas to break apart."))
return
@@ -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 99b72a81..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
@@ -24,7 +24,7 @@ class Cleanup(InkstitchExtension):
self.fill_threshold = self.options.fill_threshold
self.stroke_threshold = self.options.stroke_threshold
- self.svg.selected.clear()
+ self.svg.selection.clear()
count = 0
svg = self.document.getroot()
@@ -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/commands_scale_symbols.py b/lib/extensions/commands_scale_symbols.py
new file mode 100644
index 00000000..2e025000
--- /dev/null
+++ b/lib/extensions/commands_scale_symbols.py
@@ -0,0 +1,23 @@
+# 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 inkex import NSS, Transform
+
+from .base import InkstitchExtension
+
+
+class CommandsScaleSymbols(InkstitchExtension):
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-s", "--size", dest="size", type=float, default=1)
+
+ def effect(self):
+ size = self.options.size
+
+ svg = self.document.getroot()
+ command_symbols = svg.xpath(".//svg:symbol[starts-with(@id,'inkstitch_')]", namespaces=NSS)
+ for symbol in command_symbols:
+ transform = Transform(symbol.get('transform')).add_scale(size)
+ symbol.set('transform', str(transform))
diff --git a/lib/extensions/convert_to_satin.py b/lib/extensions/convert_to_satin.py
index f3b43366..9950b2e8 100644
--- a/lib/extensions/convert_to_satin.py
+++ b/lib/extensions/convert_to_satin.py
@@ -31,7 +31,7 @@ class ConvertToSatin(InkstitchExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select at least one line to convert to a satin column."))
return
@@ -102,7 +102,8 @@ class ConvertToSatin(InkstitchExtension):
"""Convert svg line join style to shapely parallel offset arguments."""
args = {
- 'join_style': shgeo.JOIN_STYLE.round
+ # mitre is the default per SVG spec
+ 'join_style': shgeo.JOIN_STYLE.mitre
}
element_join_style = element.get_style('stroke-linejoin')
@@ -116,6 +117,8 @@ class ConvertToSatin(InkstitchExtension):
args['mitre_limit'] = miter_limit
elif element_join_style == "bevel":
args['join_style'] = shgeo.JOIN_STYLE.bevel
+ elif element_join_style == "round":
+ args['join_style'] = shgeo.JOIN_STYLE.round
return args
diff --git a/lib/extensions/convert_to_stroke.py b/lib/extensions/convert_to_stroke.py
index dfaef615..5a2ab23c 100644
--- a/lib/extensions/convert_to_stroke.py
+++ b/lib/extensions/convert_to_stroke.py
@@ -21,7 +21,7 @@ class ConvertToStroke(InkstitchExtension):
self.arg_parser.add_argument("-k", "--keep_satin", type=inkex.Boolean, default=False, dest="keep_satin")
def effect(self):
- if not self.svg.selected or not self.get_elements():
+ if not self.svg.selection or not self.get_elements():
inkex.errormsg(_("Please select at least one satin column to convert to a running stitch."))
return
diff --git a/lib/extensions/cut_satin.py b/lib/extensions/cut_satin.py
index fcd1ca06..3d38c7d8 100644
--- a/lib/extensions/cut_satin.py
+++ b/lib/extensions/cut_satin.py
@@ -16,7 +16,7 @@ class CutSatin(InkstitchExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select one or more satin columns to cut."))
return
diff --git a/lib/extensions/cutwork_segmentation.py b/lib/extensions/cutwork_segmentation.py
new file mode 100644
index 00000000..672aeade
--- /dev/null
+++ b/lib/extensions/cutwork_segmentation.py
@@ -0,0 +1,191 @@
+# 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 math import atan2, degrees
+
+from lxml import etree
+from shapely.geometry import LineString, Point
+
+import inkex
+
+from ..elements import Stroke
+from ..i18n import _
+from ..svg import get_correction_transform
+from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS, SVG_PATH_TAG
+from .base import InkstitchExtension
+
+
+class CutworkSegmentation(InkstitchExtension):
+ '''
+ This will split up stroke elements according to their direction.
+ Overlapping angle definitions (user input) will result in overlapping paths.
+ This is wanted behaviour if the needles have a hard time to cut edges at the border of their specific angle capability.
+ '''
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-o", "--options", type=str, default=None, dest="page_1")
+ self.arg_parser.add_argument("-i", "--info", type=str, default=None, dest="page_2")
+ self.arg_parser.add_argument("-as", "--a_start", type=int, default=0, dest="a_start")
+ self.arg_parser.add_argument("-ae", "--a_end", type=int, default=0, dest="a_end")
+ self.arg_parser.add_argument("-ac", "--a_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="a_color")
+ self.arg_parser.add_argument("-bs", "--b_start", type=int, default=0, dest="b_start")
+ self.arg_parser.add_argument("-be", "--b_end", type=int, default=0, dest="b_end")
+ self.arg_parser.add_argument("-bc", "--b_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="b_color")
+ self.arg_parser.add_argument("-cs", "--c_start", type=int, default=0, dest="c_start")
+ self.arg_parser.add_argument("-ce", "--c_end", type=int, default=0, dest="c_end")
+ self.arg_parser.add_argument("-cc", "--c_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="c_color")
+ self.arg_parser.add_argument("-ds", "--d_start", type=int, default=0, dest="d_start")
+ self.arg_parser.add_argument("-de", "--d_end", type=int, default=0, dest="d_end")
+ self.arg_parser.add_argument("-dc", "--d_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="d_color")
+ self.arg_parser.add_argument("-s", "--sort_by_color", type=inkex.Boolean, default=True, dest="sort_by_color")
+ self.arg_parser.add_argument("-k", "--keep_original", type=inkex.Boolean, default=False, dest="keep_original")
+
+ def effect(self):
+ if not self.svg.selection:
+ inkex.errormsg(_("Please select one or more stroke elements."))
+ return
+
+ if not self.get_elements():
+ return
+
+ self.sectors = {
+ 1: {'id': 1, 'start': self.options.a_start, 'end': self.options.a_end, 'color': self.options.a_color, 'point_list': []},
+ 2: {'id': 2, 'start': self.options.b_start, 'end': self.options.b_end, 'color': self.options.b_color, 'point_list': []},
+ 3: {'id': 3, 'start': self.options.c_start, 'end': self.options.c_end, 'color': self.options.c_color, 'point_list': []},
+ 4: {'id': 4, 'start': self.options.d_start, 'end': self.options.d_end, 'color': self.options.d_color, 'point_list': []}
+ }
+
+ # remove sectors where the start angle equals the end angle (some setups only work with two needles instead of four)
+ self.sectors = {index: sector for index, sector in self.sectors.items() if sector['start'] != sector['end']}
+
+ self.new_elements = []
+ for element in self.elements:
+ if isinstance(element, Stroke):
+
+ # save parent and index to be able to position and insert new elements later on
+ parent = element.node.getparent()
+ index = parent.index(element.node)
+
+ for path in element.paths:
+ linestring = LineString(path)
+ # fill self.new_elements list with line segments
+ self._prepare_line_sections(element, linestring.coords)
+
+ self._insert_elements(parent, element, index)
+
+ self._remove_originals()
+
+ def _get_sectors(self, angle):
+ sectors = []
+ for sector in self.sectors.values():
+ if self._in_sector(angle, sector):
+ sectors.append(sector)
+ return sectors
+
+ def _in_sector(self, angle, sector):
+ stop = sector['end'] + 1
+ if sector['start'] > stop:
+ return angle in range(sector['start'], 181) or angle in range(0, stop)
+ else:
+ return angle in range(sector['start'], stop)
+
+ def _get_angle(self, p1, p2):
+ angle = round(degrees(atan2(p2.y - p1.y, p2.x - p1.x)) % 360)
+ if angle > 180:
+ angle -= 180
+ return angle
+
+ def _prepare_line_sections(self, element, coords):
+ prev_point = None
+ current_sectors = []
+
+ for index, point in enumerate(coords):
+ point = Point(*point)
+ if prev_point is None:
+ prev_point = point
+ continue
+
+ angle = self._get_angle(point, prev_point)
+ sectors = self._get_sectors(angle)
+
+ for sector in sectors:
+ self.sectors[sector['id']]['point_list'].append(prev_point)
+ # don't miss the last point
+ if index == len(coords) - 1:
+ self.sectors[sector['id']]['point_list'].append(point)
+ self._prepare_element(self.sectors[sector['id']], element)
+
+ # if a segment ends, prepare the element and clear point_lists
+ for current in current_sectors:
+ if current not in sectors:
+ # add last point
+ self.sectors[current['id']]['point_list'].append(prev_point)
+ self._prepare_element(self.sectors[current['id']], element)
+
+ prev_point = point
+ current_sectors = sectors
+
+ def _prepare_element(self, sector, element):
+ point_list = sector['point_list']
+ if len(point_list) < 2:
+ return
+
+ color = str(self.path_style(element, str(sector['color'])))
+
+ d = "M "
+ for point in point_list:
+ d += "%s,%s " % (point.x, point.y)
+
+ stroke_element = etree.Element(SVG_PATH_TAG,
+ {
+ "style": color,
+ "transform": get_correction_transform(element.node),
+ INKSTITCH_ATTRIBS["ties"]: "3",
+ INKSTITCH_ATTRIBS["running_stitch_length_mm"]: "1",
+ "d": d
+ })
+ self.new_elements.append([stroke_element, sector['id']])
+ # clear point_list in self.sectors
+ self.sectors[sector['id']].update({'point_list': []})
+
+ def _insert_elements(self, parent, element, index):
+ self.new_elements.reverse()
+ if self.options.sort_by_color is True:
+ self.new_elements = sorted(self.new_elements, key=lambda x: x[1], reverse=True)
+
+ group = self._insert_group(parent, _("Cutwork Group"), "__inkstitch_cutwork_group__", index)
+
+ section = 0
+ for element, section_id in self.new_elements:
+ # if sorted by color, add a subgroup for each knife
+ if self.options.sort_by_color:
+ if section_id != section:
+ section = section_id
+ section_group = self._insert_group(group, _("Needle #%s") % section, "__inkstitch_cutwork_needle_group__")
+ else:
+ section_group = group
+
+ section_group.insert(0, element)
+
+ def _insert_group(self, parent, label, group_id, index=0):
+ group = etree.Element("g", {
+ INKSCAPE_LABEL: "%s" % label,
+ "id": self.uniqueId("%s" % group_id)
+ })
+ parent.insert(index, group)
+ return group
+
+ def _remove_originals(self):
+ if self.options.keep_original:
+ return
+
+ for element in self.elements:
+ if isinstance(element, Stroke):
+ parent = element.node.getparent()
+ parent.remove(element.node)
+
+ def path_style(self, element, color):
+ # set stroke color and make it a running stitch - they don't want to cut zigzags
+ return inkex.Style(element.node.get('style', '')) + inkex.Style('stroke:%s;stroke-dasharray:6,1;' % color)
diff --git a/lib/extensions/duplicate_params.py b/lib/extensions/duplicate_params.py
index 9fcdbf1c..46110691 100644
--- a/lib/extensions/duplicate_params.py
+++ b/lib/extensions/duplicate_params.py
@@ -3,10 +3,9 @@
# Copyright (c) 2021 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-import inkex
+from inkex import NSS, ShapeElement, errormsg
from ..i18n import _
-from ..svg.tags import EMBROIDERABLE_TAGS, SVG_GROUP_TAG
from .base import InkstitchExtension
@@ -14,36 +13,29 @@ class DuplicateParams(InkstitchExtension):
# Transfer inkstitch namespaced attributes from the first selected element to the rest of selection
def effect(self):
- objects = self.get_selected_in_order()
+ objects = self.svg.selection.get(ShapeElement)
if len(objects) < 2:
- inkex.errormsg(_("This function copies Ink/Stitch parameters from the first selected element to the rest of the selection. "
- "Please select at least two elements."))
+ errormsg(_("This function copies Ink/Stitch parameters from the first selected element to the rest of the selection. "
+ "Please select at least two elements."))
return
- copy_from = objects[0]
+ copy_from = objects.first()
copy_from_attribs = self.get_inkstitch_attributes(copy_from)
- copy_to_selection = objects[1:]
- self.copy_to = []
-
- # extract copy_to group elements
- for element in copy_to_selection:
- if element.tag == SVG_GROUP_TAG:
- for descendant in element.iterdescendants(EMBROIDERABLE_TAGS):
- self.copy_to.append(descendant)
- elif element.tag in EMBROIDERABLE_TAGS:
- self.copy_to.append(element)
+ copy_to = objects
# remove inkstitch params from copy_to elements
- for element in self.copy_to:
+ for element in copy_to:
+ if element == copy_to.first():
+ continue
copy_to_attribs = self.get_inkstitch_attributes(element)
for attrib in copy_to_attribs:
element.pop(attrib)
# paste inkstitch params from copy_from element to copy_to elements
for attrib in copy_from_attribs:
- for element in self.copy_to:
+ for element in copy_to:
element.attrib[attrib] = copy_from_attribs[attrib]
def get_inkstitch_attributes(self, node):
- return {k: v for k, v in node.attrib.iteritems() if inkex.NSS['inkstitch'] in k}
+ return {k: v for k, v in node.attrib.iteritems() if NSS['inkstitch'] in k}
diff --git a/lib/extensions/flip.py b/lib/extensions/flip.py
index 743f1701..893dc038 100644
--- a/lib/extensions/flip.py
+++ b/lib/extensions/flip.py
@@ -24,7 +24,7 @@ class Flip(InkstitchExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select one or more satin columns to flip."))
return
diff --git a/lib/extensions/generate_palette.py b/lib/extensions/generate_palette.py
new file mode 100644
index 00000000..280be90f
--- /dev/null
+++ b/lib/extensions/generate_palette.py
@@ -0,0 +1,84 @@
+# Authors: see git history
+#
+# Copyright (c) 2022 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+import os
+
+import inkex
+
+from ..i18n import _
+from ..utils import guess_inkscape_config_path
+from .base import InkstitchExtension
+
+
+class GeneratePalette(InkstitchExtension):
+ # Generate a custom color palette in object related order
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-n", "--palette_name", type=str, default=None, dest="palette_name")
+ self.arg_parser.add_argument("-f", "--palette_folder", type=str, default=None, dest="palette_folder")
+ self.arg_parser.add_argument("-o", "--options", type=str, default=None, dest="page_options")
+ self.arg_parser.add_argument("-i", "--info", type=str, default=None, dest="page_help")
+
+ def effect(self):
+ path = self.options.palette_folder
+ brand = self.options.palette_name
+ file_name = "InkStitch %s.gpl" % brand
+ color_palette_name = '\nName: Ink/Stitch: %s' % brand
+
+ if not brand:
+ inkex.errormsg(_("Please specify a name for your color palette."))
+ return
+
+ if path:
+ if not os.path.isdir(path):
+ inkex.errormsg(_("Unkown directory path."))
+ return
+ else:
+ path = os.path.join(guess_inkscape_config_path(), 'palettes')
+ if not os.path.isdir(path):
+ inkex.errormsg(_("Ink/Stitch cannot find your palette folder automatically. Please enter the path manually."))
+ return
+
+ elements = self.svg.selection.rendering_order()
+
+ if not elements:
+ inkex.errormsg(_("No element selected.\n\nPlease select at least one text element with a fill color."))
+ return
+
+ colors = self._get_color_from_elements(elements)
+
+ if not colors:
+ inkex.errormsg(_("We couldn't find any fill colors on your text elements. Please read the instructions on our website."))
+ return
+
+ colors = ['GIMP Palette', color_palette_name, '\nColumns: 4', '\n# RGB Value\t Color Name Number'] + colors
+
+ file_path = os.path.join(path, file_name)
+ with open(file_path, 'w', encoding='utf-8') as gpl:
+ gpl.writelines(colors)
+
+ def _get_color_from_elements(self, elements):
+ colors = []
+ for element in elements:
+ if 'fill' not in element.style.keys() or type(element) != inkex.TextElement:
+ continue
+
+ color = inkex.Color(element.style['fill']).to_rgb()
+ color_name = element.get_text().split(' ')
+ if len(color_name) > 1 and color_name[-1].isdigit():
+ number = color_name[-1]
+ name = ' '.join(color_name[:-1])
+ else:
+ number = 0
+ name = ' '.join(color_name)
+ color = "\n%s\t%s\t%s\t%s %s" % (str(color[0]).rjust(3), str(color[1]).rjust(3), str(color[2]).rjust(3), name.rjust(30), number)
+ colors.append(color)
+
+ return colors
+
+
+if __name__ == '__main__':
+ e = GeneratePalette()
+ e.affect()
diff --git a/lib/extensions/input.py b/lib/extensions/input.py
index a8b8bee3..066b9003 100644
--- a/lib/extensions/input.py
+++ b/lib/extensions/input.py
@@ -3,70 +3,13 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-import os
-import sys
-
-import inkex
from lxml import etree
-import pyembroidery
-
-from ..i18n import _
-from ..stitch_plan import StitchPlan
-from ..svg import PIXELS_PER_MM, render_stitch_plan
-from ..svg.tags import INKSCAPE_LABEL
+from ..stitch_plan import generate_stitch_plan
class Input(object):
def run(self, args):
embroidery_file = args[0]
- self.validate_file_path(embroidery_file)
-
- pattern = pyembroidery.read(embroidery_file)
- stitch_plan = StitchPlan()
- color_block = None
-
- for raw_stitches, thread in pattern.get_as_colorblocks():
- color_block = stitch_plan.new_color_block(thread)
- for x, y, command in raw_stitches:
- if command == pyembroidery.STITCH:
- color_block.add_stitch(x * PIXELS_PER_MM / 10.0, y * PIXELS_PER_MM / 10.0)
- if len(color_block) > 0:
- if command == pyembroidery.TRIM:
- color_block.add_stitch(trim=True)
- elif command == pyembroidery.STOP:
- color_block.add_stitch(stop=True)
- color_block = stitch_plan.new_color_block(thread)
-
- stitch_plan.delete_empty_color_blocks()
-
- if stitch_plan.last_color_block:
- if stitch_plan.last_color_block.last_stitch:
- if stitch_plan.last_color_block.last_stitch.stop:
- # ending with a STOP command is redundant, so remove it
- del stitch_plan.last_color_block[-1]
-
- extents = stitch_plan.extents
- svg = inkex.SvgDocumentElement("svg", nsmap=inkex.NSS, attrib={
- "width": str(extents[0] * 2),
- "height": str(extents[1] * 2),
- "viewBox": "0 0 %s %s" % (extents[0] * 2, extents[1] * 2),
- })
- render_stitch_plan(svg, stitch_plan)
-
- # rename the Stitch Plan layer so that it doesn't get overwritten by Embroider
- layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']")
- layer.set(INKSCAPE_LABEL, os.path.basename(embroidery_file))
- layer.attrib.pop('id')
-
- # Shift the design so that its origin is at the center of the canvas
- # Note: this is NOT the same as centering the design in the canvas!
- layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1]))
-
- print(etree.tostring(svg).decode('utf-8'))
-
- def validate_file_path(self, path):
- # Check if the file exists
- if not os.path.isfile(path):
- inkex.errormsg(_('File does not exist and cannot be opened. Please correct the file path and try again.\r%s') % path)
- sys.exit(1)
+ stitch_plan = generate_stitch_plan(embroidery_file)
+ print(etree.tostring(stitch_plan).decode('utf-8'))
diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py
index 312a47ce..658f2bc7 100644
--- a/lib/extensions/lettering.py
+++ b/lib/extensions/lettering.py
@@ -9,10 +9,11 @@ import sys
from base64 import b64decode
import appdirs
-import inkex
import wx
import wx.adv
+import inkex
+
from ..elements import nodes_to_elements
from ..gui import PresetsPanel, SimulatorPreview, info_dialog
from ..i18n import _
@@ -174,7 +175,7 @@ class LetteringFrame(wx.Frame):
image.Rescale(300, 20, quality=wx.IMAGE_QUALITY_HIGH)
self.font_chooser.Append(font.marked_custom_font_name, wx.Bitmap(image))
else:
- self.font_chooser.Append(font.name)
+ self.font_chooser.Append(font.marked_custom_font_name)
def get_font_descriptions(self):
return {font.name: font.description for font in self.fonts.values()}
@@ -272,12 +273,15 @@ class LetteringFrame(wx.Frame):
font.render_text(self.settings.text, destination_group, back_and_forth=self.settings.back_and_forth, trim=self.settings.trim)
except FontError as e:
if raise_error:
- inkex.errormsg("Error: Text cannot be applied to the document.\n%s" % e)
+ inkex.errormsg(_("Error: Text cannot be applied to the document.\n%s") % e)
return
else:
pass
- if self.settings.scale != 100:
+ # destination_group isn't always the text scaling group (but also the parent group)
+ # the text scaling group label is dependend on the user language, so it would break in international file exchange if we used it
+ # scaling (correction transform) on the parent group is already applied, so let's use that for recognition
+ if self.settings.scale != 100 and not destination_group.get('transform', None):
destination_group.attrib['transform'] = 'scale(%s)' % (self.settings.scale / 100.0)
def generate_patches(self, abort_early=None):
@@ -395,10 +399,10 @@ class Lettering(CommandsExtension):
self.cancelled = True
def get_or_create_group(self):
- if self.svg.selected:
+ if self.svg.selection:
groups = set()
- for node in self.svg.selected.values():
+ for node in self.svg.selection:
if node.tag == SVG_GROUP_TAG and INKSTITCH_LETTERING in node.attrib:
groups.add(node)
diff --git a/lib/extensions/lettering_force_lock_stitches.py b/lib/extensions/lettering_force_lock_stitches.py
new file mode 100644
index 00000000..62d7ae14
--- /dev/null
+++ b/lib/extensions/lettering_force_lock_stitches.py
@@ -0,0 +1,86 @@
+# 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 shapely.geometry import Point
+
+from ..i18n import _
+from ..svg import PIXELS_PER_MM
+from ..svg.tags import INKSTITCH_ATTRIBS
+from .base import InkstitchExtension
+
+
+class LetteringForceLockStitches(InkstitchExtension):
+ '''
+ This extension helps font creators to add the force lock stitches attribute to the last objects of each glyph
+ Font creators to add forced lock stitches on glyphs with accents / spaces.
+ '''
+
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-a", "--max_distance", type=float, default=3, dest="max_distance")
+ self.arg_parser.add_argument("-i", "--min_distance", type=float, default=1, dest="min_distance")
+ self.arg_parser.add_argument("-l", "--last_element", type=inkex.Boolean, dest="last_element")
+
+ def effect(self):
+ if self.options.max_distance < self.options.min_distance:
+ inkex.errormssg(_("The maximum value is smaller than the minimum value."))
+
+ # Set glyph layers to be visible. We don't want them to be ignored by self.elements
+ self._update_layer_visibility('inline')
+
+ # mark last elements of a glyph
+ xpath = ".//svg:g[@inkscape:groupmode='layer']//svg:path[last()]"
+ last_elements = self.document.xpath(xpath, namespaces=inkex.NSS)
+ for last_element in last_elements:
+ last_element.set('lastglyphelement', str(True))
+
+ # find last point of an element
+ if not self.get_elements():
+ return
+
+ previous_element = None
+ last_stitch = None
+ for element in self.elements:
+ stitch_group = element.to_stitch_groups(None)
+ # if the distance of the last stitch of the previous object to the first stitch of this objects
+ # lies within the user defined distance range, set the force_lock_stitches-attribute.
+ if last_stitch:
+ first_stitch = stitch_group[0].stitches[0]
+ first_stitch = Point(first_stitch.x, first_stitch.y)
+ self._set_force_attribute(first_stitch, last_stitch, previous_element)
+
+ # if this is the last element of a glyph, we don't want to compare it to the next element
+ if element.node.get('lastglyphelement', False):
+ previous_element = None
+ last_stitch = None
+ else:
+ previous_element = element
+ last_stitch = stitch_group[-1].stitches[-1]
+ last_stitch = Point(last_stitch.x, last_stitch.y)
+
+ # remove last element attributes again
+ # set force lock stitches attribute if needed
+ for last_element in last_elements:
+ last_element.attrib.pop('lastglyphelement')
+ if self.options.last_element:
+ last_element.set(INKSTITCH_ATTRIBS['force_lock_stitches'], True)
+
+ # hide glyph layers again
+ self._update_layer_visibility('none')
+
+ def _set_force_attribute(self, first_stitch, last_stitch, previous_element):
+ distance_mm = first_stitch.distance(last_stitch) / PIXELS_PER_MM
+
+ if distance_mm < self.options.max_distance and distance_mm > self.options.min_distance:
+ previous_element.node.set(INKSTITCH_ATTRIBS['force_lock_stitches'], True)
+
+ def _update_layer_visibility(self, display):
+ xpath = ".//svg:g[@inkscape:groupmode='layer']"
+ layers = self.document.xpath(xpath, namespaces=inkex.NSS)
+ for layer in layers:
+ display_style = 'display:%s' % display
+ style = inkex.Style(layer.get('style', '')) + inkex.Style(display_style)
+ layer.set('style', style)
diff --git a/lib/extensions/letters_to_font.py b/lib/extensions/letters_to_font.py
new file mode 100644
index 00000000..158d0d9f
--- /dev/null
+++ b/lib/extensions/letters_to_font.py
@@ -0,0 +1,81 @@
+# 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 os
+from pathlib import Path
+
+import inkex
+from inkex import errormsg
+
+from ..commands import ensure_symbol
+from ..i18n import _
+from ..stitch_plan import generate_stitch_plan
+from ..svg import get_correction_transform
+from ..svg.tags import INKSCAPE_GROUPMODE, INKSCAPE_LABEL, SVG_PATH_TAG
+from .base import InkstitchExtension
+
+
+class LettersToFont(InkstitchExtension):
+ '''
+ This extension will create a json file to store a custom directory path for additional user fonts
+ '''
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-d", "--font-dir", type=str, default="", dest="font_dir")
+ self.arg_parser.add_argument("-f", "--file-format", type=str, default="", dest="file_format")
+ self.arg_parser.add_argument("-c", "--import-commands", type=inkex.Boolean, default=False, dest="import_commands")
+
+ def effect(self):
+ font_dir = self.options.font_dir
+ file_format = self.options.file_format
+
+ if not os.path.isdir(font_dir):
+ errormsg(_("Font directory not found. Please specify an existing directory."))
+
+ glyphs = list(Path(font_dir).rglob(file_format))
+ if not glyphs:
+ glyphs = list(Path(font_dir).rglob(file_format.lower()))
+
+ document = self.document.getroot()
+ for glyph in glyphs:
+ letter = self.get_glyph_element(glyph)
+ label = "GlyphLayer-%s" % letter.get(INKSCAPE_LABEL, ' ').split('.')[0][-1]
+ group = inkex.Group(attrib={
+ INKSCAPE_LABEL: label,
+ INKSCAPE_GROUPMODE: "layer",
+ "transform": get_correction_transform(document, child=True)
+ })
+
+ # remove color block groups if we import without commands
+ # there will only be one object per color block anyway
+ if not self.options.import_commands:
+ for element in letter.iter(SVG_PATH_TAG):
+ group.insert(0, element)
+ else:
+ group.insert(0, letter)
+
+ document.insert(0, group)
+ group.set('style', 'display:none')
+
+ # users may be confused if they get an empty document
+ # make last letter visible again
+ group.set('style', None)
+
+ # In most cases trims are inserted with the imported letters.
+ # Let's make sure the trim symbol exists in the defs section
+ ensure_symbol(document, 'trim')
+
+ self.insert_baseline(document)
+
+ def get_glyph_element(self, glyph):
+ stitch_plan = generate_stitch_plan(str(glyph), self.options.import_commands)
+ # we received a stitch plan wrapped in an svg document, we only need the stitch_plan group
+ # this group carries the name of the file, so we can search for it.
+ stitch_plan = stitch_plan.xpath('.//*[@inkscape:label="%s"]' % os.path.basename(glyph), namespaces=inkex.NSS)[0]
+ stitch_plan.attrib.pop(INKSCAPE_GROUPMODE)
+ return stitch_plan
+
+ def insert_baseline(self, document):
+ document.namedview.new_guide(position=0.0, name="baseline")
diff --git a/lib/extensions/object_commands.py b/lib/extensions/object_commands.py
index 851d4a34..4d692cae 100644
--- a/lib/extensions/object_commands.py
+++ b/lib/extensions/object_commands.py
@@ -17,7 +17,7 @@ class ObjectCommands(CommandsExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select one or more objects to which to attach commands."))
return
@@ -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/object_commands_toggle_visibility.py b/lib/extensions/object_commands_toggle_visibility.py
new file mode 100644
index 00000000..569f4305
--- /dev/null
+++ b/lib/extensions/object_commands_toggle_visibility.py
@@ -0,0 +1,24 @@
+# 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 inkex import NSS
+
+from .base import InkstitchExtension
+
+
+class ObjectCommandsToggleVisibility(InkstitchExtension):
+
+ def effect(self):
+ svg = self.document.getroot()
+ # toggle object commands (in fact it's display or hide all of them)
+ command_groups = svg.xpath(".//svg:g[starts-with(@id,'command_group')]", namespaces=NSS)
+ display = "none"
+ first_iteration = True
+ for command_group in command_groups:
+ if first_iteration:
+ first_iteration = False
+ if not command_group.is_visible():
+ display = "inline"
+ command_group.style['display'] = display
diff --git a/lib/extensions/palette_split_text.py b/lib/extensions/palette_split_text.py
new file mode 100644
index 00000000..3257d694
--- /dev/null
+++ b/lib/extensions/palette_split_text.py
@@ -0,0 +1,42 @@
+# Authors: see git history
+#
+# Copyright (c) 2022 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+import inkex
+
+from ..i18n import _
+from .base import InkstitchExtension
+
+
+class PaletteSplitText(InkstitchExtension):
+ # Splits sublines of text into it's own text elements in order to color them with the color picker
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-l", "--line-height", type=int, default=6, dest="line_height")
+
+ def effect(self):
+ if not self.svg.selection:
+ inkex.errormsg(_("Please select one or more text elements to split lines."))
+ return
+
+ line_height = self.options.line_height
+
+ for text in self.svg.selection.get(inkex.elements.TextElement):
+ parent = text.getparent()
+ content = text.get_text()
+ lines = content.split('\n')
+ lines.reverse()
+ style = text.get('style')
+ x = text.get('x')
+ y = text.get('y')
+ y = float(y) + (len(lines) - 1) * line_height
+ for line in lines:
+ element = inkex.TextElement()
+ element.text = line
+ element.set('style', style)
+ element.set('x', x)
+ element.set('y', str(y))
+ y = float(y) - line_height
+ parent.insert(0, element)
+ parent.remove(text)
diff --git a/lib/extensions/palette_to_text.py b/lib/extensions/palette_to_text.py
new file mode 100644
index 00000000..0944c649
--- /dev/null
+++ b/lib/extensions/palette_to_text.py
@@ -0,0 +1,50 @@
+# Authors: see git history
+#
+# Copyright (c) 2022 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+import os
+
+import inkex
+
+from ..i18n import _
+from ..threads.palette import ThreadPalette
+from .base import InkstitchExtension
+
+
+class PaletteToText(InkstitchExtension):
+ # Generate a custom color palette in object related order
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-f", "--file", type=str, default=None, dest="file")
+ self.arg_parser.add_argument("-o", "--notebook:options", type=str, default=None, dest="page_options")
+ self.arg_parser.add_argument("-i", "--info", type=str, default=None, dest="page_help")
+
+ def effect(self):
+ palette_file = self.options.file
+ if not os.path.isfile(palette_file):
+ inkex.errormsg(_("File does not exist."))
+ return
+
+ thread_palette = ThreadPalette(palette_file)
+ current_layer = self.svg.get_current_layer()
+
+ x = 0
+ y = 0
+ pos = 0
+ for color in thread_palette:
+ line = "%s %s" % (color.name, color.number)
+ element = inkex.TextElement()
+ element.text = line
+ element.style = "fill:%s;font-size:4px;" % color.to_hex_str()
+ element.set('x', x)
+ element.set('y', str(y))
+ current_layer.insert(pos, element)
+
+ y = float(y) + 5
+ pos += 1
+
+
+if __name__ == '__main__':
+ e = PaletteToText()
+ e.affect()
diff --git a/lib/extensions/params.py b/lib/extensions/params.py
index c96b9691..b60183e5 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,21 @@ 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.Fit()
+ 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 +190,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 +227,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 +271,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 +348,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 +363,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 +424,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 +443,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 +563,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 +603,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 +630,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 +697,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 +718,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/print_pdf.py b/lib/extensions/print_pdf.py
index e5cb25d8..63c3c699 100644
--- a/lib/extensions/print_pdf.py
+++ b/lib/extensions/print_pdf.py
@@ -73,7 +73,10 @@ class PrintPreviewServer(Thread):
def __set_resources_path(self):
if getattr(sys, 'frozen', False):
- self.resources_path = os.path.join(sys._MEIPASS, 'print', 'resources')
+ if sys.platform == "darwin":
+ self.resources_path = os.path.join(sys._MEIPASS, "..", 'Resources', 'print', 'resources')
+ else:
+ self.resources_path = os.path.join(sys._MEIPASS, 'print', 'resources')
else:
self.resources_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', 'print', 'resources'))
@@ -183,7 +186,10 @@ class PrintPreviewServer(Thread):
class Print(InkstitchExtension):
def build_environment(self):
if getattr(sys, 'frozen', False):
- print_dir = os.path.join(sys._MEIPASS, "print")
+ if sys.platform == "darwin":
+ print_dir = os.path.join(sys._MEIPASS, "..", 'Resources', "print")
+ else:
+ print_dir = os.path.join(sys._MEIPASS, "print")
else:
print_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..", "print"))
@@ -281,7 +287,7 @@ class Print(InkstitchExtension):
'num_trims': stitch_plan.num_trims,
'dimensions': stitch_plan.dimensions_mm,
'num_stitches': stitch_plan.num_stitches,
- 'estimated_thread': '', # TODO
+ 'estimated_thread': stitch_plan.estimated_thread
},
svg_overview=overview_svg,
color_blocks=stitch_plan.color_blocks,
@@ -295,7 +301,7 @@ class Print(InkstitchExtension):
# objects. It's almost certain they meant to print the whole design.
# If they really wanted to print just a few objects, they could set
# the rest invisible temporarily.
- self.svg.selected.clear()
+ self.svg.selection.clear()
if not self.get_elements():
return
diff --git a/lib/extensions/remove_embroidery_settings.py b/lib/extensions/remove_embroidery_settings.py
index 223e4811..d8e6cb0e 100644
--- a/lib/extensions/remove_embroidery_settings.py
+++ b/lib/extensions/remove_embroidery_settings.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 inkex import NSS, Boolean
+from inkex import NSS, Boolean, ShapeElement
from ..commands import find_commands
from ..svg.svg import find_elements
-from ..svg.tags import EMBROIDERABLE_TAGS, SVG_GROUP_TAG
from .base import InkstitchExtension
@@ -36,7 +35,7 @@ class RemoveEmbroiderySettings(InkstitchExtension):
self.remove_element(print_setting)
def remove_params(self):
- if not self.svg.selected:
+ if not self.svg.selection:
xpath = ".//svg:path|.//svg:circle|.//svg:rect|.//svg:ellipse"
elements = find_elements(self.svg, xpath)
self.remove_inkstitch_attributes(elements)
@@ -45,23 +44,22 @@ class RemoveEmbroiderySettings(InkstitchExtension):
self.remove_inkstitch_attributes(elements)
def remove_commands(self):
- if not self.svg.selected:
- # we are not able to grab commands by a specific id
- # so let's move through every object instead and see if it has a command
- xpath = ".//svg:path|.//svg:circle|.//svg:rect|.//svg:ellipse"
- elements = find_elements(self.svg, xpath)
+ if not self.svg.selection:
+ # remove intact command groups
+ xpath = ".//svg:g[starts-with(@id,'command_group')]"
+ groups = find_elements(self.svg, xpath)
+ for group in groups:
+ group.getparent().remove(group)
else:
elements = self.get_selected_elements()
-
- if elements:
for element in elements:
for command in find_commands(element):
group = command.connector.getparent()
group.getparent().remove(group)
- if not self.svg.selected:
- # remove standalone commands
- standalone_commands = ".//svg:use[starts-with(@xlink:href, '#inkstitch_')]"
+ if not self.svg.selection:
+ # remove standalone commands and ungrouped object commands
+ standalone_commands = ".//svg:use[starts-with(@xlink:href, '#inkstitch_')]|.//svg:path[starts-with(@id, 'command_connector')]"
self.remove_elements(standalone_commands)
# let's remove the symbols (defs), we won't need them in the document
@@ -69,14 +67,7 @@ class RemoveEmbroiderySettings(InkstitchExtension):
self.remove_elements(symbols)
def get_selected_elements(self):
- elements = []
- for node in self.svg.selected.values():
- if node.tag == SVG_GROUP_TAG:
- for child in node.iterdescendants(EMBROIDERABLE_TAGS):
- elements.append(child)
- else:
- elements.append(node)
- return elements
+ return self.svg.selection.get(ShapeElement)
def remove_elements(self, xpath):
elements = find_elements(self.svg, xpath)
diff --git a/lib/extensions/reorder.py b/lib/extensions/reorder.py
index 933c1d70..956c0615 100644
--- a/lib/extensions/reorder.py
+++ b/lib/extensions/reorder.py
@@ -3,18 +3,26 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+from inkex import errormsg
+
+from ..i18n import _
from .base import InkstitchExtension
class Reorder(InkstitchExtension):
- # Remove selected objects from the document and readd them in the order they
+ # Remove selected objects from the document and re-add them in the order they
# were selected.
def effect(self):
- objects = self.get_selected_in_order()
+ objects = self.svg.selection
+
+ if not objects:
+ errormsg(_("Please select at least two elements to reorder."))
+ return
- for obj in objects[1:]:
- obj.getparent().remove(obj)
+ for obj in objects:
+ if not obj == objects.first():
+ obj.getparent().remove(obj)
insert_parent = objects[0].getparent()
insert_pos = insert_parent.index(objects[0])
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/extensions/selection_to_pattern.py b/lib/extensions/selection_to_pattern.py
index 41f89a83..8b41ff86 100644
--- a/lib/extensions/selection_to_pattern.py
+++ b/lib/extensions/selection_to_pattern.py
@@ -4,10 +4,10 @@
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
import inkex
-from lxml import etree
from ..i18n import _
-from ..svg.tags import EMBROIDERABLE_TAGS, SVG_DEFS_TAG
+from ..marker import set_marker
+from ..svg.tags import EMBROIDERABLE_TAGS
from .base import InkstitchExtension
@@ -17,47 +17,10 @@ class SelectionToPattern(InkstitchExtension):
if not self.get_elements():
return
- if not self.svg.selected:
+ if not self.svg.selection:
inkex.errormsg(_("Please select at least one object to be marked as a pattern."))
return
for pattern in self.get_nodes():
if pattern.tag in EMBROIDERABLE_TAGS:
- self.set_marker(pattern)
-
- def set_marker(self, node):
- xpath = ".//marker[@id='inkstitch-pattern-marker']"
- pattern_marker = self.document.xpath(xpath)
-
- if not pattern_marker:
- # get or create def element
- defs = self.document.find(SVG_DEFS_TAG)
- if defs is None:
- defs = etree.SubElement(self.document, SVG_DEFS_TAG)
-
- # insert marker
- marker = """<marker
- refX="10"
- refY="5"
- orient="auto"
- id="inkstitch-pattern-marker">
- <g
- id="inkstitch-pattern-group">
- <path
- style="fill:#fafafa;stroke:#ff5500;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:1, 1;stroke-dashoffset:0;stroke-opacity:1;fill-opacity:0.8;"
- d="M 10.12911,5.2916678 A 4.8374424,4.8374426 0 0 1 5.2916656,10.12911 4.8374424,4.8374426 0 0 1 0.45422399,5.2916678 4.8374424,4.8374426 0 0 1 5.2916656,0.45422399 4.8374424,4.8374426 0 0 1 10.12911,5.2916678 Z"
- id="inkstitch-pattern-marker-circle" />
- <path
- style="fill:none;stroke:#000000;stroke-width:0.4;stroke-linecap:round;stroke-miterlimit:4;"
- id="inkstitch-pattern-marker-spiral"
- d="M 4.9673651,5.7245662 C 4.7549848,5.7646159 4.6247356,5.522384 4.6430021,5.3419847 4.6765851,5.0103151 5.036231,4.835347 5.3381858,4.8987426 5.7863901,4.9928495 6.0126802,5.4853625 5.9002872,5.9065088 5.7495249,6.4714237 5.1195537,6.7504036 4.5799191,6.5874894 3.898118,6.3816539 3.5659013,5.6122905 3.7800789,4.9545192 4.0402258,4.1556558 4.9498996,3.7699484 5.7256318,4.035839 6.6416744,4.3498087 7.0810483,5.4003986 6.7631909,6.2939744 6.395633,7.3272552 5.2038143,7.8204128 4.1924535,7.4503931 3.0418762,7.0294421 2.4948761,5.6961604 2.9171752,4.567073 3.3914021,3.2991406 4.8663228,2.6982592 6.1130974,3.1729158 7.4983851,3.7003207 8.1531869,5.3169977 7.6260947,6.6814205 7.0456093,8.1841025 5.2870784,8.8928844 3.8050073,8.3132966 2.1849115,7.6797506 1.4221671,5.7793073 2.0542715,4.1796074 2.7408201,2.4420977 4.7832541,1.6253548 6.5005435,2.310012 8.3554869,3.0495434 9.2262638,5.2339874 8.4890181,7.0688861 8.4256397,7.2266036 8.3515789,7.379984 8.2675333,7.5277183" />
- </g>
- </marker>""" # noqa: E501
- defs.append(etree.fromstring(marker))
-
- # attach marker to node
- style = node.get('style') or ''
- style = style.split(";")
- style = [i for i in style if not i.startswith('marker-start')]
- style.append('marker-start:url(#inkstitch-pattern-marker)')
- node.set('style', ";".join(style))
+ set_marker(pattern, 'start', 'pattern')
diff --git a/lib/extensions/stitch_plan_preview.py b/lib/extensions/stitch_plan_preview.py
index c50fa738..e5e570fb 100644
--- a/lib/extensions/stitch_plan_preview.py
+++ b/lib/extensions/stitch_plan_preview.py
@@ -3,12 +3,23 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+from inkex import Boolean, Style
+from lxml import etree
+
from ..stitch_plan import stitch_groups_to_stitch_plan
from ..svg import render_stitch_plan
+from ..svg.tags import (INKSCAPE_GROUPMODE, SVG_DEFS_TAG, SVG_GROUP_TAG,
+ SVG_PATH_TAG)
from .base import InkstitchExtension
class StitchPlanPreview(InkstitchExtension):
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("-s", "--move-to-side", type=Boolean, default=True, dest="move_to_side")
+ self.arg_parser.add_argument("-v", "--layer-visibility", type=int, default=0, dest="layer_visibility")
+ self.arg_parser.add_argument("-n", "--needle-points", type=Boolean, default=False, dest="needle_points")
+
def effect(self):
# delete old stitch plan
svg = self.document.getroot()
@@ -27,6 +38,53 @@ class StitchPlanPreview(InkstitchExtension):
stitch_plan = stitch_groups_to_stitch_plan(patches, collapse_len=collapse_len)
render_stitch_plan(svg, stitch_plan, realistic)
- # translate stitch plan to the right side of the canvas
+ # apply options
layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']")
- layer.set('transform', 'translate(%s)' % svg.get('viewBox', '0 0 800 0').split(' ')[2])
+
+ # update layer visibility 0 = unchanged, 1 = hidden, 2 = lower opacity
+ if self.options.layer_visibility == 1:
+ self.hide_all_layers()
+ layer.set('style', None)
+ elif self.options.layer_visibility == 2:
+ for g in self.document.getroot().findall(SVG_GROUP_TAG):
+ style = g.specified_style()
+ # check groupmode and exclude stitch_plan layer
+ # exclude objects which are not displayed at all or already have opacity < 0.4
+ if (g.get(INKSCAPE_GROUPMODE) == "layer" and not g == layer and
+ float(style.get('opacity', 1)) > 0.4 and not style.get('display', 'inline') == 'none'):
+ style += Style('opacity:0.4')
+ g.set("style", style)
+
+ # translate stitch plan to the right side of the canvas
+ if self.options.move_to_side:
+ layer.set('transform', 'translate(%s)' % svg.get('viewBox', '0 0 800 0').split(' ')[2])
+ else:
+ layer.set('transform', None)
+
+ # display needle points
+ if self.options.needle_points:
+ markers = 'marker-mid:url(#inkstitch-needle-point);marker-start:url(#inkstitch-needle-point);marker-end:url(#inkstitch-needle-point)'
+ for element in layer.iterdescendants(SVG_PATH_TAG):
+ style = ';'.join([element.get('style'), markers])
+ element.set('style', style)
+ self.ensure_marker()
+
+ def ensure_marker(self):
+ xpath = ".//svg:marker[@id='inkstitch-needle-point']"
+ point_marker = self.document.getroot().xpath(xpath)
+
+ if not point_marker:
+ # get or create def element
+ defs = self.document.find(SVG_DEFS_TAG)
+ if defs is None:
+ defs = etree.SubElement(self.document, SVG_DEFS_TAG)
+
+ # insert marker
+ marker = """<marker
+ orient="auto"
+ id="inkstitch-needle-point">
+ <circle
+ cx="0" cy="0" r="1.5"
+ style="fill:context-stroke;opacity:0.8;" />
+ </marker>"""
+ defs.append(etree.fromstring(marker))
diff --git a/lib/extensions/troubleshoot.py b/lib/extensions/troubleshoot.py
index bf7faf76..f7d979e7 100644
--- a/lib/extensions/troubleshoot.py
+++ b/lib/extensions/troubleshoot.py
@@ -128,9 +128,13 @@ class Troubleshoot(InkstitchExtension):
self.warning_group = warning_group
self.type_warning_group = type_warning_group
- def add_descriptions(self, problem_types):
+ def add_descriptions(self, problem_types): # noqa: C901
svg = self.document.getroot()
- text_x = str(float(svg.get('viewBox', '0 0 800 0').split(' ')[2]) + 5.0)
+
+ # We could use svg.viewport_width, but then we would need to do unit conversions,
+ # so let's stay with parsing the viewbox by ourselves
+ # viewbox values are either separated through white space or commas
+ text_x = str(float(svg.get('viewBox', '0 0 800 0').replace(",", " ").split()[2]) + 5.0)
text_container = inkex.TextElement(attrib={
"x": text_x,
@@ -170,6 +174,8 @@ class Troubleshoot(InkstitchExtension):
text.append([problem.name, "font-weight: bold; fill: %s;" % text_color])
text.append([problem.description, "font-size: 3px;"])
text.append(["", ""])
+ if problem.steps_to_solve:
+ text.append([_("Possible solutions"), "font-weight: bold; text-decoration: underline; font-size: 4px;"])
for step in problem.steps_to_solve:
text.append([step, "font-size: 4px;"])
text.append(["", ""])
diff --git a/lib/gui/electron.py b/lib/gui/electron.py
index dcf03c6f..651080a9 100644..100755
--- a/lib/gui/electron.py
+++ b/lib/gui/electron.py
@@ -22,7 +22,7 @@ def open_url(url):
electron_path = os.path.join(get_bundled_dir("electron"), "inkstitch-gui")
if sys.platform == "darwin":
- electron_path += ".app/Contents/MacOS/inkstitch-gui"
+ electron_path = os.path.join(sys._MEIPASS, "electron", "inkstitch-gui.app", "Contents", "MacOS", "inkstitch-gui")
command = ["open", "-W", "-a", electron_path, "--args", url]
else:
command = [electron_path, url]
@@ -32,5 +32,18 @@ def open_url(url):
cwd = get_bundled_dir("electron")
# Any output on stdout will crash inkscape.
- with open(os.devnull, 'w') as null:
- return subprocess.Popen(command, cwd=cwd, stdout=null)
+ # In macos manual install the python env paths are incomplete
+ # Adding the yarn path to the env paths fixes this issue
+ if sys.platform == "darwin" and getattr(sys, 'frozen', None) is None:
+ mac_dev_env = os.environ.copy()
+ # these are paths installed by brew or macports
+ yarn_path = "/usr/local/bin:/opt/local/bin:"
+ if yarn_path in mac_dev_env["PATH"]:
+ pass
+ else:
+ mac_dev_env["PATH"] = yarn_path + mac_dev_env["PATH"]
+ with open(os.devnull, 'w') as null:
+ return subprocess.Popen(command, cwd=cwd, stdout=null, env=mac_dev_env)
+ else:
+ with open(os.devnull, 'w') as null:
+ return subprocess.Popen(command, cwd=cwd, stdout=null)
diff --git a/lib/i18n.py b/lib/i18n.py
index 37972e36..204381dc 100644
--- a/lib/i18n.py
+++ b/lib/i18n.py
@@ -30,7 +30,10 @@ def _set_locale_dir():
else:
locale_dir = dirname(dirname(realpath(__file__)))
- locale_dir = os.path.join(locale_dir, 'locales')
+ if sys.platform == "darwin":
+ locale_dir = os.path.join(locale_dir, "..", 'Resources', 'locales')
+ else:
+ locale_dir = os.path.join(locale_dir, 'locales')
def localize(languages=None):
diff --git a/lib/inx/generate.py b/lib/inx/generate.py
index cfcb224e..e94d22f6 100644
--- a/lib/inx/generate.py
+++ b/lib/inx/generate.py
@@ -7,12 +7,10 @@ from .info import generate_info_inx_files
from .extensions import generate_extension_inx_files
from .inputs import generate_input_inx_files
from .outputs import generate_output_inx_files
-from .utils import iterate_inx_locales
def generate_inx_files():
- for locale in iterate_inx_locales():
- generate_input_inx_files()
- generate_output_inx_files()
- generate_extension_inx_files()
- generate_info_inx_files()
+ generate_input_inx_files()
+ generate_output_inx_files()
+ generate_extension_inx_files()
+ generate_info_inx_files()
diff --git a/lib/inx/utils.py b/lib/inx/utils.py
index 4a62c507..31da518e 100644..100755
--- a/lib/inx/utils.py
+++ b/lib/inx/utils.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 errno
-import gettext
import os
import sys
from os.path import dirname
from jinja2 import Environment, FileSystemLoader
-from ..i18n import N_, locale_dir
-from ..i18n import translation as default_translation
-
_top_path = dirname(dirname(dirname(os.path.realpath(__file__))))
inx_path = os.path.join(_top_path, "inx")
template_path = os.path.join(_top_path, "templates")
version_path = _top_path
-current_translation = default_translation
-current_locale = "en_US"
-
def build_environment():
env = Environment(
@@ -30,9 +22,6 @@ def build_environment():
extensions=['jinja2.ext.i18n']
)
- env.install_gettext_translations(current_translation)
- env.globals["locale"] = current_locale
-
with open(os.path.join(version_path, 'LICENSE'), 'r') as license:
env.globals["inkstitch_license"] = "".join(license.readlines())
@@ -40,53 +29,26 @@ def build_environment():
# building a ZIP release, with inkstitch packaged as a binary
# About extension: add version information
with open(os.path.join(version_path, 'VERSION'), 'r') as version:
- env.globals["inkstitch_version"] = "%s %s" % (version.readline(), current_locale)
+ env.globals["inkstitch_version"] = "%s" % version.readline()
# Command tag and icons path
if sys.platform == "win32":
- env.globals["command_tag"] = '<command location="inx">inkstitch/bin/inkstitch.exe</command>'
- env.globals["image_path"] = 'inkstitch/bin/icons/'
+ env.globals["command_tag"] = '<command location="inx">../bin/inkstitch.exe</command>'
+ env.globals["image_path"] = '../bin/icons/'
elif sys.platform == "darwin":
- env.globals["command_tag"] = '<command location="inx">inkstitch.app/Contents/MacOS/inkstitch</command>'
- env.globals["image_path"] = 'inkstitch.app/Contents/MacOS/icons/'
+ env.globals["command_tag"] = '<command location="inx">../../MacOS/inkstitch</command>'
+ env.globals["image_path"] = '../../Resources/icons/'
else:
- env.globals["command_tag"] = '<command location="inx">inkstitch/bin/inkstitch</command>'
- env.globals["image_path"] = 'inkstitch/bin/icons/'
+ env.globals["command_tag"] = '<command location="inx">../bin/inkstitch</command>'
+ env.globals["image_path"] = '../bin/icons/'
else:
# user is running inkstitch.py directly as a developer
- env.globals["command_tag"] = '<command location="inx" interpreter="python">../../inkstitch.py</command>'
- env.globals["image_path"] = '../../icons/'
+ env.globals["command_tag"] = '<command location="inx" interpreter="python">../inkstitch.py</command>'
+ env.globals["image_path"] = '../icons/'
env.globals["inkstitch_version"] = "Manual Install"
return env
def write_inx_file(name, contents):
- inx_locale_dir = os.path.join(inx_path, current_locale)
-
- try:
- os.makedirs(inx_locale_dir)
- except OSError as e:
- if e.errno != errno.EEXIST:
- raise
-
inx_file_name = "inkstitch_%s.inx" % name
- with open(os.path.join(inx_locale_dir, inx_file_name), 'w', encoding="utf-8") as inx_file:
+ with open(os.path.join(inx_path, inx_file_name), 'w', encoding="utf-8") as inx_file:
print(contents, file=inx_file)
-
-
-def iterate_inx_locales():
- global current_translation, current_locale
-
- locales = sorted(os.listdir(locale_dir))
- for locale in locales:
- translation = gettext.translation("inkstitch", locale_dir, languages=[locale], fallback=True)
-
- # L10N If you translate this string, that will tell Ink/Stitch to
- # generate menu items for this language in Inkscape's "Extensions"
- # menu.
- magic_string = N_("Generate INX files")
- translated_magic_string = translation.gettext(magic_string)
-
- if translated_magic_string != magic_string or locale == "en_US":
- current_translation = translation
- current_locale = locale
- yield locale
diff --git a/lib/lettering/font.py b/lib/lettering/font.py
index ff726a56..3e601472 100644
--- a/lib/lettering/font.py
+++ b/lib/lettering/font.py
@@ -6,15 +6,19 @@
import json
import os
from copy import deepcopy
+from random import randint
import inkex
+from ..commands import ensure_symbol
from ..elements import nodes_to_elements
from ..exceptions import InkstitchException
from ..extensions.lettering_custom_font_dir import get_custom_font_dir
from ..i18n import _, get_languages
+from ..marker import MARKER, ensure_marker
from ..stitches.auto_satin import auto_satin
-from ..svg.tags import INKSCAPE_LABEL, SVG_PATH_TAG
+from ..svg.tags import (CONNECTION_END, CONNECTION_START, INKSCAPE_LABEL,
+ SVG_PATH_TAG, SVG_USE_TAG, XLINK_HREF)
from ..utils import Point
from .font_variant import FontVariant
@@ -83,14 +87,14 @@ class Font(object):
def _load_metadata(self):
try:
- with open(os.path.join(self.path, "font.json"), encoding="utf-8") as metadata_file:
+ with open(os.path.join(self.path, "font.json"), encoding="utf-8-sig") as metadata_file:
self.metadata = json.load(metadata_file)
except IOError:
pass
def _load_license(self):
try:
- with open(os.path.join(self.path, "LICENSE"), encoding="utf-8") as license_file:
+ with open(os.path.join(self.path, "LICENSE"), encoding="utf-8-sig") as license_file:
self.license = license_file.read()
except IOError:
pass
@@ -205,20 +209,19 @@ class Font(object):
# make sure font stroke styles have always a similar look
for element in destination_group.iterdescendants(SVG_PATH_TAG):
- dash_array = ""
- stroke_width = ""
- style = inkex.styles.Style(element.get('style'))
-
+ style = inkex.Style(element.get('style'))
if style.get('fill') == 'none':
- stroke_width = ";stroke-width:1px"
- if style.get('stroke-width'):
- style.pop('stroke-width')
-
+ style += inkex.Style("stroke-width:1px")
if style.get('stroke-dasharray') and style.get('stroke-dasharray') != 'none':
- stroke_width = ";stroke-width:0.5px"
- dash_array = ";stroke-dasharray:3, 1"
+ style += inkex.Style("stroke-dasharray:3, 1")
+ # Set a smaller width to auto-route running stitches
+ if self.auto_satin or element.get_id().startswith("autosatinrun"):
+ style += inkex.Style("stroke-width:0.5px")
+ element.set('style', '%s' % style.to_str())
- element.set('style', '%s%s%s' % (style.to_str(), stroke_width, dash_array))
+ # make sure necessary marker and command symbols are in the defs section
+ self._ensure_command_symbols(destination_group)
+ self._ensure_marker_symbols(destination_group)
return destination_group
@@ -303,8 +306,45 @@ class Font(object):
position.x += self.horiz_adv_x.get(character, horiz_adv_x_default) - glyph.min_x
+ self._update_commands(node, glyph)
+
return node
+ def _update_commands(self, node, glyph):
+ for element, connectors in glyph.commands.items():
+ # update element
+ el = node.find(".//*[@id='%s']" % element)
+ # we cannot get a unique id from the document at this point
+ # so let's create a random id which will most probably work as well
+ new_element_id = "%s_%s" % (element, randint(0, 9999))
+ el.set_id(new_element_id)
+ for connector, symbol in connectors:
+ # update symbol
+ new_symbol_id = "%s_%s" % (symbol, randint(0, 9999))
+ s = node.find(".//*[@id='%s']" % symbol)
+ s.set_id(new_symbol_id)
+ # update connector
+ c = node.find(".//*[@id='%s']" % connector)
+ c.set(CONNECTION_END, "#%s" % new_element_id)
+ c.set(CONNECTION_START, "#%s" % new_symbol_id)
+
+ def _ensure_command_symbols(self, group):
+ # collect commands
+ commands = set()
+ for element in group.iterdescendants(SVG_USE_TAG):
+ xlink = element.get(XLINK_HREF, ' ')
+ if xlink.startswith('#inkstitch_'):
+ commands.add(xlink[11:])
+ # make sure all necessary command symbols are in the document
+ for command in commands:
+ ensure_symbol(group.getroottree().getroot(), command)
+
+ def _ensure_marker_symbols(self, group):
+ for marker in MARKER:
+ xpath = ".//*[contains(@style, 'marker-start:url(#inkstitch-%s-marker)')]" % marker
+ if group.xpath(xpath, namespaces=inkex.NSS):
+ ensure_marker(group.getroottree().getroot(), marker)
+
def _apply_auto_satin(self, group, trim):
"""Apply Auto-Satin to an SVG XML node tree with an svg:g at its root.
@@ -312,5 +352,9 @@ class Font(object):
satin operation. Any nested svg:g elements will be removed.
"""
+ # TODO: trim option for non-auto-route
+
elements = nodes_to_elements(group.iterdescendants(SVG_PATH_TAG))
- auto_satin(elements, preserve_order=True, trim=trim)
+
+ if elements:
+ auto_satin(elements, preserve_order=True, trim=trim)
diff --git a/lib/lettering/font_variant.py b/lib/lettering/font_variant.py
index d9d8ed44..a7f353fe 100644
--- a/lib/lettering/font_variant.py
+++ b/lib/lettering/font_variant.py
@@ -7,7 +7,8 @@ import os
import inkex
-from ..svg.tags import INKSCAPE_GROUPMODE, INKSCAPE_LABEL
+from ..svg.tags import (INKSCAPE_GROUPMODE, INKSCAPE_LABEL, SVG_GROUP_TAG,
+ SVG_PATH_TAG, SVG_USE_TAG)
from .glyph import Glyph
@@ -60,7 +61,8 @@ class FontVariant(object):
def _load_glyphs(self):
svg_path = os.path.join(self.path, "%s.svg" % self.variant)
- svg = inkex.load_svg(svg_path)
+ svg = inkex.load_svg(svg_path).getroot()
+ svg = self._apply_transforms(svg)
glyph_layers = svg.xpath(".//svg:g[starts-with(@inkscape:label, 'GlyphLayer-')]", namespaces=inkex.NSS)
for layer in glyph_layers:
@@ -79,6 +81,29 @@ class FontVariant(object):
group.style.pop('display', None)
group.attrib.pop('display', None)
+ def _apply_transforms(self, svg):
+ # apply transforms to paths and use tags
+ for element in svg.iterdescendants((SVG_PATH_TAG, SVG_USE_TAG)):
+ transform = element.composed_transform()
+ if element.tag == SVG_PATH_TAG:
+ path = element.path.transform(transform)
+ element.set_path(path)
+ element.attrib.pop("transform", None)
+
+ if element.tag == SVG_USE_TAG:
+ oldx = element.get('x', 0)
+ oldy = element.get('y', 0)
+ newx, newy = transform.apply_to_point((oldx, oldy))
+ element.set('x', newx)
+ element.set('y', newy)
+ element.attrib.pop("transform", None)
+
+ # remove transforms after they have been applied
+ for group in svg.iterdescendants(SVG_GROUP_TAG):
+ group.attrib.pop('transform', None)
+
+ return svg
+
def __getitem__(self, character):
if character in self.glyphs:
return self.glyphs[character]
diff --git a/lib/lettering/glyph.py b/lib/lettering/glyph.py
index 047c12cf..fd97885b 100644
--- a/lib/lettering/glyph.py
+++ b/lib/lettering/glyph.py
@@ -5,10 +5,11 @@
from copy import copy
-from inkex import paths, transforms
+from inkex import paths, transforms, units
-from ..svg import get_guides
-from ..svg.tags import SVG_GROUP_TAG, SVG_PATH_TAG
+from ..svg import get_correction_transform, get_guides
+from ..svg.tags import (CONNECTION_END, SVG_GROUP_TAG, SVG_PATH_TAG,
+ SVG_USE_TAG, XLINK_HREF)
class Glyph(object):
@@ -38,6 +39,7 @@ class Glyph(object):
self.node = self._process_group(group)
self._process_bbox()
self._move_to_origin()
+ self._process_commands()
def _process_group(self, group):
new_group = copy(group)
@@ -50,13 +52,21 @@ class Glyph(object):
new_group.append(self._process_group(node))
else:
node_copy = copy(node)
+ transform = -transforms.Transform(get_correction_transform(node, True))
if "d" in node.attrib:
- node_copy.path = node.path.transform(node.composed_transform()).to_absolute()
-
- # Delete transforms from paths and groups, since we applied
- # them to the paths already.
- node_copy.attrib.pop('transform', None)
+ node_copy.path = node.path.transform(transform).to_absolute()
+
+ if not node.tag == SVG_USE_TAG:
+ # Delete transforms from paths and groups, since we applied
+ # them to the paths already.
+ node_copy.attrib.pop('transform', None)
+ else:
+ oldx = node.get('x', 0)
+ oldy = node.get('y', 0)
+ x, y = transform.apply_to_point((oldx, oldy))
+ node_copy.set('x', x)
+ node_copy.set('y', y)
new_group.append(node_copy)
@@ -72,11 +82,30 @@ class Glyph(object):
self.baseline = 0
def _process_bbox(self):
- bbox = [paths.Path(node.get("d")).bounding_box() for node in self.node.iterdescendants(SVG_PATH_TAG)]
+ bbox = [paths.Path(node.get("d")).bounding_box() for node in self.node.iterdescendants(SVG_PATH_TAG) if not node.get(CONNECTION_END, None)]
left, right = min([box.left for box in bbox]), max([box.right for box in bbox])
self.width = right - left
self.min_x = left
+ def _process_commands(self):
+ # Save object ids with commands in a dictionary: {object_id: [connector_id, symbol_id]}
+ self.commands = {}
+
+ for node in self.node.iter(SVG_USE_TAG):
+ xlink = node.get(XLINK_HREF, ' ')
+ if not xlink.startswith('#inkstitch_'):
+ continue
+
+ try:
+ connector = self.node.xpath(".//*[@inkscape:connection-start='#%s']" % node.get('id', ' '))[0]
+ command_object = connector.get(CONNECTION_END)[1:]
+ try:
+ self.commands[command_object].append([connector.get_id(), node.get_id()])
+ except KeyError:
+ self.commands[command_object] = [[connector.get_id(), node.get_id()]]
+ except IndexError:
+ pass
+
def _move_to_origin(self):
translate_x = -self.min_x
translate_y = -self.baseline
@@ -87,3 +116,11 @@ class Glyph(object):
path = path.transform(transform)
node.set('d', str(path))
node.attrib.pop('transform', None)
+
+ # Move commands as well
+ for node in self.node.iter(SVG_USE_TAG):
+ oldx = units.convert_unit(node.get("x", 0), 'px', node.unit)
+ oldy = units.convert_unit(node.get("y", 0), 'px', node.unit)
+ x, y = transform.apply_to_point((oldx, oldy))
+ node.set('x', x)
+ node.set('y', y)
diff --git a/lib/marker.py b/lib/marker.py
new file mode 100644
index 00000000..17d4bebd
--- /dev/null
+++ b/lib/marker.py
@@ -0,0 +1,87 @@
+# 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 copy import deepcopy
+from os import path
+
+from shapely import geometry as shgeo
+
+import inkex
+
+from .svg.tags import EMBROIDERABLE_TAGS
+from .utils import cache, get_bundled_dir
+
+MARKER = ['pattern', 'guide-line']
+
+
+def ensure_marker(svg, marker):
+ marker_path = ".//*[@id='inkstitch-%s-marker']" % marker
+ if svg.defs.find(marker_path) is None:
+ svg.defs.append(deepcopy(_marker_svg().defs.find(marker_path)))
+
+
+@cache
+def _marker_svg():
+ marker_path = path.join(get_bundled_dir("symbols"), "marker.svg")
+ with open(marker_path) as marker_file:
+ return inkex.load_svg(marker_file).getroot()
+
+
+def set_marker(node, position, marker):
+ ensure_marker(node.getroottree().getroot(), marker)
+
+ # attach marker to node
+ 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-%s-marker)' % (position, marker))
+ node.set('style', ";".join(style))
+
+
+def get_marker_elements(node, marker, get_fills=True, get_strokes=True, get_satins=False):
+ from .elements import EmbroideryElement
+ from .elements.satin_column import SatinColumn
+ from .elements.stroke import Stroke
+
+ fills = []
+ strokes = []
+ satins = []
+ 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))
+
+ if get_satins and stroke is not None:
+ satin = SatinColumn(marker)
+ if len(satin.rails) == 2:
+ satins.append(satin)
+
+ return {'fill': fills, 'stroke': strokes, 'satin': satins}
+
+
+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 8a0c8449..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_stroke_patterns(patterns['stroke_patterns'], patches)
- _apply_fill_patterns(patterns['fill_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,42 +56,13 @@ def _apply_fill_patterns(patterns, patches):
patch.stitches = patch_points
-def _get_patterns(node):
- from .elements import EmbroideryElement
- from .elements.fill import Fill
- 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 = Fill(pattern).shape
- fills.append(fill_pattern)
-
- 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/stitch_plan/__init__.py b/lib/stitch_plan/__init__.py
index d4b43ace..9764e66a 100644
--- a/lib/stitch_plan/__init__.py
+++ b/lib/stitch_plan/__init__.py
@@ -3,8 +3,9 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-from .stitch_plan import stitch_groups_to_stitch_plan, StitchPlan
from .color_block import ColorBlock
-from .stitch_group import StitchGroup
-from .stitch import Stitch
+from .generate_stitch_plan import generate_stitch_plan
from .read_file import stitch_plan_from_file
+from .stitch import Stitch
+from .stitch_group import StitchGroup
+from .stitch_plan import StitchPlan, stitch_groups_to_stitch_plan
diff --git a/lib/stitch_plan/color_block.py b/lib/stitch_plan/color_block.py
index 86edaff2..cd7b9c6d 100644
--- a/lib/stitch_plan/color_block.py
+++ b/lib/stitch_plan/color_block.py
@@ -63,6 +63,15 @@ class ColorBlock(object):
return len(self.stitches)
@property
+ def estimated_thread(self):
+ previous_stitch = self.stitches[0]
+ length = 0
+ for stitch in self.stitches[1:]:
+ length += (stitch - previous_stitch).length()
+ previous_stitch = stitch
+ return length
+
+ @property
def num_trims(self):
"""Number of trims in this color block."""
@@ -119,7 +128,7 @@ class ColorBlock(object):
raise ValueError("internal error: can't add a command to an empty stitch block")
self.stitches.append(Stitch(*args, **kwargs))
if isinstance(args[0], Stitch):
- self.stitches.append(args[0])
+ self.stitches.append(Stitch(*args, **kwargs))
elif isinstance(args[0], Point):
self.stitches.append(Stitch(args[0].x, args[0].y, *args[1:], **kwargs))
diff --git a/lib/stitch_plan/generate_stitch_plan.py b/lib/stitch_plan/generate_stitch_plan.py
new file mode 100644
index 00000000..2d8ceeff
--- /dev/null
+++ b/lib/stitch_plan/generate_stitch_plan.py
@@ -0,0 +1,74 @@
+# 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 os
+import sys
+
+import inkex
+
+import pyembroidery
+
+from ..i18n import _
+from ..svg import PIXELS_PER_MM, render_stitch_plan
+from ..svg.tags import INKSCAPE_LABEL
+from .stitch import Stitch
+from .stitch_plan import StitchPlan
+
+
+def generate_stitch_plan(embroidery_file, import_commands=True): # noqa: C901
+ validate_file_path(embroidery_file)
+ pattern = pyembroidery.read(embroidery_file)
+ stitch_plan = StitchPlan()
+ color_block = None
+
+ for raw_stitches, thread in pattern.get_as_colorblocks():
+ color_block = stitch_plan.new_color_block(thread)
+ for x, y, command in raw_stitches:
+ if command == pyembroidery.STITCH:
+ color_block.add_stitch(Stitch(x * PIXELS_PER_MM / 10.0, y * PIXELS_PER_MM / 10.0))
+ if len(color_block) > 0:
+ if not import_commands and command in [pyembroidery.TRIM, pyembroidery.STOP]:
+ # Importing commands is not wanted:
+ # start a new color block without inserting the command
+ color_block = stitch_plan.new_color_block(thread)
+ elif command == pyembroidery.TRIM:
+ color_block.add_stitch(trim=True)
+ elif command == pyembroidery.STOP:
+ color_block.add_stitch(stop=True)
+ color_block = stitch_plan.new_color_block(thread)
+
+ stitch_plan.delete_empty_color_blocks()
+
+ if stitch_plan.last_color_block:
+ if stitch_plan.last_color_block.last_stitch:
+ if stitch_plan.last_color_block.last_stitch.stop:
+ # ending with a STOP command is redundant, so remove it
+ del stitch_plan.last_color_block[-1]
+
+ extents = stitch_plan.extents
+ svg = inkex.SvgDocumentElement("svg", nsmap=inkex.NSS, attrib={
+ "width": str(extents[0] * 2),
+ "height": str(extents[1] * 2),
+ "viewBox": "0 0 %s %s" % (extents[0] * 2, extents[1] * 2),
+ })
+ render_stitch_plan(svg, stitch_plan)
+
+ # rename the Stitch Plan layer so that it doesn't get overwritten by Embroider
+ layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']")
+ layer.set(INKSCAPE_LABEL, os.path.basename(embroidery_file))
+ layer.attrib.pop('id')
+
+ # Shift the design so that its origin is at the center of the canvas
+ # Note: this is NOT the same as centering the design in the canvas!
+ layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1]))
+
+ return svg
+
+
+def validate_file_path(path):
+ # Check if the file exists
+ if not os.path.isfile(path):
+ inkex.errormsg(_('File does not exist and cannot be opened. Please correct the file path and try again.\r%s') % path)
+ sys.exit(1)
diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py
index f163d09c..a4c50b60 100644
--- a/lib/stitch_plan/stitch.py
+++ b/lib/stitch_plan/stitch.py
@@ -10,7 +10,8 @@ from copy import deepcopy
class Stitch(Point):
"""A stitch is a Point with extra information telling how to sew it."""
- def __init__(self, x, y=None, color=None, jump=False, stop=False, trim=False, color_change=False, tie_modus=0, no_ties=False, tags=None):
+ def __init__(self, x, y=None, color=None, jump=False, stop=False, trim=False, color_change=False,
+ tie_modus=0, force_lock_stitches=False, no_ties=False, tags=None):
if isinstance(x, Stitch):
# Allow creating a Stitch from another Stitch. Attributes passed as
# arguments will override any existing attributes.
@@ -28,6 +29,7 @@ class Stitch(Point):
self.trim = trim
self.stop = stop
self.color_change = color_change
+ self.force_lock_stitches = force_lock_stitches
self.tie_modus = tie_modus
self.no_ties = no_ties
self.tags = set()
@@ -35,15 +37,16 @@ class Stitch(Point):
self.add_tags(tags or [])
def __repr__(self):
- return "Stitch(%s, %s, %s, %s, %s, %s, %s, %s, %s)" % (self.x,
- self.y,
- self.color,
- "JUMP" if self.jump else " ",
- "TRIM" if self.trim else " ",
- "STOP" if self.stop else " ",
- "TIE MODUS" if self.tie_modus else " ",
- "NO TIES" if self.no_ties else " ",
- "COLOR CHANGE" if self.color_change else " ")
+ return "Stitch(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" % (self.x,
+ self.y,
+ self.color,
+ "JUMP" if self.jump else " ",
+ "TRIM" if self.trim else " ",
+ "STOP" if self.stop else " ",
+ "TIE MODUS" if self.tie_modus else " ",
+ "FORCE LOCK STITCHES" if self.force_lock_stitches else " ",
+ "NO TIES" if self.no_ties else " ",
+ "COLOR CHANGE" if self.color_change else " ")
def add_tags(self, tags):
for tag in tags:
@@ -68,7 +71,8 @@ class Stitch(Point):
return tag in self.tags
def copy(self):
- return Stitch(self.x, self.y, self.color, self.jump, self.stop, self.trim, self.color_change, self.tie_modus, self.no_ties, self.tags)
+ return Stitch(self.x, self.y, self.color, self.jump, self.stop, self.trim, self.color_change,
+ self.tie_modus, self.force_lock_stitches, self.no_ties, self.tags)
def __json__(self):
attributes = dict(vars(self))
diff --git a/lib/stitch_plan/stitch_group.py b/lib/stitch_plan/stitch_group.py
index 98d9799e..21beebe1 100644
--- a/lib/stitch_plan/stitch_group.py
+++ b/lib/stitch_plan/stitch_group.py
@@ -17,11 +17,13 @@ class StitchGroup:
between them by the stitch plan generation code.
"""
- def __init__(self, color=None, stitches=None, trim_after=False, stop_after=False, tie_modus=0, stitch_as_is=False, tags=None):
+ def __init__(self, color=None, stitches=None, trim_after=False, stop_after=False,
+ tie_modus=0, force_lock_stitches=False, stitch_as_is=False, tags=None):
self.color = color
self.trim_after = trim_after
self.stop_after = stop_after
self.tie_modus = tie_modus
+ self.force_lock_stitches = force_lock_stitches
self.stitch_as_is = stitch_as_is
self.stitches = []
diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py
index 7e7621c1..4593781a 100644
--- a/lib/stitch_plan/stitch_plan.py
+++ b/lib/stitch_plan/stitch_plan.py
@@ -3,9 +3,14 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-from .ties import add_ties
-from .color_block import ColorBlock
+from sys import exit
+
+from inkex import errormsg
+
+from ..i18n import _
from ..svg import PIXELS_PER_MM
+from .color_block import ColorBlock
+from .ties import add_ties
def stitch_groups_to_stitch_plan(stitch_groups, collapse_len=None, disable_ties=False): # noqa: C901
@@ -17,6 +22,11 @@ def stitch_groups_to_stitch_plan(stitch_groups, collapse_len=None, disable_ties=
* adds jump-stitches between stitch_group if necessary
"""
+ if not stitch_groups:
+ errormsg(_("There is no selected stitchable element. Please run "
+ "Extensions > Ink/Stitch > Troubleshoot > Troubleshoot objects in case you have expected a stitchout."))
+ exit(1)
+
if collapse_len is None:
collapse_len = 3.0
collapse_len = collapse_len * PIXELS_PER_MM
@@ -40,12 +50,15 @@ def stitch_groups_to_stitch_plan(stitch_groups, collapse_len=None, disable_ties=
color_block = stitch_plan.new_color_block(color=stitch_group.color)
# always start a color with a JUMP to the first stitch position
- color_block.add_stitch(stitch_group.stitches[0], jump=True)
+ color_block.add_stitch(stitch_group.stitches[0], jump=True, tie_modus=stitch_group.tie_modus)
else:
- if len(color_block) and (stitch_group.stitches[0] - color_block.stitches[-1]).length() > collapse_len:
- color_block.add_stitch(stitch_group.stitches[0], jump=True)
+ if (len(color_block) and
+ ((stitch_group.stitches[0] - color_block.stitches[-1]).length() > collapse_len or
+ color_block.stitches[-1].force_lock_stitches)):
+ color_block.add_stitch(stitch_group.stitches[0], jump=True, tie_modus=stitch_group.tie_modus)
- color_block.add_stitches(stitches=stitch_group.stitches, tie_modus=stitch_group.tie_modus, no_ties=stitch_group.stitch_as_is)
+ color_block.add_stitches(stitches=stitch_group.stitches, tie_modus=stitch_group.tie_modus,
+ force_lock_stitches=stitch_group.force_lock_stitches, no_ties=stitch_group.stitch_as_is)
if stitch_group.trim_after:
color_block.add_stitch(trim=True)
@@ -110,7 +123,8 @@ class StitchPlan(object):
num_stops=self.num_stops,
num_trims=self.num_trims,
num_stitches=self.num_stitches,
- bounding_box=self.bounding_box
+ bounding_box=self.bounding_box,
+ estimated_thread=self.estimated_thread
)
@property
@@ -145,6 +159,11 @@ class StitchPlan(object):
return minx, miny, maxx, maxy
@property
+ def estimated_thread(self):
+ thread_meter = sum(block.estimated_thread for block in self) / PIXELS_PER_MM / 1000
+ return round(thread_meter, 2)
+
+ @property
def dimensions(self):
minx, miny, maxx, maxy = self.bounding_box
return (maxx - minx, maxy - miny)
diff --git a/lib/stitch_plan/ties.py b/lib/stitch_plan/ties.py
index c649ee44..a95f9805 100644
--- a/lib/stitch_plan/ties.py
+++ b/lib/stitch_plan/ties.py
@@ -37,7 +37,7 @@ def add_tie(stitches, tie_path):
def add_tie_off(stitches):
# tie_modus: 0 = both | 1 = before | 2 = after | 3 = neither
- if stitches[-1].tie_modus not in [1, 3]:
+ if stitches[-1].tie_modus not in [1, 3] or stitches[-1].force_lock_stitches:
add_tie(stitches, stitches[-1:-3:-1])
diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py
index 4de88733..b0ff64fc 100644
--- a/lib/stitches/__init__.py
+++ b/lib/stitches/__init__.py
@@ -5,7 +5,9 @@
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 :(
-#from auto_satin import auto_satin
+# from .auto_satin import auto_satin
+# from .ripple_stitch import ripple_stitch
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..ab5762a3
--- /dev/null
+++ b/lib/stitches/auto_run.py
@@ -0,0 +1,293 @@
+# 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-Route"))
+ 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']
+ # create a new element if we hit an other original element to keep it's properties
+ if el and element and el != element and d and not direction == 'underpath':
+ element_list.append(create_element(d, position, path_direction, el))
+ original_parents.append(el.node.getparent())
+ d = ""
+ position += 1
+ 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
+
+ el_id = "%s_%s_" % (direction, position)
+
+ index = position + 1
+ if direction == "autorun":
+ label = _("AutoRun %d") % index
+ else:
+ label = _("AutoRun Underpath %d") % index
+
+ 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", element.node.style)
+
+ # Set Ink/Stitch attributes
+ stitch_length = element.node.get(INKSTITCH_ATTRIBS['running_stitch_length_mm'], '')
+ repeats = int(element.node.get(INKSTITCH_ATTRIBS['repeats'], 1))
+ if repeats % 2 == 0:
+ repeats -= 1
+
+ if direction == "autorun":
+ for attrib in element.node.attrib:
+ if attrib.startswith(inkex.NSS['inkstitch'], 1):
+ if attrib == INKSTITCH_ATTRIBS['repeats']:
+ node.set(INKSTITCH_ATTRIBS['repeats'], str(repeats))
+ else:
+ node.set(attrib, element.node.get(attrib))
+ else:
+ if stitch_length:
+ node.set(INKSTITCH_ATTRIBS['running_stitch_length_mm'], stitch_length)
+ node.set("style", element.node.style + inkex.Style("stroke-dasharray:0.5,0.5;fill:none;"))
+ 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..93bffd57 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.
@@ -697,6 +507,7 @@ def name_elements(new_elements, preserve_order):
for element in new_elements:
if isinstance(element, SatinColumn):
element.node.set("id", generate_unique_id(element.node, "autosatin"))
+ _ensure_even_repeats(element)
else:
element.node.set("id", generate_unique_id(element.node, "autosatinrun"))
@@ -704,6 +515,7 @@ def name_elements(new_elements, preserve_order):
if isinstance(element, SatinColumn):
# L10N Label for a satin column created by Auto-Route Satin Columns and Lettering extensions
element.node.set(INKSCAPE_LABEL, _("AutoSatin %d") % index)
+ _ensure_even_repeats(element)
else:
# L10N Label for running stitch (underpathing) created by Auto-Route Satin Columns amd Lettering extensions
element.node.set(INKSCAPE_LABEL, _("AutoSatin Running Stitch %d") % index)
@@ -711,6 +523,14 @@ def name_elements(new_elements, preserve_order):
index += 1
+def _ensure_even_repeats(element):
+ # center underlay can have an odd number of repeats, this would cause jumps in auto route satin
+ # so let's set it to an even number of repeats, but not lower than 2
+ if int(element.node.get(INKSTITCH_ATTRIBS['center_walk_underlay_repeats'], 2)) % 2 == 1:
+ repeats = max(int(element.node.get(INKSTITCH_ATTRIBS['center_walk_underlay_repeats'])) - 1, 2)
+ element.node.set(INKSTITCH_ATTRIBS['center_walk_underlay_repeats'], repeats)
+
+
def add_trims(elements, trim_indices):
"""Add trim commands on the specified elements.
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 d134be32..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:
@@ -143,8 +141,8 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non
res = grating_line.intersection(shape)
- if (isinstance(res, shapely.geometry.MultiLineString)):
- runs = [line_string.coords for line_string in res.geoms]
+ if (isinstance(res, shapely.geometry.MultiLineString) or isinstance(res, shapely.geometry.GeometryCollection)):
+ runs = [line_string.coords for line_string in res.geoms if isinstance(line_string, shapely.geometry.LineString)]
else:
if res.is_empty or len(res.coords) == 1:
# ignore if we intersected at a single point or no points
@@ -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/ripple_stitch.py b/lib/stitches/ripple_stitch.py
new file mode 100644
index 00000000..46fc5e07
--- /dev/null
+++ b/lib/stitches/ripple_stitch.py
@@ -0,0 +1,263 @@
+from collections import defaultdict
+from math import atan2
+
+import numpy as np
+from shapely.affinity import rotate, scale, translate
+from shapely.geometry import LineString, Point
+
+from .running_stitch import running_stitch
+from ..elements import SatinColumn
+from ..utils import Point as InkstitchPoint
+from ..utils.geometry import line_string_to_point_list
+
+
+def ripple_stitch(stroke):
+ '''
+ Ripple stitch is allowed to cross itself and doesn't care about an equal distance of lines
+ It is meant to be used with light (not dense) stitching
+ It will ignore holes in a closed shape. Closed shapes will be filled with a spiral
+ Open shapes will be stitched back and forth.
+ If there is only one (open) line or a closed shape the target point will be used.
+ If more sublines are present interpolation will take place between the first two.
+ '''
+
+ is_linear, helper_lines = _get_helper_lines(stroke)
+ ripple_points = _do_ripple(stroke, helper_lines, is_linear)
+
+ if stroke.reverse:
+ ripple_points.reverse()
+
+ if stroke.grid_size != 0:
+ ripple_points.extend(_do_grid(stroke, helper_lines))
+
+ stitches = running_stitch(ripple_points, stroke.running_stitch_length)
+
+ return _repeat_coords(stitches, stroke.repeats)
+
+
+def _do_ripple(stroke, helper_lines, is_linear):
+ points = []
+
+ for point_num in range(stroke.get_skip_start(), len(helper_lines[0]) - stroke.get_skip_end()):
+ row = []
+ for line_num in range(len(helper_lines)):
+ row.append(helper_lines[line_num][point_num])
+
+ if is_linear and point_num % 2 == 1:
+ # reverse every other row in linear ripple
+ row.reverse()
+
+ points.extend(row)
+
+ return points
+
+
+def _get_helper_lines(stroke):
+ lines = stroke.as_multi_line_string().geoms
+ if len(lines) > 1:
+ return True, _get_satin_ripple_helper_lines(stroke)
+ else:
+ outline = LineString(running_stitch(line_string_to_point_list(lines[0]), stroke.grid_size or stroke.running_stitch_length))
+
+ if stroke.is_closed:
+ return False, _get_circular_ripple_helper_lines(stroke, outline)
+ else:
+ return True, _get_linear_ripple_helper_lines(stroke, outline)
+
+
+def _get_satin_ripple_helper_lines(stroke):
+ # if grid_size has a number use this, otherwise use running_stitch_length
+ length = stroke.grid_size or stroke.running_stitch_length
+
+ # use satin column points for satin like build ripple stitches
+ rail_points = SatinColumn(stroke.node).plot_points_on_rails(length, 0)
+
+ steps = _get_steps(stroke.line_count, exponent=stroke.exponent, flip=stroke.flip_exponent)
+
+ helper_lines = []
+ for point0, point1 in zip(*rail_points):
+ helper_lines.append([])
+ helper_line = LineString((point0, point1))
+ for step in steps:
+ helper_lines[-1].append(InkstitchPoint.from_shapely_point(helper_line.interpolate(step, normalized=True)))
+
+ return helper_lines
+
+
+def _get_circular_ripple_helper_lines(stroke, outline):
+ helper_lines = _get_linear_ripple_helper_lines(stroke, outline)
+
+ # Now we want to adjust the helper lines to make a spiral.
+ num_lines = len(helper_lines)
+ steps = _get_steps(num_lines)
+ for i, line in enumerate(helper_lines):
+ points = []
+ for j in range(len(line) - 1):
+ points.append(line[j] * (1 - steps[i]) + line[j + 1] * steps[i])
+ helper_lines[i] = points
+
+ return helper_lines
+
+
+def _get_linear_ripple_helper_lines(stroke, outline):
+ guide_line = stroke.get_guide_line()
+ max_dist = stroke.grid_size or stroke.running_stitch_length
+
+ if guide_line:
+ return _get_guided_helper_lines(stroke, outline, max_dist)
+ else:
+ return _target_point_helper_lines(stroke, outline)
+
+
+def _target_point_helper_lines(stroke, outline):
+ helper_lines = [[] for i in range(len(outline.coords))]
+ target = stroke.get_ripple_target()
+ steps = _get_steps(stroke.get_line_count(), exponent=stroke.exponent, flip=stroke.flip_exponent)
+ for i, point in enumerate(outline.coords):
+ line = LineString([point, target])
+
+ for step in steps:
+ helper_lines[i].append(InkstitchPoint.from_shapely_point(line.interpolate(step, normalized=True)))
+
+ return helper_lines
+
+
+def _do_grid(stroke, helper_lines):
+ for i, helper in enumerate(helper_lines):
+ start = stroke.get_skip_start()
+ end = len(helper) - stroke.get_skip_end()
+ points = helper[start:end]
+ if i % 2 == 0:
+ points.reverse()
+ yield from points
+
+
+def _get_guided_helper_lines(stroke, outline, max_distance):
+ # for each point generate a line going along and pointing to the guide line
+ guide_line = stroke.get_guide_line()
+ if isinstance(guide_line, SatinColumn):
+ # satin type guide line
+ return _generate_satin_guide_helper_lines(stroke, outline, guide_line)
+ else:
+ # simple guide line
+ return _generate_guided_helper_lines(stroke, outline, max_distance, guide_line.geoms[0])
+
+
+def _generate_guided_helper_lines(stroke, outline, max_distance, guide_line):
+ # helper lines are generated by making copies of the outline alog the guide line
+ line_point_dict = defaultdict(list)
+ outline = LineString(running_stitch(line_string_to_point_list(outline), max_distance))
+
+ center = outline.centroid
+ center = InkstitchPoint(center.x, center.y)
+
+ outline_steps = _get_steps(stroke.get_line_count(), exponent=stroke.exponent, flip=stroke.flip_exponent)
+ scale_steps = _get_steps(stroke.get_line_count(), start=stroke.scale_start / 100.0, end=stroke.scale_end / 100.0)
+
+ start_point = InkstitchPoint(*(guide_line.coords[0]))
+ start_rotation = _get_start_rotation(guide_line)
+
+ previous_guide_point = None
+ for i in range(stroke.get_line_count()):
+ guide_point = InkstitchPoint.from_shapely_point(guide_line.interpolate(outline_steps[i], normalized=True))
+ translation = guide_point - start_point
+ scaling = scale_steps[i]
+ if stroke.rotate_ripples and previous_guide_point:
+ rotation = atan2(guide_point.y - previous_guide_point.y, guide_point.x - previous_guide_point.x)
+ rotation = rotation - start_rotation
+ else:
+ rotation = 0
+ transformed_outline = _transform_outline(translation, rotation, scaling, outline, Point(guide_point), stroke.scale_axis)
+
+ for j, point in enumerate(transformed_outline.coords):
+ line_point_dict[j].append(InkstitchPoint(point[0], point[1]))
+
+ previous_guide_point = guide_point
+
+ return _point_dict_to_helper_lines(len(outline.coords), line_point_dict)
+
+
+def _get_start_rotation(line):
+ point0 = line.interpolate(0)
+ point1 = line.interpolate(0.1)
+
+ return atan2(point1.y - point0.y, point1.x - point0.x)
+
+
+def _generate_satin_guide_helper_lines(stroke, outline, guide_line):
+ spacing = guide_line.center_line.length / (stroke.get_line_count() - 1)
+ rail_points = guide_line.plot_points_on_rails(spacing, 0)
+
+ point0 = rail_points[0][0]
+ point1 = rail_points[1][0]
+ start_rotation = atan2(point1.y - point0.y, point1.x - point0.x)
+ start_scale = (point1 - point0).length()
+ outline_center = InkstitchPoint.from_shapely_point(outline.centroid)
+
+ line_point_dict = defaultdict(list)
+
+ # add scaled and rotated outlines along the satin column guide line
+ for i, (point0, point1) in enumerate(zip(*rail_points)):
+ guide_center = (point0 + point1) / 2
+ translation = guide_center - outline_center
+ if stroke.rotate_ripples:
+ rotation = atan2(point1.y - point0.y, point1.x - point0.x)
+ rotation = rotation - start_rotation
+ else:
+ rotation = 0
+ scaling = (point1 - point0).length() / start_scale
+
+ transformed_outline = _transform_outline(translation, rotation, scaling, outline, Point(guide_center), stroke.scale_axis)
+
+ # outline to helper line points
+ for j, point in enumerate(transformed_outline.coords):
+ line_point_dict[j].append(InkstitchPoint(point[0], point[1]))
+
+ return _point_dict_to_helper_lines(len(outline.coords), line_point_dict)
+
+
+def _transform_outline(translation, rotation, scaling, outline, origin, scale_axis):
+ # transform
+ transformed_outline = translate(outline, translation.x, translation.y)
+ # rotate
+ if rotation != 0:
+ transformed_outline = rotate(transformed_outline, rotation, use_radians=True, origin=origin)
+ # scale | scale_axis => 0: xy, 1: x, 2: y, 3: none
+ scale_x = scale_y = scaling
+ if scale_axis in [2, 3]:
+ scale_x = 1
+ if scale_axis in [1, 3]:
+ scale_y = 1
+ transformed_outline = scale(transformed_outline, scale_x, scale_y, origin=origin)
+ return transformed_outline
+
+
+def _point_dict_to_helper_lines(line_count, point_dict):
+ lines = []
+ for i in range(line_count):
+ points = point_dict[i]
+ lines.append(points)
+ return lines
+
+
+def _get_steps(num_steps, start=0.0, end=1.0, exponent=1, flip=False):
+ steps = np.linspace(start, end, num_steps)
+ steps = steps ** exponent
+
+ if flip:
+ steps = 1.0 - np.flip(steps)
+
+ return list(steps)
+
+
+def _repeat_coords(coords, repeats):
+ final_coords = []
+ for i in range(repeats):
+ if i % 2 == 1:
+ # reverse every other pass
+ this_coords = coords[::-1]
+ else:
+ this_coords = coords[:]
+
+ final_coords.extend(this_coords)
+ return final_coords
diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py
index 2878480c..98d080ba 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,52 @@ 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]
+ if not output or output[-1] != section[0]:
+ 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
+
+ if points[-1] != output[-1]:
+ 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/guides.py b/lib/svg/guides.py
index e492a90d..3329940e 100644
--- a/lib/svg/guides.py
+++ b/lib/svg/guides.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 inkex import transforms
+from inkex.units import convert_unit
from ..utils import Point, cache, string_to_floats
from .tags import INKSCAPE_LABEL, SODIPODI_GUIDE, SODIPODI_NAMEDVIEW
-from .units import get_doc_size, get_viewbox_transform
class InkscapeGuide(object):
@@ -20,16 +19,15 @@ class InkscapeGuide(object):
def _parse(self):
self.label = self.node.get(INKSCAPE_LABEL, "")
- doc_size = list(get_doc_size(self.svg))
-
- # convert the size from viewbox-relative to real-world pixels
- viewbox_transform = get_viewbox_transform(self.svg)
- viewbox_transform = transforms.Transform(-transforms.Transform(viewbox_transform)).apply_to_point(doc_size)
+ doc_size = self.svg.get_page_bbox()
+ # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates
self.position = Point(*string_to_floats(self.node.get('position')))
+ self.position.y = doc_size.y.size - self.position.y
- # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates
- self.position.y = doc_size[1] - self.position.y
+ # convert units to px
+ unit = self.svg.unit
+ self.position.y = convert_unit(self.position.y, 'px', unit)
# This one baffles me. I think inkscape might have gotten the order of
# their vector wrong?
diff --git a/lib/svg/path.py b/lib/svg/path.py
index 53cf80f2..6c2cbe35 100644
--- a/lib/svg/path.py
+++ b/lib/svg/path.py
@@ -24,7 +24,7 @@ def compose_parent_transforms(node, mat):
trans = node.get('transform')
if trans:
- mat = inkex.transforms.Transform(trans) * mat
+ mat = inkex.transforms.Transform(trans) @ mat
if node.getparent() is not None:
if node.getparent().tag in [SVG_GROUP_TAG, SVG_LINK_TAG]:
mat = compose_parent_transforms(node.getparent(), mat)
@@ -47,7 +47,7 @@ def get_node_transform(node):
# add in the transform implied by the viewBox
viewbox_transform = get_viewbox_transform(node.getroottree().getroot())
- transform = viewbox_transform * transform
+ transform = viewbox_transform @ transform
return transform
@@ -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 5c1d892a..f0dc69bc 100644
--- a/lib/svg/tags.py
+++ b/lib/svg/tags.py
@@ -3,14 +3,16 @@
# 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'
SVG_PATH_TAG = inkex.addNS('path', 'svg')
SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg')
+SVG_POLYGON_TAG = inkex.addNS('polygon', 'svg')
SVG_RECT_TAG = inkex.addNS('rect', 'svg')
SVG_ELLIPSE_TAG = inkex.addNS('ellipse', 'svg')
SVG_CIRCLE_TAG = inkex.addNS('circle', 'svg')
@@ -22,12 +24,15 @@ SVG_LINK_TAG = inkex.addNS('a', 'svg')
SVG_SYMBOL_TAG = inkex.addNS('symbol', 'svg')
SVG_USE_TAG = inkex.addNS('use', 'svg')
SVG_IMAGE_TAG = inkex.addNS('image', 'svg')
+SVG_CLIPPATH_TAG = inkex.addNS('clipPath', 'svg')
+SVG_MASK_TAG = inkex.addNS('mask', 'svg')
INKSCAPE_LABEL = inkex.addNS('label', 'inkscape')
INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape')
CONNECTION_START = inkex.addNS('connection-start', 'inkscape')
CONNECTION_END = inkex.addNS('connection-end', 'inkscape')
CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape')
+INKSCAPE_DOCUMENT_UNITS = inkex.addNS('document-units', 'inkscape')
XLINK_HREF = inkex.addNS('href', 'xlink')
@@ -37,60 +42,80 @@ SODIPODI_ROLE = inkex.addNS('role', 'sodipodi')
INKSTITCH_LETTERING = inkex.addNS('lettering', 'inkstitch')
-EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG, SVG_RECT_TAG, SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG)
+EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG, SVG_POLYGON_TAG,
+ SVG_RECT_TAG, SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG)
NOT_EMBROIDERABLE_TAGS = (SVG_IMAGE_TAG, SVG_TEXT_TAG)
SVG_OBJECT_TAGS = (SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG, SVG_RECT_TAG)
INKSTITCH_ATTRIBS = {}
inkstitch_attribs = [
- 'ties',
- # 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',
- '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',
- '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',
+ 'line_count',
+ 'skip_start',
+ 'skip_end',
+ 'grid_size',
+ 'reverse',
+ 'exponent',
+ 'flip_exponent',
+ 'scale_axis',
+ 'scale_start',
+ 'scale_end',
+ 'rotate_ripples',
+ '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
+ 'stroke_method',
+ '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',
+ 'center_walk_underlay_repeats',
+ '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',
+ 'manual_stitch',
+]
for attrib in inkstitch_attribs:
INKSTITCH_ATTRIBS[attrib] = inkex.addNS(attrib, 'inkstitch')
diff --git a/lib/svg/units.py b/lib/svg/units.py
index f229c2c3..e8a21c18 100644
--- a/lib/svg/units.py
+++ b/lib/svg/units.py
@@ -149,7 +149,7 @@ def get_viewbox_transform(node):
sx = sy = max(sx, sy) if 'slice' in aspect_ratio else min(sx, sy)
scale_transform = inkex.transforms.Transform("scale(%f, %f)" % (sx, sy))
- transform = transform * scale_transform
+ transform = transform @ scale_transform
except ZeroDivisionError:
pass
diff --git a/lib/threads/color.py b/lib/threads/color.py
index 69a70798..4a2f58e6 100644
--- a/lib/threads/color.py
+++ b/lib/threads/color.py
@@ -15,6 +15,10 @@ class ThreadColor(object):
hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I)
def __init__(self, color, name=None, number=None, manufacturer=None):
+ # set colors with a gradient to black (avoiding an error message)
+ if type(color) == str and color.startswith('url'):
+ color = None
+
if color is None:
self.rgb = (0, 0, 0)
elif isinstance(color, EmbThread):
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..8d29ddb0 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.
@@ -69,6 +125,10 @@ class Point:
self.x = x
self.y = y
+ @classmethod
+ def from_shapely_point(cls, point):
+ return cls(point.x, point.y)
+
def __json__(self):
return vars(self)
@@ -99,12 +159,15 @@ class Point:
else:
raise ValueError("cannot multiply %s by %s" % (type(self), type(other)))
- def __div__(self, other):
+ def __truediv__(self, other):
if isinstance(other, (int, float)):
return self * (1.0 / other)
else:
raise ValueError("cannot divide %s by %s" % (type(self), type(other)))
+ def __eq__(self, other):
+ return self.x == other.x and self.y == other.y
+
def __repr__(self):
return "%s(%s,%s)" % (type(self), self.x, self.y)
diff --git a/lib/utils/inkscape.py b/lib/utils/inkscape.py
index 62442650..58c4ea9b 100644
--- a/lib/utils/inkscape.py
+++ b/lib/utils/inkscape.py
@@ -4,18 +4,21 @@
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
import sys
-from os.path import expanduser, realpath
+from os.path import expanduser, realpath, split
+
+from inkex.utils import get_user_directory
def guess_inkscape_config_path():
if getattr(sys, 'frozen', None):
- path = realpath(sys._MEIPASS.split('extensions', 1)[0])
- if sys.platform == "win32":
- import win32api
-
- # This expands ugly things like EXTENS~1
- path = win32api.GetLongPathName(path)
+ if get_user_directory() is not None:
+ path = split(get_user_directory())[0]
+ else:
+ path = realpath(sys._MEIPASS.split('extensions', 1)[0])
+ if sys.platform == "win32":
+ import win32api
+ # This expands ugly things like EXTENS~1
+ path = win32api.GetLongPathName(path)
else:
path = expanduser("~/.config/inkscape")
-
return path
diff --git a/lib/utils/paths.py b/lib/utils/paths.py
index 938c5f33..2a95f6e7 100644..100755
--- a/lib/utils/paths.py
+++ b/lib/utils/paths.py
@@ -10,13 +10,19 @@ from os.path import dirname, realpath
def get_bundled_dir(name):
if getattr(sys, 'frozen', None) is not None:
- return realpath(os.path.join(sys._MEIPASS, "..", name))
+ if sys.platform == "darwin":
+ return realpath(os.path.join(sys._MEIPASS, "..", 'Resources', name))
+ else:
+ return realpath(os.path.join(sys._MEIPASS, "..", name))
else:
return realpath(os.path.join(dirname(realpath(__file__)), '..', '..', name))
def get_resource_dir(name):
if getattr(sys, 'frozen', None) is not None:
- return realpath(os.path.join(sys._MEIPASS, name))
+ if sys.platform == "darwin":
+ return realpath(os.path.join(sys._MEIPASS, "..", 'Resources', name))
+ else:
+ return realpath(os.path.join(sys._MEIPASS, name))
else:
return realpath(os.path.join(dirname(realpath(__file__)), '..', '..', name))
diff --git a/lib/utils/version.py b/lib/utils/version.py
index 2186ca23..0b46669a 100644..100755
--- a/lib/utils/version.py
+++ b/lib/utils/version.py
@@ -11,7 +11,10 @@ from ..i18n import _
def get_inkstitch_version():
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
- version = realpath(join(sys._MEIPASS, "..", "VERSION"))
+ if sys.platform == "darwin":
+ version = realpath(join(sys._MEIPASS, "..", 'Resources', "VERSION"))
+ else:
+ version = realpath(join(sys._MEIPASS, "..", "VERSION"))
else:
version = realpath(join(realpath(__file__), "..", "..", "..", 'VERSION'))
if isfile(version):