summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorKaalleen <36401965+kaalleen@users.noreply.github.com>2022-05-18 16:02:07 +0200
committerGitHub <noreply@github.com>2022-05-18 16:02:07 +0200
commitbc4f3b4699555f48c571be9a22a6768635c38cd0 (patch)
tree6a0bcd47bd19742857d0a7ed6bc034b22a4621a6 /lib
parentbb0f3b8168ea2c889935b96b532188b79d7f952e (diff)
Auto route for running stitch (#1638)
* add auto route for running stitch * introduce free motion commands Co-authored-by: Lex Neva <github.com@lexneva.name>
Diffstat (limited to 'lib')
-rw-r--r--lib/commands.py17
-rw-r--r--lib/elements/marker.py (renamed from lib/elements/pattern.py)13
-rw-r--r--lib/elements/satin_column.py5
-rw-r--r--lib/elements/stroke.py11
-rw-r--r--lib/elements/utils.py8
-rw-r--r--lib/extensions/__init__.py2
-rw-r--r--lib/extensions/auto_run.py65
-rw-r--r--lib/extensions/base.py8
-rw-r--r--lib/extensions/reorder.py2
-rw-r--r--lib/marker.py46
-rw-r--r--lib/patterns.py48
-rw-r--r--lib/stitches/auto_run.py284
-rw-r--r--lib/stitches/auto_satin.py248
-rw-r--r--lib/stitches/utils/autoroute.py221
14 files changed, 685 insertions, 293 deletions
diff --git a/lib/commands.py b/lib/commands.py
index c9da782a..1d235759 100644
--- a/lib/commands.py
+++ b/lib/commands.py
@@ -27,6 +27,12 @@ COMMANDS = {
"fill_end": N_("Fill stitch ending position"),
# L10N command attached to an object
+ "run_start": N_("Auto-route running stitch starting position"),
+
+ # L10N command attached to an object
+ "run_end": N_("Auto-route running stitch ending position"),
+
+ # L10N command attached to an object
"satin_start": N_("Auto-route satin stitch starting position"),
# L10N command attached to an object
@@ -54,7 +60,8 @@ COMMANDS = {
"stop_position": N_("Jump destination for Stop commands (a.k.a. \"Frame Out position\")."),
}
-OBJECT_COMMANDS = ["fill_start", "fill_end", "satin_start", "satin_end", "stop", "trim", "ignore_object", "satin_cut_point"]
+OBJECT_COMMANDS = ["fill_start", "fill_end", "run_start", "run_end", "satin_start", "satin_end", "stop", "trim", "ignore_object", "satin_cut_point"]
+FREE_MOVEMENT_OBJECT_COMMANDS = ["run_start", "run_end", "satin_start", "satin_end"]
LAYER_COMMANDS = ["ignore_layer"]
GLOBAL_COMMANDS = ["origin", "stop_position"]
@@ -288,7 +295,7 @@ def add_group(document, node, command):
return group
-def add_connector(document, symbol, element):
+def add_connector(document, symbol, command, element):
# I'd like it if I could position the connector endpoint nicely but inkscape just
# moves it to the element's center immediately after the extension runs.
start_pos = (symbol.get('x'), symbol.get('y'))
@@ -304,12 +311,14 @@ def add_connector(document, symbol, element):
"style": "stroke:#000000;stroke-width:1px;stroke-opacity:0.5;fill:none;",
CONNECTION_START: "#%s" % symbol.get('id'),
CONNECTION_END: "#%s" % element.node.get('id'),
- CONNECTOR_TYPE: "polyline",
# l10n: the name of the line that connects a command to the object it applies to
INKSCAPE_LABEL: _("connector")
})
+ if command not in FREE_MOVEMENT_OBJECT_COMMANDS:
+ path.attrib[CONNECTOR_TYPE] = "polyline"
+
symbol.getparent().insert(0, path)
@@ -383,7 +392,7 @@ def add_commands(element, commands):
group = add_group(svg, element.node, command)
pos = get_command_pos(element, i, len(commands))
symbol = add_symbol(svg, group, command, pos)
- add_connector(svg, symbol, element)
+ add_connector(svg, symbol, command, element)
def add_layer_commands(layer, commands):
diff --git a/lib/elements/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/satin_column.py b/lib/elements/satin_column.py
index a30f16d4..83080003 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -216,10 +216,7 @@ class SatinColumn(EmbroideryElement):
# This isn't used for satins at all, but other parts of the code
# may need to know the general shape of a satin column.
- flattened = self.flatten(self.parse_path())
- line_strings = [shgeo.LineString(path) for path in flattened]
-
- return shgeo.MultiLineString(line_strings)
+ return shgeo.MultiLineString(self.flattened_rails).convex_hull
@property
@cache
diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py
index 307c78b8..6f8d8bb1 100644
--- a/lib/elements/stroke.py
+++ b/lib/elements/stroke.py
@@ -87,7 +87,7 @@ class Stroke(EmbroideryElement):
# manipulate invalid path
if len(flattened[0]) == 1:
- return [[[flattened[0][0][0], flattened[0][0][1]], [flattened[0][0][0]+1.0, flattened[0][0][1]]]]
+ return [[[flattened[0][0][0], flattened[0][0][1]], [flattened[0][0][0] + 1.0, flattened[0][0][1]]]]
if self.manual_stitch_mode:
return [self.strip_control_points(subpath) for subpath in path]
@@ -97,12 +97,13 @@ class Stroke(EmbroideryElement):
@property
@cache
def shape(self):
+ return self.as_multi_line_string().convex_hull
+
+ @cache
+ def as_multi_line_string(self):
line_strings = [shapely.geometry.LineString(path) for path in self.paths]
- # Using convex_hull here is an important optimization. Otherwise
- # complex paths cause operations on the shape to take a long time.
- # This especially happens when importing machine embroidery files.
- return shapely.geometry.MultiLineString(line_strings).convex_hull
+ return shapely.geometry.MultiLineString(line_strings)
@property
@param('manual_stitch',
diff --git a/lib/elements/utils.py b/lib/elements/utils.py
index 99df7002..960f5b07 100644
--- a/lib/elements/utils.py
+++ b/lib/elements/utils.py
@@ -4,7 +4,7 @@
# 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
@@ -13,7 +13,7 @@ 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
@@ -30,8 +30,8 @@ def node_to_elements(node): # noqa: C901
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:
element = EmbroideryElement(node)
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index b6e0d1d1..d0cfa911 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -6,6 +6,7 @@
from lib.extensions.troubleshoot import Troubleshoot
from .apply_threadlist import ApplyThreadlist
+from .auto_run import AutoRun
from .auto_satin import AutoSatin
from .break_apart import BreakApart
from .cleanup import Cleanup
@@ -61,6 +62,7 @@ __all__ = extensions = [StitchPlanPreview,
ConvertToStroke,
CutSatin,
AutoSatin,
+ AutoRun,
Lettering,
LetteringGenerateJson,
LetteringRemoveKerning,
diff --git a/lib/extensions/auto_run.py b/lib/extensions/auto_run.py
new file mode 100644
index 00000000..02997fd0
--- /dev/null
+++ b/lib/extensions/auto_run.py
@@ -0,0 +1,65 @@
+# Authors: see git history
+#
+# Copyright (c) 2010 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+import inkex
+
+from ..elements import Stroke
+from ..i18n import _
+from ..stitches.auto_run import autorun
+from .commands import CommandsExtension
+
+
+class AutoRun(CommandsExtension):
+ COMMANDS = ["trim"]
+
+ def __init__(self, *args, **kwargs):
+ CommandsExtension.__init__(self, *args, **kwargs)
+
+ self.arg_parser.add_argument("-b", "--break_up", dest="break_up", type=inkex.Boolean, default=True)
+ self.arg_parser.add_argument("-p", "--preserve_order", dest="preserve_order", type=inkex.Boolean, default=False)
+ self.arg_parser.add_argument("-o", "--options", dest="options", type=str, default="")
+ self.arg_parser.add_argument("-i", "--info", dest="help", type=str, default="")
+
+ def effect(self):
+ elements = self.check_selection()
+ if not elements:
+ return
+
+ starting_point = self.get_starting_point()
+ ending_point = self.get_ending_point()
+
+ break_up = self.options.break_up
+
+ autorun(elements, self.options.preserve_order, break_up, starting_point, ending_point, self.options.trim)
+
+ def get_starting_point(self):
+ return self.get_command_point("run_start")
+
+ def get_ending_point(self):
+ return self.get_command_point("run_end")
+
+ def get_command_point(self, command_type):
+ command = None
+ for stroke in self.elements:
+ command = stroke.get_command(command_type)
+ # return the first occurence directly
+ if command:
+ return command.target_point
+
+ def check_selection(self):
+ if not self.get_elements():
+ return
+
+ if not self.svg.selection:
+ # L10N auto-route running stitch columns extension
+ inkex.errormsg(_("Please select one or more stroke elements."))
+ return False
+
+ elements = [element for element in self.elements if isinstance(element, Stroke)]
+ if len(elements) == 0:
+ inkex.errormsg(_("Please select at least one stroke element."))
+ return False
+
+ return elements
diff --git a/lib/extensions/base.py b/lib/extensions/base.py
index 75a07c5a..a5f1209a 100644
--- a/lib/extensions/base.py
+++ b/lib/extensions/base.py
@@ -17,7 +17,7 @@ from ..commands import is_command, layer_commands
from ..elements import EmbroideryElement, nodes_to_elements
from ..elements.clone import is_clone
from ..i18n import _
-from ..patterns import is_pattern
+from ..marker import has_marker
from ..svg import generate_unique_id
from ..svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS, INKSCAPE_GROUPMODE,
NOT_EMBROIDERABLE_TAGS, SVG_CLIPPATH_TAG, SVG_DEFS_TAG,
@@ -169,10 +169,10 @@ class InkstitchExtension(inkex.Effect):
if selected:
if node.tag == SVG_GROUP_TAG:
pass
- elif (node.tag in EMBROIDERABLE_TAGS or is_clone(node)) and not is_pattern(node):
+ elif (node.tag in EMBROIDERABLE_TAGS or is_clone(node)) and not has_marker(node):
nodes.append(node)
- # add images, text and patterns for the troubleshoot extension
- elif troubleshoot and (node.tag in NOT_EMBROIDERABLE_TAGS or is_pattern(node)):
+ # add images, text and elements with a marker for the troubleshoot extension
+ elif troubleshoot and (node.tag in NOT_EMBROIDERABLE_TAGS or has_marker(node)):
nodes.append(node)
return nodes
diff --git a/lib/extensions/reorder.py b/lib/extensions/reorder.py
index 83ecfe26..956c0615 100644
--- a/lib/extensions/reorder.py
+++ b/lib/extensions/reorder.py
@@ -17,7 +17,7 @@ class Reorder(InkstitchExtension):
objects = self.svg.selection
if not objects:
- errormsg(_("Please select at least to elements to reorder."))
+ errormsg(_("Please select at least two elements to reorder."))
return
for obj in objects:
diff --git a/lib/marker.py b/lib/marker.py
index 4f262abe..56a43c3b 100644
--- a/lib/marker.py
+++ b/lib/marker.py
@@ -7,10 +7,12 @@ from copy import deepcopy
from os import path
import inkex
+from shapely import geometry as shgeo
+from .svg.tags import EMBROIDERABLE_TAGS
from .utils import cache, get_bundled_dir
-MARKER = ['pattern']
+MARKER = ['pattern', 'guide-line']
def ensure_marker(svg, marker):
@@ -33,5 +35,45 @@ def set_marker(node, position, marker):
style = node.get('style') or ''
style = style.split(";")
style = [i for i in style if not i.startswith('marker-%s' % position)]
- style.append('marker-%s:url(#inkstitch-pattern-marker)' % position)
+ style.append('marker-%s:url(#inkstitch-%s-marker)' % (position, marker))
node.set('style', ";".join(style))
+
+
+def get_marker_elements(node, marker, get_fills=True, get_strokes=True):
+ from .elements import EmbroideryElement
+ from .elements.stroke import Stroke
+
+ fills = []
+ strokes = []
+ xpath = "./parent::svg:g/*[contains(@style, 'marker-start:url(#inkstitch-%s-marker)')]" % marker
+ markers = node.xpath(xpath, namespaces=inkex.NSS)
+ for marker in markers:
+ if marker.tag not in EMBROIDERABLE_TAGS:
+ continue
+
+ element = EmbroideryElement(marker)
+ fill = element.get_style('fill')
+ stroke = element.get_style('stroke')
+
+ if get_fills and fill is not None:
+ fill = Stroke(marker).paths
+ linear_rings = [shgeo.LinearRing(path) for path in fill]
+ for ring in linear_rings:
+ fills.append(shgeo.Polygon(ring))
+
+ if get_strokes and stroke is not None:
+ stroke = Stroke(marker).paths
+ line_strings = [shgeo.LineString(path) for path in stroke]
+ strokes.append(shgeo.MultiLineString(line_strings))
+
+ return {'fill': fills, 'stroke': strokes}
+
+
+def has_marker(node, marker=list()):
+ if not marker:
+ marker = MARKER
+ for m in marker:
+ style = node.get('style') or ''
+ if "marker-start:url(#inkstitch-%s-marker)" % m in style:
+ return True
+ return False
diff --git a/lib/patterns.py b/lib/patterns.py
index da22f21b..aca6155c 100644
--- a/lib/patterns.py
+++ b/lib/patterns.py
@@ -3,25 +3,17 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-import inkex
from shapely import geometry as shgeo
+from .marker import get_marker_elements
from .stitch_plan import Stitch
-from .svg.tags import EMBROIDERABLE_TAGS
from .utils import Point
-def is_pattern(node):
- if node.tag not in EMBROIDERABLE_TAGS:
- return False
- style = node.get('style') or ''
- return "marker-start:url(#inkstitch-pattern-marker)" in style
-
-
def apply_patterns(patches, node):
- patterns = _get_patterns(node)
- _apply_fill_patterns(patterns['fill_patterns'], patches)
- _apply_stroke_patterns(patterns['stroke_patterns'], patches)
+ patterns = get_marker_elements(node, "pattern")
+ _apply_fill_patterns(patterns['fill'], patches)
+ _apply_stroke_patterns(patterns['stroke'], patches)
def _apply_stroke_patterns(patterns, patches):
@@ -64,43 +56,13 @@ def _apply_fill_patterns(patterns, patches):
patch.stitches = patch_points
-def _get_patterns(node):
- from .elements import EmbroideryElement
- from .elements.stroke import Stroke
-
- fills = []
- strokes = []
- xpath = "./parent::svg:g/*[contains(@style, 'marker-start:url(#inkstitch-pattern-marker)')]"
- patterns = node.xpath(xpath, namespaces=inkex.NSS)
- for pattern in patterns:
- if pattern.tag not in EMBROIDERABLE_TAGS:
- continue
-
- element = EmbroideryElement(pattern)
- fill = element.get_style('fill')
- stroke = element.get_style('stroke')
-
- if fill is not None:
- fill_pattern = Stroke(pattern).paths
- linear_rings = [shgeo.LinearRing(path) for path in fill_pattern]
- for ring in linear_rings:
- fills.append(shgeo.Polygon(ring))
-
- if stroke is not None:
- stroke_pattern = Stroke(pattern).paths
- line_strings = [shgeo.LineString(path) for path in stroke_pattern]
- strokes.append(shgeo.MultiLineString(line_strings))
-
- return {'fill_patterns': fills, 'stroke_patterns': strokes}
-
-
def _get_pattern_points(first, second, pattern):
points = []
intersection = shgeo.LineString([first, second]).intersection(pattern)
if isinstance(intersection, shgeo.Point):
points.append(Point(intersection.x, intersection.y))
if isinstance(intersection, shgeo.MultiPoint):
- for point in intersection:
+ for point in intersection.geoms:
points.append(Point(point.x, point.y))
# sort points after their distance to first
points.sort(key=lambda point: point.distance(first))
diff --git a/lib/stitches/auto_run.py b/lib/stitches/auto_run.py
new file mode 100644
index 00000000..847a1bcd
--- /dev/null
+++ b/lib/stitches/auto_run.py
@@ -0,0 +1,284 @@
+# Authors: see git history
+#
+# Copyright (c) 2022 Authors
+# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
+
+from collections import defaultdict
+
+import networkx as nx
+from shapely.geometry import LineString, MultiLineString, MultiPoint, Point
+from shapely.ops import nearest_points, substring, unary_union
+
+import inkex
+
+from ..commands import add_commands
+from ..elements import Stroke
+from ..i18n import _
+from ..svg import PIXELS_PER_MM, generate_unique_id
+from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS
+from .utils.autoroute import (add_elements_to_group, add_jumps,
+ create_new_group, find_path,
+ get_starting_and_ending_nodes,
+ preserve_original_groups,
+ remove_original_elements)
+
+
+class LineSegments:
+ '''
+ Takes elements and splits them into segments.
+
+ Attributes:
+ _lines -- a list of LineStrings from the subpaths of the Stroke elements
+ _elements -- a list of Stroke elements for each corresponding line in _lines
+ _intersection_points -- a dictionary with intersection points {line_index: [intersection_points]}
+ segments -- (public) a list of segments and corresponding elements [[segment, element], ...]
+ '''
+
+ def __init__(self, elements):
+ self._lines = []
+ self._elements = []
+ self._intersection_points = defaultdict(list)
+ self.segments = []
+
+ self._process_elements(elements)
+ self._get_intersection_points()
+ self._get_segments()
+
+ def _process_elements(self, elements):
+ for element in elements:
+ lines = element.as_multi_line_string().geoms
+
+ for line in lines:
+ # split at self-intersections if necessary
+ unary_lines = unary_union(line)
+ if isinstance(unary_lines, MultiLineString):
+ for unary_line in unary_lines.geoms:
+ self._lines.append(unary_line)
+ self._elements.append(element)
+ else:
+ self._lines.append(line)
+ self._elements.append(element)
+
+ def _get_intersection_points(self):
+ for i, line1 in enumerate(self._lines):
+ for j in range(i + 1, len(self._lines)):
+ line2 = self._lines[j]
+ distance = line1.distance(line2)
+ if distance > 50:
+ continue
+ if not distance == 0:
+ # add nearest points
+ near = nearest_points(line1, line2)
+ self._add_point(i, near[0])
+ self._add_point(j, near[1])
+ # add intersections
+ intersections = line1.intersection(line2)
+ if isinstance(intersections, Point):
+ self._add_point(i, intersections)
+ self._add_point(j, intersections)
+ elif isinstance(intersections, MultiPoint):
+ for point in intersections.geoms:
+ self._add_point(i, point)
+ self._add_point(j, point)
+ elif isinstance(intersections, LineString):
+ for point in intersections.coords:
+ self._add_point(i, Point(*point))
+ self._add_point(j, Point(*point))
+
+ def _add_point(self, element, point):
+ self._intersection_points[element].append(point)
+
+ def _get_segments(self):
+ '''
+ Splits elements into segments at intersection and "almost intersecions".
+ The split method would make this very easy (it can split a MultiString with
+ MultiPoints) but sadly it fails too often, while snap moves the points away
+ from where we want them. So we need to calculate the distance along the line
+ and finally split it into segments with shapelys substring method.
+ '''
+ self.segments = []
+ for i, line in enumerate(self._lines):
+ length = line.length
+ points = self._intersection_points[i]
+
+ distances = [0, length]
+ for point in points:
+ distances.append(line.project(point))
+ distances = sorted(set(distances))
+
+ for j in range(len(distances) - 1):
+ start = distances[j]
+ end = distances[j + 1]
+
+ if end - start > 0.1:
+ seg = substring(line, start, end)
+ self.segments.append([seg, self._elements[i]])
+
+
+def autorun(elements, preserve_order=False, break_up=None, starting_point=None, ending_point=None, trim=False):
+ graph = build_graph(elements, preserve_order, break_up)
+ graph = add_jumps(graph, elements, preserve_order)
+
+ starting_point, ending_point = get_starting_and_ending_nodes(
+ graph, elements, preserve_order, starting_point, ending_point)
+
+ path = find_path(graph, starting_point, ending_point)
+ path = add_path_attribs(path)
+
+ new_elements, trims, original_parents = path_to_elements(graph, path, trim)
+
+ if preserve_order:
+ preserve_original_groups(new_elements, original_parents)
+ else:
+ parent = elements[0].node.getparent()
+ insert_index = parent.index(elements[0].node)
+ group = create_new_group(parent, insert_index, _("Auto-Run"))
+ add_elements_to_group(new_elements, group)
+
+ if trim:
+ add_trims(new_elements, trims)
+
+ remove_original_elements(elements)
+
+
+def build_graph(elements, preserve_order, break_up):
+ if preserve_order:
+ graph = nx.DiGraph()
+ else:
+ graph = nx.Graph()
+
+ if not break_up:
+ segments = []
+ for element in elements:
+ line_strings = [[line, element] for line in element.as_multi_line_string().geoms]
+ segments.extend(line_strings)
+ else:
+ segments = LineSegments(elements).segments
+
+ for segment, element in segments:
+ for c1, c2 in zip(segment.coords[:-1], segment.coords[1:]):
+ start = Point(*c1)
+ end = Point(*c2)
+
+ graph.add_node(str(start), point=start)
+ graph.add_node(str(end), point=end)
+ graph.add_edge(str(start), str(end), element=element)
+
+ if preserve_order:
+ # The graph is a directed graph, but we want to allow travel in
+ # any direction, so we add the edge in the opposite direction too.
+ graph.add_edge(str(end), str(start), element=element)
+
+ return graph
+
+
+def add_path_attribs(path):
+ # find_path() will have duplicated some of the edges in the graph. We don't
+ # want to sew the same running stitch twice. If a running stitch section appears
+ # twice in the path, we'll sew the first occurrence as a simple running stitch without
+ # the original running stitch repetitions and bean stitch settings.
+ seen = set()
+ for i, point in reversed(list(enumerate(path))):
+ if point in seen:
+ path[i] = (*point, "underpath")
+ else:
+ path[i] = (*point, "autorun")
+ seen.add(point)
+ seen.add((point[1], point[0]))
+ return path
+
+
+def path_to_elements(graph, path, trim): # noqa: C901
+ element_list = []
+ original_parents = []
+ trims = []
+
+ d = ""
+ position = 0
+ path_direction = "autorun"
+ just_trimmed = False
+ el = None
+ for start, end, direction in path:
+ element = graph[start][end].get('element')
+ start_coord = graph.nodes[start]['point']
+ end_coord = graph.nodes[end]['point']
+ if element:
+ el = element
+
+ if just_trimmed:
+ if direction == "underpath":
+ # no sense in doing underpath after we trim
+ continue
+ else:
+ just_trimmed = False
+
+ # create a new element if direction (purpose) changes
+ if direction != path_direction:
+ if d:
+ element_list.append(create_element(d, position, path_direction, el))
+ original_parents.append(el.node.getparent())
+ d = ""
+ position += 1
+ path_direction = direction
+
+ if d == "":
+ d = "M %s %s, %s %s" % (start_coord.x, start_coord.y, end_coord.x, end_coord.y)
+ else:
+ d += ", %s %s" % (end_coord.x, end_coord.y)
+ elif el and d:
+ # this is a jump, so complete the element whose path we've been building
+ element_list.append(create_element(d, position, path_direction, el))
+ original_parents.append(el.node.getparent())
+ d = ""
+
+ if trim and start_coord.distance(end_coord) > 0.75 * PIXELS_PER_MM:
+ trims.append(position)
+ just_trimmed = True
+
+ position += 1
+
+ if d:
+ element_list.append(create_element(d, position, path_direction, el))
+ original_parents.append(el.node.getparent())
+
+ return element_list, trims, original_parents
+
+
+def create_element(path, position, direction, element):
+ if not path:
+ return
+
+ style = inkex.Style(element.node.get("style"))
+ style = style + inkex.Style("stroke-dasharray:0.5,0.5;fill:none;")
+ el_id = "%s_%s_" % (direction, position)
+
+ index = position + 1
+ if direction == "autorun":
+ label = _("AutoRun %d") % index
+ else:
+ label = _("AutoRun Underpath %d") % index
+
+ stitch_length = element.node.get(INKSTITCH_ATTRIBS['running_stitch_length_mm'], '')
+ bean = element.node.get(INKSTITCH_ATTRIBS['bean_stitch_repeats'], 0)
+ repeats = int(element.node.get(INKSTITCH_ATTRIBS['repeats'], 1))
+ if repeats % 2 == 0:
+ repeats -= 1
+
+ node = inkex.PathElement()
+ node.set("id", generate_unique_id(element.node, el_id))
+ node.set(INKSCAPE_LABEL, label)
+ node.set("d", path)
+ node.set("style", str(style))
+ if stitch_length:
+ node.set(INKSTITCH_ATTRIBS['running_stitch_length_mm'], stitch_length)
+ if direction == "autorun":
+ node.set(INKSTITCH_ATTRIBS['repeats'], str(repeats))
+ if bean:
+ node.set(INKSTITCH_ATTRIBS['bean_stitch_repeats'], bean)
+
+ return Stroke(node)
+
+
+def add_trims(elements, trim_indices):
+ for i in trim_indices:
+ add_commands(elements[i], ["trim"])
diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py
index 2b7f0906..ba5c8698 100644
--- a/lib/stitches/auto_satin.py
+++ b/lib/stitches/auto_satin.py
@@ -6,19 +6,24 @@
import math
from itertools import chain
-import inkex
import networkx as nx
from shapely import geometry as shgeo
from shapely.geometry import Point as ShapelyPoint
+import inkex
+
from ..commands import add_commands
from ..elements import SatinColumn, Stroke
from ..i18n import _
-from ..svg import (PIXELS_PER_MM, generate_unique_id, get_correction_transform,
- line_strings_to_csp)
-from ..svg.tags import (INKSCAPE_LABEL, INKSTITCH_ATTRIBS)
+from ..svg import PIXELS_PER_MM, generate_unique_id, line_strings_to_csp
+from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS
from ..utils import Point as InkstitchPoint
from ..utils import cache, cut
+from .utils.autoroute import (add_elements_to_group, add_jumps,
+ create_new_group, find_path,
+ get_starting_and_ending_nodes,
+ preserve_original_groups,
+ remove_original_elements)
class SatinSegment(object):
@@ -177,7 +182,7 @@ class SatinSegment(object):
class JumpStitch(object):
"""A jump stitch between two points."""
- def __init__(self, start, end):
+ def __init__(self, start, end, source_element, destination_element):
"""Initialize a JumpStitch.
Arguments:
@@ -186,6 +191,8 @@ class JumpStitch(object):
self.start = start
self.end = end
+ self.source_element = source_element
+ self.destination_element = destination_element
def is_sequential(self, other):
# Don't bother joining jump stitches.
@@ -196,6 +203,15 @@ class JumpStitch(object):
def length(self):
return self.start.distance(self.end)
+ def as_line_string(self):
+ return shgeo.LineString((self.start, self.end))
+
+ def should_trim(self):
+ actual_jump = self.as_line_string().difference(self.source_element.shape)
+ actual_jump = actual_jump.difference(self.destination_element.shape)
+
+ return actual_jump.length > PIXELS_PER_MM
+
class RunningStitch(object):
"""Running stitch along a path."""
@@ -326,7 +342,7 @@ def auto_satin(elements, preserve_order=False, starting_point=None, ending_point
if preserve_order:
preserve_original_groups(new_elements, original_parents)
else:
- group = create_new_group(parent, index)
+ group = create_new_group(parent, index, _("Auto-Route"))
add_elements_to_group(new_elements, group)
name_elements(new_elements, preserve_order)
@@ -358,8 +374,8 @@ def build_graph(elements, preserve_order=False):
for segment in segments:
# This is necessary because shapely points aren't hashable and thus
# can't be used as nodes directly.
- graph.add_node(str(segment.start_point), point=segment.start_point)
- graph.add_node(str(segment.end_point), point=segment.end_point)
+ graph.add_node(str(segment.start_point), point=segment.start_point, element=element)
+ graph.add_node(str(segment.end_point), point=segment.end_point, element=element)
graph.add_edge(str(segment.start_point), str(
segment.end_point), segment=segment, element=element)
@@ -373,168 +389,6 @@ def build_graph(elements, preserve_order=False):
return graph
-def get_starting_and_ending_nodes(graph, elements, preserve_order, starting_point, ending_point):
- """Find or choose the starting and ending graph nodes.
-
- If points were passed, we'll find the nearest graph nodes. Since we split
- every satin up into 1mm-chunks, we'll be at most 1mm away which is good
- enough.
-
- If we weren't given starting and ending points, we'll pic kthe far left and
- right nodes.
-
- returns:
- (starting graph node, ending graph node)
- """
-
- nodes = []
-
- nodes.append(find_node(graph, starting_point,
- min, preserve_order, elements[0]))
- nodes.append(find_node(graph, ending_point,
- max, preserve_order, elements[-1]))
-
- return nodes
-
-
-def find_node(graph, point, extreme_function, constrain_to_satin=False, satin=None):
- if constrain_to_satin:
- nodes = get_nodes_on_element(graph, satin)
- else:
- nodes = graph.nodes()
-
- if point is None:
- return extreme_function(nodes, key=lambda node: graph.nodes[node]['point'].x)
- else:
- point = shgeo.Point(*point)
- return min(nodes, key=lambda node: graph.nodes[node]['point'].distance(point))
-
-
-def get_nodes_on_element(graph, element):
- nodes = set()
-
- for start_node, end_node, element_for_edge in graph.edges(data='element'):
- if element_for_edge is element:
- nodes.add(start_node)
- nodes.add(end_node)
-
- return nodes
-
-
-def add_jumps(graph, elements, preserve_order):
- """Add jump stitches between elements as necessary.
-
- Jump stitches are added to ensure that all elements can be reached. Only the
- minimal number and length of jumps necessary will be added.
- """
-
- if preserve_order:
- # For each sequential pair of elements, find the shortest possible jump
- # stitch between them and add it. The directions of these new edges
- # will enforce stitching the satins in order.
-
- for element1, element2 in zip(elements[:-1], elements[1:]):
- potential_edges = []
-
- nodes1 = get_nodes_on_element(graph, element1)
- nodes2 = get_nodes_on_element(graph, element2)
-
- for node1 in nodes1:
- for node2 in nodes2:
- point1 = graph.nodes[node1]['point']
- point2 = graph.nodes[node2]['point']
- potential_edges.append((point1, point2))
-
- if potential_edges:
- edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1]))
- graph.add_edge(str(edge[0]), str(edge[1]), jump=True)
- else:
- # networkx makes this super-easy! k_edge_agumentation tells us what edges
- # we need to add to ensure that the graph is fully connected. We give it a
- # set of possible edges that it can consider adding (avail). Each edge has
- # a weight, which we'll set as the length of the jump stitch. The
- # algorithm will minimize the total length of jump stitches added.
- for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))):
- graph.add_edge(*jump, jump=True)
-
-
-def possible_jumps(graph):
- """All possible jump stitches in the graph with their lengths.
-
- Returns: a generator of tuples: (node1, node2, length)
- """
-
- # We'll take the easy approach and list all edges that aren't already in
- # the graph. networkx's algorithm is pretty efficient at ignoring
- # pointless options like jumping between two points on the same satin.
-
- for start, end in nx.complement(graph).edges():
- start_point = graph.nodes[start]['point']
- end_point = graph.nodes[end]['point']
- yield (start, end, start_point.distance(end_point))
-
-
-def find_path(graph, starting_node, ending_node):
- """Find a path through the graph that sews every satin."""
-
- # This is done in two steps. First, we find the shortest path from the
- # start to the end. We remove it from the graph, and proceed to step 2.
- #
- # Then, we traverse the path node by node. At each node, we follow any
- # branchings with a depth-first search. We travel down each branch of
- # the tree, inserting each seen branch into the tree. When the DFS
- # hits a dead-end, as it back-tracks, we also add the seen edges _again_.
- # Repeat until there are no more edges left in the graph.
- #
- # Visiting the edges again on the way back allows us to set up
- # "underpathing". As we stitch down each branch, we'll do running stitch.
- # Then when we come back up, we'll do satin stitch, covering the previous
- # running stitch.
- path = nx.shortest_path(graph, starting_node, ending_node)
-
- # Copy the graph so that we can remove the edges as we visit them.
- # This also converts the directed graph into an undirected graph in the
- # case that "preserve_order" is set. This way we avoid going back and
- # forth on each satin twice due to the satin edges being in the graph
- # twice (forward and reverse).
- graph = nx.Graph(graph)
- graph.remove_edges_from(list(zip(path[:-1], path[1:])))
-
- final_path = []
- prev = None
- for node in path:
- if prev is not None:
- final_path.append((prev, node))
- prev = node
-
- for n1, n2, edge_type in list(nx.dfs_labeled_edges(graph, node)):
- if n1 == n2:
- # dfs_labeled_edges gives us (start, start, "forward") for
- # the starting node for some reason
- continue
-
- if edge_type == "forward":
- final_path.append((n1, n2))
- graph.remove_edge(n1, n2)
- elif edge_type == "reverse":
- final_path.append((n2, n1))
- elif edge_type == "nontree":
- # a "nontree" happens when there exists an edge from n1 to n2
- # but n2 has already been visited. It's a dead-end that runs
- # into part of the graph that we've already traversed. We
- # do still need to make sure that satin is sewn, so we travel
- # down and back on this edge.
- #
- # It's possible for a given "nontree" edge to be listed more
- # than once so we'll deduplicate.
- if (n1, n2) in graph.edges:
- final_path.append((n1, n2))
- final_path.append((n2, n1))
- graph.remove_edge(n1, n2)
-
- return final_path
-
-
def reversed_path(path):
"""Generator for a version of the path travelling in the opposite direction.
@@ -563,7 +417,10 @@ def path_to_operations(graph, path):
segment = segment.reversed()
operations.append(segment)
else:
- operations.append(JumpStitch(graph.nodes[start]['point'], graph.nodes[end]['point']))
+ operations.append(JumpStitch(graph.nodes[start]['point'],
+ graph.nodes[end]['point'],
+ graph.nodes[start]['element'],
+ graph.nodes[end]['element']))
# find_path() will have duplicated some of the edges in the graph. We don't
# want to sew the same satin twice. If a satin section appears twice in the
@@ -616,59 +473,12 @@ def operations_to_elements_and_trims(operations, preserve_order):
elements.append(operation.to_element())
original_parent_nodes.append(operation.original_node.getparent())
elif isinstance(operation, (JumpStitch)):
- if elements and operation.length > 0.75 * PIXELS_PER_MM:
+ if elements and operation.should_trim():
trims.append(len(elements) - 1)
return elements, list(set(trims)), original_parent_nodes
-def remove_original_elements(elements):
- for element in elements:
- for command in element.commands:
- command_group = command.use.getparent()
- if command_group is not None and command_group.get('id').startswith('command_group'):
- remove_from_parent(command_group)
- else:
- remove_from_parent(command.connector)
- remove_from_parent(command.use)
- remove_from_parent(element.node)
-
-
-def remove_from_parent(node):
- if node.getparent() is not None:
- node.getparent().remove(node)
-
-
-def preserve_original_groups(elements, original_parent_nodes):
- """Ensure that all elements are contained in the original SVG group elements.
-
- When preserve_order is True, no SatinColumn or Stroke elements will be
- reordered in the XML tree. This makes it possible to preserve original SVG
- group membership. We'll ensure that each newly-created Element is added
- to the group that contained the original SatinColumn that spawned it.
- """
-
- for element, parent in zip(elements, original_parent_nodes):
- if parent is not None:
- parent.append(element.node)
- element.node.set('transform', get_correction_transform(parent, child=True))
-
-
-def create_new_group(parent, insert_index):
- group = inkex.Group(attrib={
- INKSCAPE_LABEL: _("Auto-Satin"),
- "transform": get_correction_transform(parent, child=True)
- })
- parent.insert(insert_index, group)
-
- return group
-
-
-def add_elements_to_group(elements, group):
- for element in elements:
- group.append(element.node)
-
-
def name_elements(new_elements, preserve_order):
"""Give the newly-created SVG objects useful names.
diff --git a/lib/stitches/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)