diff --git a/inkstitch.py b/inkstitch.py
index b466a5089..1a7b14683 100644
--- a/inkstitch.py
+++ b/inkstitch.py
@@ -1,12 +1,24 @@
import os
import sys
+import logging
import traceback
+from cStringIO import StringIO
from argparse import ArgumentParser
from lib import extensions
from lib.utils import save_stderr, restore_stderr
+logger = logging.getLogger('shapely.geos')
+logger.setLevel(logging.DEBUG)
+shapely_errors = StringIO()
+ch = logging.StreamHandler(shapely_errors)
+ch.setLevel(logging.DEBUG)
+formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
+ch.setFormatter(formatter)
+logger.addHandler(ch)
+
+
parser = ArgumentParser()
parser.add_argument("--extension")
my_args, remaining_args = parser.parse_known_args()
@@ -31,20 +43,25 @@ extension_class_name = extension_name.title().replace("_", "")
extension_class = getattr(extensions, extension_class_name)
extension = extension_class()
-exception = None
-
-save_stderr()
-try:
+if hasattr(sys, 'gettrace') and sys.gettrace():
extension.affect(args=remaining_args)
-except (SystemExit, KeyboardInterrupt):
- raise
-except Exception:
- exception = traceback.format_exc()
-finally:
- restore_stderr()
-
-if exception:
- print >> sys.stderr, exception
- sys.exit(1)
else:
- sys.exit(0)
+ save_stderr()
+ exception = None
+ try:
+ extension.affect(args=remaining_args)
+ except (SystemExit, KeyboardInterrupt):
+ raise
+ except Exception:
+ exception = traceback.format_exc()
+ finally:
+ restore_stderr()
+
+ if shapely_errors.tell():
+ print >> sys.stderr, shapely_errors.getvalue()
+
+ if exception:
+ print >> sys.stderr, exception
+ sys.exit(1)
+ else:
+ sys.exit(0)
diff --git a/lib/commands.py b/lib/commands.py
index df82a8c41..3c7397088 100644
--- a/lib/commands.py
+++ b/lib/commands.py
@@ -15,6 +15,12 @@ COMMANDS = {
# L10N command attached to an object
N_("fill_end"): N_("Fill stitch ending position"),
+ # L10N command attached to an object
+ N_("satin_start"): N_("Auto-route satin stitch starting position"),
+
+ # L10N command attached to an object
+ N_("satin_end"): N_("Auto-route satin stitch ending position"),
+
# L10N command attached to an object
N_("stop"): N_("Stop (pause machine) after sewing this object"),
@@ -38,7 +44,7 @@ COMMANDS = {
N_("stop_position"): N_("Jump destination for Stop commands (a.k.a. \"Frame Out position\")."),
}
-OBJECT_COMMANDS = ["fill_start", "fill_end", "stop", "trim", "ignore_object", "satin_cut_point"]
+OBJECT_COMMANDS = ["fill_start", "fill_end", "satin_start", "satin_end", "stop", "trim", "ignore_object", "satin_cut_point"]
LAYER_COMMANDS = ["ignore_layer"]
GLOBAL_COMMANDS = ["origin", "stop_position"]
diff --git a/lib/elements/__init__.py b/lib/elements/__init__.py
index 7e05e19c3..22603217a 100644
--- a/lib/elements/__init__.py
+++ b/lib/elements/__init__.py
@@ -1,6 +1,6 @@
-from auto_fill import AutoFill
-from fill import Fill
-from stroke import Stroke
-from satin_column import SatinColumn
from element import EmbroideryElement
+from satin_column import SatinColumn
+from stroke import Stroke
from polyline import Polyline
+from fill import Fill
+from auto_fill import AutoFill
diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py
index 705983d75..1f9854edb 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -5,8 +5,8 @@ import cubicsuperpath
from .element import param, EmbroideryElement, Patch
from ..i18n import _
-from ..utils import cache, Point, cut
-from ..svg import line_strings_to_csp, get_correction_transform
+from ..utils import cache, Point, cut, collapse_duplicate_point
+from ..svg import line_strings_to_csp, point_lists_to_csp
class SatinColumn(EmbroideryElement):
@@ -245,10 +245,17 @@ class SatinColumn(EmbroideryElement):
intersection = rail_segment.intersection(rung)
+ # If there are duplicate points in a rung-less satin, then
+ # intersection will be a GeometryCollection of multiple copies
+ # of the same point. This reduces it that to a single point.
+ intersection = collapse_duplicate_point(intersection)
+
if not intersection.is_empty:
if isinstance(intersection, shgeo.MultiLineString):
intersections += len(intersection)
break
+ elif not isinstance(intersection, shgeo.Point):
+ self.fatal("intersection is a: %s %s" % (intersection, intersection.geoms))
else:
intersections += 1
@@ -321,6 +328,35 @@ class SatinColumn(EmbroideryElement):
self.fatal(_("satin column: object %(id)s has two paths with an unequal number of points (%(length1)d and %(length2)d)") %
dict(id=node_id, length1=len(self.rails[0]), length2=len(self.rails[1])))
+ def reverse(self):
+ """Return a new SatinColumn like this one but in the opposite direction.
+
+ The path will be flattened and the new satin will contain a new XML
+ node that is not yet in the SVG.
+ """
+ # flatten the path because you can't just reverse a CSP subpath's elements (I think)
+ point_lists = []
+
+ for rail in self.rails:
+ point_lists.append(list(reversed(self.flatten_subpath(rail))))
+
+ # reverse the order of the rails because we're sewing in the opposite direction
+ point_lists.reverse()
+
+ for rung in self.rungs:
+ point_lists.append(self.flatten_subpath(rung))
+
+ return self._csp_to_satin(point_lists_to_csp(point_lists))
+
+ def apply_transform(self):
+ """Return a new SatinColumn like this one but with transforms applied.
+
+ This node's and all ancestor nodes' transforms will be applied. The
+ new SatinColumn's node will not be in the SVG document.
+ """
+
+ return self._csp_to_satin(self.csp)
+
def split(self, split_point):
"""Split a satin into two satins at the specified point
@@ -328,6 +364,9 @@ class SatinColumn(EmbroideryElement):
ends. Finds corresponding point on the other rail (taking into account
the rungs) and breaks the rails at these points.
+ split_point can also be a noramlized projection of a distance along the
+ satin, in the range 0.0 to 1.0.
+
Returns two new SatinColumn instances: the part before and the part
after the split point. All parameters are copied over to the new
SatinColumn instances.
@@ -337,7 +376,7 @@ class SatinColumn(EmbroideryElement):
path_lists = self._cut_rails(cut_points)
self._assign_rungs_to_split_rails(path_lists)
self._add_rungs_if_necessary(path_lists)
- return self._path_lists_to_satins(path_lists)
+ return [self._path_list_to_satins(path_list) for path_list in path_lists]
def _find_cut_points(self, split_point):
"""Find the points on each satin corresponding to the split point.
@@ -347,22 +386,30 @@ class SatinColumn(EmbroideryElement):
for that rail. A corresponding cut point will be chosen on the other
rail, taking into account the satin's rungs to choose a matching point.
+ split_point can instead be a number in [0.0, 1.0] indicating a
+ a fractional distance down the satin to cut at.
+
Returns: a list of two Point objects corresponding to the selected
cut points.
"""
- split_point = Point(*split_point)
- patch = self.do_satin()
- index_of_closest_stitch = min(range(len(patch)), key=lambda index: split_point.distance(patch.stitches[index]))
+ # like in do_satin()
+ points = list(chain.from_iterable(izip(*self.walk_paths(self.zigzag_spacing, 0))))
+
+ if isinstance(split_point, float):
+ index_of_closest_stitch = int(round(len(points) * split_point))
+ else:
+ split_point = Point(*split_point)
+ index_of_closest_stitch = min(range(len(points)), key=lambda index: split_point.distance(points[index]))
if index_of_closest_stitch % 2 == 0:
# split point is on the first rail
- return (patch.stitches[index_of_closest_stitch],
- patch.stitches[index_of_closest_stitch + 1])
+ return (points[index_of_closest_stitch],
+ points[index_of_closest_stitch + 1])
else:
# split point is on the second rail
- return (patch.stitches[index_of_closest_stitch - 1],
- patch.stitches[index_of_closest_stitch])
+ return (points[index_of_closest_stitch - 1],
+ points[index_of_closest_stitch])
def _cut_rails(self, cut_points):
"""Cut the rails of this satin at the specified points.
@@ -372,7 +419,7 @@ class SatinColumn(EmbroideryElement):
Returns: A list of two elements, corresponding two the two new sets of
rails. Each element is a list of two rails of type LineString.
- """
+ """
rails = [shgeo.LineString(self.flatten_subpath(rail)) for rail in self.rails]
@@ -396,19 +443,22 @@ class SatinColumn(EmbroideryElement):
path_list.extend(rung for rung in rungs if path_list[0].intersects(rung) and path_list[1].intersects(rung))
def _add_rungs_if_necessary(self, path_lists):
- """Add an additional rung to each new satin if it ended up with none.
+ """Add an additional rung to each new satin if needed.
- If the split point is between the end and the last rung, then one of
- the satins will have no rungs. Add one to make it stitch properly.
+ Case #1: If the split point is between the end and the last rung, then
+ one of the satins will have no rungs. It will be treated as an old-style
+ satin, but it may not have an equal number of points in each rail. Adding
+ a rung will make it stitch properly.
+
+ Case #2: If one of the satins ends up with exactly two rungs, it's
+ ambiguous which of the subpaths are rails and which are rungs. Adding
+ another rung disambiguates this case. See rail_indices() above for more
+ information.
"""
- # no need to add rungs if there weren't any in the first place
- if not self.rungs:
- return
-
for path_list in path_lists:
- if len(path_list) == 2:
- # If a path has no rungs, it may be invalid. Add a rung at the start.
+ if len(path_list) in (2, 4):
+ # Add the rung just after the start of the satin.
rung_start = path_list[0].interpolate(0.1)
rung_end = path_list[1].interpolate(0.1)
rung = shgeo.LineString((rung_start, rung_end))
@@ -418,18 +468,26 @@ class SatinColumn(EmbroideryElement):
path_list.append(rung)
- def _path_lists_to_satins(self, path_lists):
- transform = get_correction_transform(self.node)
- satins = []
- for path_list in path_lists:
- node = deepcopy(self.node)
- csp = line_strings_to_csp(path_list)
- d = cubicsuperpath.formatPath(csp)
- node.set("d", d)
- node.set("transform", transform)
- satins.append(SatinColumn(node))
+ def _path_list_to_satins(self, path_list):
+ return self._csp_to_satin(line_strings_to_csp(path_list))
- return satins
+ def _csp_to_satin(self, csp):
+ node = deepcopy(self.node)
+ d = cubicsuperpath.formatPath(csp)
+ node.set("d", d)
+
+ # we've already applied the transform, so get rid of it
+ if node.get("transform"):
+ del node.attrib["transform"]
+
+ return SatinColumn(node)
+
+ @property
+ @cache
+ def center_line(self):
+ # similar technique to do_center_walk()
+ center_walk, _ = self.walk_paths(self.zigzag_spacing, -100000)
+ return shgeo.LineString(center_walk)
def offset_points(self, pos1, pos2, offset_px):
# Expand or contract two points about their midpoint. This is
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index 56cd774b2..f70c01351 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -12,6 +12,7 @@ from layer_commands import LayerCommands
from global_commands import GlobalCommands
from convert_to_satin import ConvertToSatin
from cut_satin import CutSatin
+from auto_satin import AutoSatin
__all__ = extensions = [Embroider,
Install,
@@ -26,4 +27,5 @@ __all__ = extensions = [Embroider,
LayerCommands,
GlobalCommands,
ConvertToSatin,
- CutSatin]
+ CutSatin,
+ AutoSatin]
diff --git a/lib/extensions/auto_satin.py b/lib/extensions/auto_satin.py
new file mode 100644
index 000000000..e5e9c40bd
--- /dev/null
+++ b/lib/extensions/auto_satin.py
@@ -0,0 +1,108 @@
+import sys
+
+import inkex
+
+from ..elements import SatinColumn
+from ..i18n import _
+from ..stitches.auto_satin import auto_satin
+from ..svg import get_correction_transform
+from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_LABEL
+from .commands import CommandsExtension
+
+
+class AutoSatin(CommandsExtension):
+ COMMANDS = ["trim"]
+
+ def __init__(self, *args, **kwargs):
+ CommandsExtension.__init__(self, *args, **kwargs)
+
+ self.OptionParser.add_option("-p", "--preserve_order", dest="preserve_order", type="inkbool", default=False)
+
+ def get_starting_point(self):
+ return self.get_point("satin_start")
+
+ def get_ending_point(self):
+ return self.get_point("satin_end")
+
+ def get_point(self, command_type):
+ command = None
+ for satin in self.elements:
+ this_command = satin.get_command(command_type)
+ if command is not None and this_command:
+ inkex.errormsg(_("Please ensure that at most one start and end command is attached to the selected satin columns."))
+ sys.exit(0)
+ elif this_command:
+ command = this_command
+
+ if command is not None:
+ return command.target_point
+
+ def effect(self):
+ if not self.check_selection():
+ return
+
+ group = self.create_group()
+ new_elements, trim_indices = self.auto_satin()
+
+ # The ordering is careful here. Some of the original satins may have
+ # been used unmodified. That's why we remove all of the original
+ # satins _first_ before adding new_elements back into the SVG.
+ self.remove_original_satins()
+ self.add_elements(group, new_elements)
+
+ self.add_trims(new_elements, trim_indices)
+
+ def check_selection(self):
+ if not self.get_elements():
+ return
+
+ if not self.selected:
+ # L10N auto-route satin columns extension
+ inkex.errormsg(_("Please select one or more satin columns."))
+ return False
+
+ return True
+
+ def create_group(self):
+ first = self.elements[0].node
+ parent = first.getparent()
+ insert_index = parent.index(first)
+ group = inkex.etree.Element(SVG_GROUP_TAG, {
+ "transform": get_correction_transform(parent, child=True)
+ })
+ parent.insert(insert_index, group)
+
+ return group
+
+ def auto_satin(self):
+ starting_point = self.get_starting_point()
+ ending_point = self.get_ending_point()
+ return auto_satin(self.elements, self.options.preserve_order, starting_point, ending_point)
+
+ def remove_original_satins(self):
+ for element in self.elements:
+ for command in element.commands:
+ command.connector.getparent().remove(command.connector)
+ command.use.getparent().remove(command.use)
+ element.node.getparent().remove(element.node)
+
+ def add_elements(self, group, new_elements):
+ for i, element in enumerate(new_elements):
+ if isinstance(element, SatinColumn):
+ element.node.set("id", self.uniqueId("autosatin"))
+
+ # L10N Label for a satin column created by Auto-Route Satin Columns extension
+ element.node.set(INKSCAPE_LABEL, _("AutoSatin %d") % (i + 1))
+ else:
+ element.node.set("id", self.uniqueId("autosatinrun"))
+
+ # L10N Label for running stitch (underpathing) created by Auto-Route Satin Columns extension
+ element.node.set(INKSCAPE_LABEL, _("AutoSatin Running Stitch %d") % (i + 1))
+
+ group.append(element.node)
+
+ def add_trims(self, new_elements, trim_indices):
+ if self.options.trim and trim_indices:
+ self.ensure_symbol("trim")
+ for i in trim_indices:
+ self.add_commands(new_elements[i], ["trim"])
diff --git a/lib/extensions/base.py b/lib/extensions/base.py
index 25de441f8..b9bba617a 100644
--- a/lib/extensions/base.py
+++ b/lib/extensions/base.py
@@ -1,14 +1,15 @@
-import inkex
-import re
-import json
-from copy import deepcopy
from collections import MutableMapping
+from copy import deepcopy
+import json
+import re
+
+import inkex
from stringcase import snakecase
-from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_GROUPMODE, SVG_DEFS_TAG, EMBROIDERABLE_TAGS, SVG_POLYLINE_TAG
-from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement
from ..commands import is_command, layer_commands
+from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement
from ..i18n import _
+from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_GROUPMODE, SVG_DEFS_TAG, EMBROIDERABLE_TAGS, SVG_POLYLINE_TAG
SVG_METADATA_TAG = inkex.addNS("metadata", "svg")
@@ -21,7 +22,7 @@ def strip_namespace(tag):
<<< namedview
"""
- match = re.match('^\{[^}]+\}(.+)$', tag)
+ match = re.match(r'^\{[^}]+\}(.+)$', tag)
if match:
return match.group(1)
@@ -211,8 +212,20 @@ class InkstitchExtension(inkex.Effect):
return svg_filename
+ def uniqueId(self, prefix, make_new_id=True):
+ """Override inkex.Effect.uniqueId with a nicer naming scheme."""
+ i = 1
+ while True:
+ new_id = "%s%d" % (prefix, i)
+ if new_id not in self.doc_ids:
+ break
+ i += 1
+ self.doc_ids[new_id] = 1
+
+ return new_id
+
def parse(self):
- """Override inkex.Effect to add Ink/Stitch xml namespace"""
+ """Override inkex.Effect.parse to add Ink/Stitch xml namespace"""
# SVG parsers don't actually look for anything at this URL. They just
# care that it's unique. That defines a "namespace" of element and
diff --git a/lib/extensions/commands.py b/lib/extensions/commands.py
index fb6f78742..07b450e1a 100644
--- a/lib/extensions/commands.py
+++ b/lib/extensions/commands.py
@@ -1,13 +1,21 @@
import os
import inkex
from copy import deepcopy
+from random import random
+
from .base import InkstitchExtension
from ..utils import get_bundled_dir, cache
-from ..svg.tags import SVG_DEFS_TAG
+from ..commands import get_command_description
+from ..i18n import _
+from ..svg.tags import SVG_DEFS_TAG, SVG_PATH_TAG, CONNECTION_START, CONNECTION_END, \
+ CONNECTOR_TYPE, INKSCAPE_LABEL, SVG_GROUP_TAG, SVG_USE_TAG, XLINK_HREF
+from ..svg import get_correction_transform
class CommandsExtension(InkstitchExtension):
+ """Base class for extensions that manipulate commands."""
+
def __init__(self, *args, **kwargs):
InkstitchExtension.__init__(self, *args, **kwargs)
for command in self.COMMANDS:
@@ -37,3 +45,84 @@ class CommandsExtension(InkstitchExtension):
path = "./*[@id='inkstitch_%s']" % command
if self.defs.find(path) is None:
self.defs.append(deepcopy(self.symbol_defs.find(path)))
+
+ def add_connector(self, symbol, 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'))
+ end_pos = element.shape.centroid
+
+ path = inkex.etree.Element(SVG_PATH_TAG,
+ {
+ "id": self.uniqueId("connector"),
+ "d": "M %s,%s %s,%s" % (start_pos[0], start_pos[1], end_pos.x, end_pos.y),
+ "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")
+ }
+ )
+
+ symbol.getparent().insert(0, path)
+
+ def get_command_pos(self, element, index, total):
+ # Put command symbols 30 pixels out from the shape, spaced evenly around it.
+
+ # get a line running 30 pixels out from the shape
+ outline = element.shape.buffer(30).exterior
+
+ # pick this item's spot arond the outline and perturb it a bit to avoid
+ # stacking up commands if they run the extension multiple times
+ position = index / float(total)
+ position += random() * 0.1
+
+ return outline.interpolate(position, normalized=True)
+
+ def remove_legacy_param(self, element, command):
+ if command == "trim" or command == "stop":
+ # If they had the old "TRIM after" or "STOP after" attributes set,
+ # automatically delete them. THe new commands will do the same
+ # thing.
+ #
+ # If we didn't delete these here, then things would get confusing.
+ # If the user were to delete a "trim" symbol added by this extension
+ # but the "embroider_trim_after" attribute is still set, then the
+ # trim would keep happening.
+
+ attribute = "embroider_%s_after" % command
+
+ if attribute in element.node.attrib:
+ del element.node.attrib[attribute]
+
+ def add_commands(self, element, commands):
+ for i, command in enumerate(commands):
+ self.remove_legacy_param(element, command)
+
+ pos = self.get_command_pos(element, i, len(commands))
+
+ group = inkex.etree.SubElement(element.node.getparent(), SVG_GROUP_TAG,
+ {
+ "id": self.uniqueId("group"),
+ INKSCAPE_LABEL: _("Ink/Stitch Command") + ": %s" % get_command_description(command),
+ "transform": get_correction_transform(element.node)
+ }
+ )
+
+ symbol = inkex.etree.SubElement(group, SVG_USE_TAG,
+ {
+ "id": self.uniqueId("use"),
+ XLINK_HREF: "#inkstitch_%s" % command,
+ "height": "100%",
+ "width": "100%",
+ "x": str(pos.x),
+ "y": str(pos.y),
+
+ # l10n: the name of a command symbol (example: scissors icon for trim command)
+ INKSCAPE_LABEL: _("command marker"),
+ }
+ )
+
+ self.add_connector(symbol, element)
diff --git a/lib/extensions/cut_satin.py b/lib/extensions/cut_satin.py
index 0bef794ed..b776a68c8 100644
--- a/lib/extensions/cut_satin.py
+++ b/lib/extensions/cut_satin.py
@@ -3,6 +3,7 @@ import inkex
from .base import InkstitchExtension
from ..i18n import _
from ..elements import SatinColumn
+from ..svg import get_correction_transform
class CutSatin(InkstitchExtension):
@@ -29,9 +30,11 @@ class CutSatin(InkstitchExtension):
command.connector.getparent().remove(command.connector)
new_satins = satin.split(split_point)
+ transform = get_correction_transform(satin.node)
parent = satin.node.getparent()
index = parent.index(satin.node)
parent.remove(satin.node)
for new_satin in new_satins:
+ new_satin.node.set('transform', transform)
parent.insert(index, new_satin.node)
index += 1
diff --git a/lib/extensions/layer_commands.py b/lib/extensions/layer_commands.py
index dbafc39f4..60a5fab21 100644
--- a/lib/extensions/layer_commands.py
+++ b/lib/extensions/layer_commands.py
@@ -35,12 +35,12 @@ class LayerCommands(CommandsExtension):
inkex.etree.SubElement(self.current_layer, SVG_USE_TAG,
{
- "id": self.uniqueId("use"),
- INKSCAPE_LABEL: _("Ink/Stitch Command") + ": %s" % get_command_description(command),
- XLINK_HREF: "#inkstitch_%s" % command,
- "height": "100%",
- "width": "100%",
- "x": str(i * 20),
- "y": "-10",
- "transform": correction_transform
+ "id": self.uniqueId("use"),
+ INKSCAPE_LABEL: _("Ink/Stitch Command") + ": %s" % get_command_description(command),
+ XLINK_HREF: "#inkstitch_%s" % command,
+ "height": "100%",
+ "width": "100%",
+ "x": str(i * 20),
+ "y": "-10",
+ "transform": correction_transform
})
diff --git a/lib/extensions/object_commands.py b/lib/extensions/object_commands.py
index e678890d4..47fb361df 100644
--- a/lib/extensions/object_commands.py
+++ b/lib/extensions/object_commands.py
@@ -1,97 +1,13 @@
import inkex
-from random import random
from .commands import CommandsExtension
-from ..commands import OBJECT_COMMANDS, get_command_description
+from ..commands import OBJECT_COMMANDS
from ..i18n import _
-from ..svg.tags import SVG_PATH_TAG, CONNECTION_START, CONNECTION_END, CONNECTOR_TYPE, INKSCAPE_LABEL, SVG_GROUP_TAG, SVG_USE_TAG, XLINK_HREF
-from ..svg import get_correction_transform
class ObjectCommands(CommandsExtension):
COMMANDS = OBJECT_COMMANDS
- def add_connector(self, symbol, 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'))
- end_pos = element.shape.centroid
-
- path = inkex.etree.Element(SVG_PATH_TAG,
- {
- "id": self.uniqueId("connector"),
- "d": "M %s,%s %s,%s" % (start_pos[0], start_pos[1], end_pos.x, end_pos.y),
- "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")
- }
- )
-
- symbol.getparent().insert(0, path)
-
- def get_command_pos(self, element, index, total):
- # Put command symbols 30 pixels out from the shape, spaced evenly around it.
-
- # get a line running 30 pixels out from the shape
- outline = element.shape.buffer(30).exterior
-
- # pick this item's spot arond the outline and perturb it a bit to avoid
- # stacking up commands if they run the extension multiple times
- position = index / float(total)
- position += random() * 0.1
-
- return outline.interpolate(position, normalized=True)
-
- def remove_legacy_param(self, element, command):
- if command == "trim" or command == "stop":
- # If they had the old "TRIM after" or "STOP after" attributes set,
- # automatically delete them. THe new commands will do the same
- # thing.
- #
- # If we didn't delete these here, then things would get confusing.
- # If the user were to delete a "trim" symbol added by this extension
- # but the "embroider_trim_after" attribute is still set, then the
- # trim would keep happening.
-
- attribute = "embroider_%s_after" % command
-
- if attribute in element.node.attrib:
- del element.node.attrib[attribute]
-
- def add_commands(self, element, commands):
- for i, command in enumerate(commands):
- self.remove_legacy_param(element, command)
-
- pos = self.get_command_pos(element, i, len(commands))
-
- group = inkex.etree.SubElement(element.node.getparent(), SVG_GROUP_TAG,
- {
- "id": self.uniqueId("group"),
- INKSCAPE_LABEL: _("Ink/Stitch Command") + ": %s" % get_command_description(command),
- "transform": get_correction_transform(element.node)
- }
- )
-
- symbol = inkex.etree.SubElement(group, SVG_USE_TAG,
- {
- "id": self.uniqueId("use"),
- XLINK_HREF: "#inkstitch_%s" % command,
- "height": "100%",
- "width": "100%",
- "x": str(pos.x),
- "y": str(pos.y),
-
- # l10n: the name of a command symbol (example: scissors icon for trim command)
- INKSCAPE_LABEL: _("command marker"),
- }
- )
-
- self.add_connector(symbol, element)
-
def effect(self):
if not self.get_elements():
return
diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py
index d2ff04463..e052d1443 100644
--- a/lib/stitches/__init__.py
+++ b/lib/stitches/__init__.py
@@ -1,3 +1,6 @@
from running_stitch import *
from auto_fill import auto_fill
from fill import legacy_fill
+
+# Can't put this here because we get a circular import :(
+#from auto_satin import auto_satin
diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py
new file mode 100644
index 000000000..59bf6b0ad
--- /dev/null
+++ b/lib/stitches/auto_satin.py
@@ -0,0 +1,573 @@
+from itertools import chain
+import math
+
+import cubicsuperpath
+import inkex
+from shapely import geometry as shgeo
+from shapely.geometry import Point as ShapelyPoint
+import simplestyle
+
+import networkx as nx
+
+from ..elements import Stroke
+from ..svg import PIXELS_PER_MM, line_strings_to_csp
+from ..svg.tags import SVG_PATH_TAG
+from ..utils import Point as InkstitchPoint, cut, cache
+
+
+class SatinSegment(object):
+ """A portion of SatinColumn.
+
+ Attributes:
+ satin -- the SatinColumn instance
+ start -- how far along the satin this graph edge starts (a float from 0.0 to 1.0)
+ end -- how far along the satin this graph edge ends (a float from 0.0 to 1.0)
+ reverse -- if True, reverse the direction of the satin
+ """
+
+ def __init__(self, satin, start=0.0, end=1.0, reverse=False):
+ """Initialize a SatinEdge.
+
+ Arguments:
+ satin -- the SatinColumn instance
+ start, end -- a tuple or Point falling somewhere on the
+ satin column, OR a floating point specifying a
+ normalized projection of a distance along the satin
+ (0.0 to 1.0 inclusive)
+ reverse -- boolean
+ """
+
+ self.satin = satin
+ self.reverse = reverse
+
+ # start and end are stored as normalized projections
+ self.start = self._parse_init_param(start)
+ self.end = self._parse_init_param(end)
+
+ if self.start > self.end:
+ self.end, self.start = self.start, self.end
+ self.reverse = True
+
+ def _parse_init_param(self, param):
+ if isinstance(param, (float, int)):
+ return param
+ elif isinstance(param, (tuple, InkstitchPoint, ShapelyPoint)):
+ return self.satin.center.project(ShapelyPoint(param), normalized=True)
+
+ def to_satin(self):
+ satin = self.satin
+
+ if self.start > 0.0:
+ before, satin = satin.split(self.start)
+
+ if self.end < 1.0:
+ satin, after = satin.split(
+ (self.end - self.start) / (1.0 - self.start))
+
+ if self.reverse:
+ satin = satin.reverse()
+
+ satin = satin.apply_transform()
+
+ return satin
+
+ to_element = to_satin
+
+ def to_running_stitch(self):
+ return RunningStitch(self.center_line, self.satin)
+
+ def break_up(self, segment_size):
+ """Break this SatinSegment up into SatinSegments of the specified size."""
+
+ num_segments = int(math.ceil(self.center_line.length / segment_size))
+ segments = []
+ satin = self.to_satin()
+ for i in xrange(num_segments):
+ segments.append(SatinSegment(satin, float(
+ i) / num_segments, float(i + 1) / num_segments, self.reverse))
+
+ if self.reverse:
+ segments.reverse()
+
+ return segments
+
+ def reversed(self):
+ """Return a copy of this SatinSegment in the opposite direction."""
+ return SatinSegment(self.satin, self.start, self.end, not self.reverse)
+
+ @property
+ def center_line(self):
+ center_line = self.satin.center_line
+
+ if self.start < 1.0:
+ before, center_line = cut(center_line, self.start, normalized=True)
+
+ if self.end > 0.0:
+ center_line, after = cut(
+ center_line, (self.end - self.start) / (1.0 - self.start), normalized=True)
+
+ if self.reverse:
+ center_line = shgeo.LineString(reversed(center_line.coords))
+
+ return center_line
+
+ @property
+ @cache
+ def start_point(self):
+ return self.satin.center_line.interpolate(self.start, normalized=True)
+
+ @property
+ @cache
+ def end_point(self):
+ return self.satin.center_line.interpolate(self.end, normalized=True)
+
+ def is_sequential(self, other):
+ """Check if a satin segment immediately follows this one on the same satin."""
+
+ if not isinstance(other, SatinSegment):
+ return False
+
+ if self.satin is not other.satin:
+ return False
+
+ if self.reverse != other.reverse:
+ return False
+
+ if self.reverse:
+ return self.start == other.end
+ else:
+ return self.end == other.start
+
+ def __add__(self, other):
+ """Combine two sequential SatinSegments.
+
+ If self.is_sequential(other) is not True then adding results in
+ undefined behavior.
+ """
+ if self.reverse:
+ return SatinSegment(self.satin, other.start, self.end, reverse=True)
+ else:
+ return SatinSegment(self.satin, self.start, other.end)
+
+ def __eq__(self, other):
+ # Two SatinSegments are equal if they refer to the same section of the same
+ # satin (even if in opposite directions).
+ return self.satin is other.satin and self.start == other.start and self.end == other.end
+
+ def __hash__(self):
+ return hash((id(self.satin), self.start, self.end))
+
+ def __repr__(self):
+ return "SatinSegment(%s, %s, %s, %s)" % (self.satin, self.start, self.end, self.reverse)
+
+
+class JumpStitch(object):
+ """A jump stitch between two points."""
+
+ def __init__(self, start, end):
+ """Initialize a JumpStitch.
+
+ Arguments:
+ start, end -- instances of shgeo.Point
+ """
+
+ self.start = start
+ self.end = end
+
+ def is_sequential(self, other):
+ # Don't bother joining jump stitches.
+ return False
+
+ @property
+ @cache
+ def length(self):
+ return self.start.distance(self.end)
+
+
+class RunningStitch(object):
+ """Running stitch along a path."""
+
+ def __init__(self, path_or_stroke, original_element=None):
+ if isinstance(path_or_stroke, Stroke):
+ # Technically a Stroke object's underlying path could have multiple
+ # subpaths. We don't have a particularly good way of dealing with
+ # that so we'll just use the first one.
+ self.path = path_or_stroke.shape.geoms[0]
+ original_element = path_or_stroke
+ else:
+ self.path = path_or_stroke
+
+ self.original_element = original_element
+ self.style = original_element.node.get('style', '')
+ self.running_stitch_length = \
+ original_element.node.get('embroider_running_stitch_length_mm', '') or \
+ original_element.node.get('embroider_center_walk_underlay_stitch_length_mm', '') or \
+ original_element.node.get('embroider_contour_underlay_stitch_length_mm', '')
+
+ def to_element(self):
+ node = inkex.etree.Element(SVG_PATH_TAG)
+ node.set("d", cubicsuperpath.formatPath(
+ line_strings_to_csp([self.path])))
+
+ style = simplestyle.parseStyle(self.style)
+ style['stroke-dasharray'] = "0.5,0.5"
+ style = simplestyle.formatStyle(style)
+ node.set("style", style)
+
+ node.set("embroider_running_stitch_length_mm", self.running_stitch_length)
+
+ return Stroke(node)
+
+ @property
+ @cache
+ def start_point(self):
+ return self.path.interpolate(0.0, normalized=True)
+
+ @property
+ @cache
+ def end_point(self):
+ return self.path.interpolate(1.0, normalized=True)
+
+ @cache
+ def reversed(self):
+ return RunningStitch(shgeo.LineString(reversed(self.path.coords)), self.style)
+
+ def is_sequential(self, other):
+ if not isinstance(other, RunningStitch):
+ return False
+
+ return self.path.distance(other.path) < 0.5
+
+ def __add__(self, other):
+ new_path = shgeo.LineString(chain(self.path.coords, other.path.coords))
+ return RunningStitch(new_path, self.original_element)
+
+
+def auto_satin(elements, preserve_order=False, starting_point=None, ending_point=None):
+ """Find an optimal order to stitch a list of SatinColumns.
+
+ Add running stitch and jump stitches as necessary to construct a stitch
+ order. Cut satins as necessary to minimize jump stitch length.
+
+ For example, consider three satins making up the letters "PO":
+
+ * one vertical satin for the "P"
+ * the loop of the "P"
+ * the "O"
+
+ A good stitch path would be:
+
+ 1. up the leg
+ 2. down through half of the loop
+ 3. running stitch to the bottom of the loop
+ 4. satin stitch back up to the middle of the loop
+ 5. jump to the closest point on the O
+ 6. satin stitch around the O
+
+ If passed, stitching will start from starting_point and end at
+ ending_point. It is expected that the starting and ending points will
+ fall on satin columns in the list. If they don't, the nearest
+ point on a satin column in the list will be used.
+
+ If preserve_order is True, then the algorithm is constrained to keep the
+ satins in the same order they were in the original list. It will only split
+ them and add running stitch as necessary to achieve an optimal stitch path.
+
+ Elements should be primarily made up of SatinColumn instances. Some Stroke
+ instances (that are running stitch) can be included to indicate how to travel
+ between two SatinColumns. This works best when preserve_order is True.
+
+ Returns: a list of SVG path nodes making up the selected stitch order.
+ Jumps between objects are implied if they are not right next to each
+ other.
+ """
+
+ graph = build_graph(elements, preserve_order)
+ add_jumps(graph, elements, preserve_order)
+ starting_node, ending_node = get_starting_and_ending_nodes(
+ graph, elements, preserve_order, starting_point, ending_point)
+ path = find_path(graph, starting_node, ending_node)
+ operations = path_to_operations(graph, path)
+ operations = collapse_sequential_segments(operations)
+ return operations_to_elements_and_trims(operations)
+
+
+def build_graph(elements, preserve_order=False):
+ if preserve_order:
+ graph = nx.DiGraph()
+ else:
+ graph = nx.Graph()
+
+ # Take each satin and dice it up into pieces 1mm long. This allows many
+ # possible spots for jump-stitches between satins. NetworkX will find the
+ # best spots for us.
+
+ for element in elements:
+ segments = []
+ if isinstance(element, Stroke):
+ segments.append(RunningStitch(element))
+ else:
+ whole_satin = SatinSegment(element)
+ segments = whole_satin.break_up(PIXELS_PER_MM)
+
+ 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_edge(str(segment.start_point), str(
+ segment.end_point), segment=segment, element=element)
+
+ if preserve_order:
+ # The graph is a directed graph, but we want to allow travel in
+ # any direction in a satin, so we add the edge in the opposite
+ # direction too.
+ graph.add_edge(str(segment.end_point), str(
+ segment.start_point), segment=segment, element=element)
+
+ 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))
+
+ edge = min(potential_edges, key=lambda (p1, p2): p1.distance(p2))
+ 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(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.
+
+ Example:
+
+ [(1, 2), (2, 3), (3, 4), (4, 5), (5, 1)] =>
+ [(1, 5), (5, 4), (4, 3), (3, 2), (2, 1)]
+ """
+
+ for node1, node2 in reversed(path):
+ yield (node2, node1)
+
+
+def path_to_operations(graph, path):
+ """Convert an edge path to a list of SatinSegment and JumpStitch instances."""
+
+ graph = nx.Graph(graph)
+
+ operations = []
+
+ for start, end in path:
+ segment = graph[start][end].get('segment')
+ if segment:
+ start_point = graph.nodes[start]['point']
+ if segment.start_point != start_point:
+ segment = segment.reversed()
+ operations.append(segment)
+ else:
+ operations.append(JumpStitch(graph.nodes[start]['point'], graph.nodes[end]['point']))
+
+ # 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
+ # path, we'll sew the first occurrence as running stitch. It will later be
+ # covered by the satin stitch.
+ seen = set()
+
+ for i, item in reversed(list(enumerate(operations))):
+ if isinstance(item, SatinSegment):
+ if item in seen:
+ operations[i] = item.to_running_stitch()
+ else:
+ seen.add(item)
+
+ return operations
+
+
+def collapse_sequential_segments(old_operations):
+ old_operations = iter(old_operations)
+ new_operations = [next(old_operations)]
+
+ for operation in old_operations:
+ if new_operations[-1].is_sequential(operation):
+ new_operations[-1] += operation
+ else:
+ new_operations.append(operation)
+
+ return new_operations
+
+
+def operations_to_elements_and_trims(operations):
+ """Convert a list of operations to Elements and locations of trims.
+
+ Returns:
+ (nodes, trims)
+
+ element -- a list of Element instances
+ trims -- indices of nodes after which the thread should be trimmed
+ """
+
+ elements = []
+ trims = []
+
+ for operation in operations:
+ # Ignore JumpStitch opertions. Jump stitches in Ink/Stitch are
+ # implied and added by Embroider if needed.
+ if isinstance(operation, (SatinSegment, RunningStitch)):
+ elements.append(operation.to_element())
+ elif isinstance(operation, (JumpStitch)):
+ if elements and operation.length > PIXELS_PER_MM:
+ trims.append(len(elements) - 1)
+
+ return elements, list(set(trims))
diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py
index a56fcca7c..74a409b61 100644
--- a/lib/svg/__init__.py
+++ b/lib/svg/__init__.py
@@ -1,3 +1,3 @@
from .svg import color_block_to_point_lists, render_stitch_plan
from .units import *
-from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp
+from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp, point_lists_to_csp
diff --git a/lib/svg/path.py b/lib/svg/path.py
index abaeda525..6212211ff 100644
--- a/lib/svg/path.py
+++ b/lib/svg/path.py
@@ -50,13 +50,19 @@ def get_correction_transform(node, child=False):
def line_strings_to_csp(line_strings):
+ return point_lists_to_csp(ls.coords for ls in line_strings)
+
+
+def point_lists_to_csp(point_lists):
csp = []
- for ls in line_strings:
+ for point_list in point_lists:
subpath = []
- for point in ls.coords:
+ for point in point_list:
+ # cubicsuperpath is very particular that these must be lists, not tuples
+ point = list(point)
# create a straight line as a degenerate bezier
- subpath.append((point, point, point))
+ subpath.append([point, point, point])
csp.append(subpath)
return csp
diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py
index 64f6f16f4..ab7f24c11 100644
--- a/lib/utils/geometry.py
+++ b/lib/utils/geometry.py
@@ -2,11 +2,14 @@ from shapely.geometry import LineString, Point as ShapelyPoint
import math
-def cut(line, distance):
+def cut(line, distance, normalized=False):
""" Cuts a LineString in two at a distance from its starting point.
This is an example in the Shapely documentation.
"""
+ if normalized:
+ distance *= line.length
+
if distance <= 0.0:
return [None, line]
elif distance >= line.length:
@@ -47,6 +50,14 @@ def cut_path(points, length):
return [Point(*point) for point in subpath.coords]
+def collapse_duplicate_point(geometry):
+ if hasattr(geometry, 'geoms'):
+ if geometry.area < 0.01:
+ return geometry.representative_point()
+
+ return geometry
+
+
class Point:
def __init__(self, x, y):
self.x = x
diff --git a/requirements.txt b/requirements.txt
index 84bb1d510..e1ab6e084 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,7 @@
./pyembroidery
backports.functools_lru_cache
wxPython
-networkx
+networkx==2.2
shapely
lxml
appdirs
diff --git a/symbols/inkstitch.svg b/symbols/inkstitch.svg
index 980d1c769..4a67ae1cd 100644
--- a/symbols/inkstitch.svg
+++ b/symbols/inkstitch.svg
@@ -25,9 +25,9 @@
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
- inkscape:zoom="7.9999996"
- inkscape:cx="306.93962"
- inkscape:cy="286.56855"
+ inkscape:zoom="1.9999999"
+ inkscape:cx="160.57712"
+ inkscape:cy="279.26165"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
@@ -254,6 +254,48 @@
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#242424;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.4000001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
inkscape:connector-curvature="0" />
+
+ Satin column starting point
+
+
+
+
+
+ Satin column ending point
+
+
+
+
@@ -356,5 +398,21 @@
width="100%"
height="100%"
transform="translate(340.1839,75.511321)" />
+
+
diff --git a/templates/auto_satin.inx b/templates/auto_satin.inx
new file mode 100644
index 000000000..d825d8a11
--- /dev/null
+++ b/templates/auto_satin.inx
@@ -0,0 +1,23 @@
+
+
+ {% trans %}Auto-Route Satin Columns{% endtrans %}
+ org.inkstitch.auto_satin.{{ locale }}
+ inkstitch.py
+ inkex.py
+ true
+ false
+ auto_satin
+
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/convert_to_satin.inx b/templates/convert_to_satin.inx
index 50886d4a3..d214502a9 100644
--- a/templates/convert_to_satin.inx
+++ b/templates/convert_to_satin.inx
@@ -9,7 +9,9 @@
all
-
+
+
+
diff --git a/templates/cut_satin.inx b/templates/cut_satin.inx
index b8d9e5a51..4d330f062 100644
--- a/templates/cut_satin.inx
+++ b/templates/cut_satin.inx
@@ -9,7 +9,9 @@
all
-
+
+
+
diff --git a/templates/flip.inx b/templates/flip.inx
index ebeb40906..ef2000d67 100644
--- a/templates/flip.inx
+++ b/templates/flip.inx
@@ -1,6 +1,6 @@
- {% trans %}Flip Satin Columns{% endtrans %}
+ {% trans %}Flip Satin Column Rails{% endtrans %}
org.inkstitch.flip_satins.{{ locale }}
inkstitch.py
inkex.py
@@ -9,7 +9,9 @@
all
-
+
+
+