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 - + + +