diff options
Diffstat (limited to 'lib')
36 files changed, 1247 insertions, 473 deletions
diff --git a/lib/commands.py b/lib/commands.py new file mode 100644 index 00000000..02c13b25 --- /dev/null +++ b/lib/commands.py @@ -0,0 +1,86 @@ +import inkex +import cubicsuperpath + +from .svg import apply_transforms +from .svg.tags import SVG_USE_TAG, SVG_SYMBOL_TAG, CONNECTION_START, CONNECTION_END, XLINK_HREF + + +class Command(object): + def __init__(self, connector): + self.connector = connector + self.svg = self.connector.getroottree().getroot() + + self.parse_command() + + def get_node_by_url(self, url): + # url will be #path12345. Find the object at the other end. + + if url is None: + raise ValueError("url is None") + + if not url.startswith('#'): + raise ValueError("invalid connection url: %s" % url) + + id = url[1:] + + try: + return self.svg.xpath(".//*[@id='%s']" % id)[0] + except (IndexError, AttributeError): + raise ValueError("could not find node by url %s" % id) + + def parse_connector_path(self): + path = cubicsuperpath.parsePath(self.connector.get('d')) + return apply_transforms(path, self.connector) + + def parse_command(self): + path = self.parse_connector_path() + + neighbors = [ + (self.get_node_by_url(self.connector.get(CONNECTION_START)), path[0][0][1]), + (self.get_node_by_url(self.connector.get(CONNECTION_END)), path[0][-1][1]) + ] + + if neighbors[0][0].tag != SVG_USE_TAG: + neighbors.reverse() + + if neighbors[0][0].tag != SVG_USE_TAG: + raise ValueError("connector does not point to a use tag") + + self.symbol = self.get_node_by_url(neighbors[0][0].get(XLINK_HREF)) + + if self.symbol.tag != SVG_SYMBOL_TAG: + raise ValueError("use points to non-symbol") + + self.command = self.symbol.get('id') + + if self.command.startswith('inkstitch_'): + self.command = self.command[10:] + else: + raise ValueError("symbol is not an Ink/Stitch command") + + self.target = neighbors[1][0] + self.target_point = neighbors[1][1] + + def __repr__(self): + return "Command('%s', %s)" % (self.command, self.target_point) + +def find_commands(node): + """Find the symbols this node is connected to and return them as Commands""" + + # find all paths that have this object as a connection + xpath = ".//*[@inkscape:connection-start='#%(id)s' or @inkscape:connection-end='#%(id)s']" % dict(id=node.get('id')) + connectors = node.getroottree().getroot().xpath(xpath, namespaces=inkex.NSS) + + # try to turn them into commands + commands = [] + for connector in connectors: + try: + commands.append(Command(connector)) + except ValueError: + # Parsing the connector failed, meaning it's not actually an Ink/Stitch command. + pass + + return commands + +def is_command(node): + return CONNECTION_START in node.attrib or CONNECTION_END in node.attrib diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py index 08ae67f7..59816878 100644 --- a/lib/elements/auto_fill.py +++ b/lib/elements/auto_fill.py @@ -63,27 +63,65 @@ class AutoFill(Fill): return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length @property - @param('fill_underlay_inset_mm', _('Inset'), unit='mm', group=_('AutoFill Underlay'), type='float', default=0) + @param('fill_underlay_inset_mm', + _('Inset'), + tooltip='Shrink the shape before doing underlay, to prevent underlay from showing around the outside of the fill.', + unit='mm', + group=_('AutoFill Underlay'), + type='float', + default=0) def fill_underlay_inset(self): return self.get_float_param('fill_underlay_inset_mm', 0) @property - def underlay_shape(self): - if self.fill_underlay_inset: - shape = self.shape.buffer(-self.fill_underlay_inset) + @param('expand_mm', + _('Expand'), + tooltip='Expand the shape before fill stitching, to compensate for gaps between shapes.', + unit='mm', + type='float', + default=0) + def expand(self): + return self.get_float_param('expand_mm', 0) + + def shrink_or_grow_shape(self, amount): + if amount: + shape = self.shape.buffer(amount) if not isinstance(shape, shgeo.MultiPolygon): shape = shgeo.MultiPolygon([shape]) return shape else: return self.shape + @property + def underlay_shape(self): + return self.shrink_or_grow_shape(-self.fill_underlay_inset) + + @property + def fill_shape(self): + return self.shrink_or_grow_shape(self.expand) + + def get_starting_point(self, last_patch): + # If there is a "fill_start" Command, then use that; otherwise pick + # the point closest to the end of the last patch. + + if self.get_command('fill_start'): + return self.get_command('fill_start').target_point + elif last_patch: + return last_patch.stitches[-1] + else: + return None + + def get_ending_point(self): + if self.get_command('fill_end'): + return self.get_command('fill_end').target_point + else: + return None + def to_patches(self, last_patch): stitches = [] - if last_patch is None: - starting_point = None - else: - starting_point = last_patch.stitches[-1] + starting_point = self.get_starting_point(last_patch) + ending_point = self.get_ending_point() if self.fill_underlay: stitches.extend(auto_fill(self.underlay_shape, @@ -96,13 +134,14 @@ class AutoFill(Fill): starting_point)) starting_point = stitches[-1] - stitches.extend(auto_fill(self.shape, + stitches.extend(auto_fill(self.fill_shape, self.angle, self.row_spacing, self.end_row_spacing, self.max_stitch_length, self.running_stitch_length, self.staggers, - starting_point)) + starting_point, + ending_point)) return [Patch(stitches=stitches, color=self.color)] diff --git a/lib/elements/element.py b/lib/elements/element.py index 300136dd..ebca90a4 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -4,7 +4,8 @@ from shapely import geometry as shgeo from ..i18n import _ from ..utils import cache -from ..svg import PIXELS_PER_MM, get_viewbox_transform, convert_length, get_doc_size +from ..svg import PIXELS_PER_MM, convert_length, get_doc_size, apply_transforms +from ..commands import find_commands # inkscape-provided utilities import simpletransform @@ -29,6 +30,10 @@ class Patch: else: raise TypeError("Patch can only be added to another Patch") + def __len__(self): + # This method allows `len(patch)` and `if patch: + return len(self.stitches) + def add_stitch(self, stitch): self.stitches.append(stitch) @@ -36,7 +41,6 @@ class Patch: return Patch(self.color, self.stitches[::-1]) - class Param(object): def __init__(self, name, description, unit=None, values=[], type=None, group=None, inverse=False, default=None, tooltip=None, sort_index=0): self.name = name @@ -132,10 +136,10 @@ class EmbroideryElement(object): self.node.set("embroider_%s" % name, str(value)) @cache - def get_style(self, style_name): + def get_style(self, style_name, default=None): style = simplestyle.parseStyle(self.node.get("style")) if (style_name not in style): - return None + return default value = style[style_name] if value == 'none': return None @@ -158,7 +162,7 @@ class EmbroideryElement(object): @property @cache def stroke_width(self): - width = self.get_style("stroke-width") + width = self.get_style("stroke-width", "1") if width is None: return 1.0 @@ -168,10 +172,6 @@ class EmbroideryElement(object): @property def path(self): - return cubicsuperpath.parsePath(self.node.get("d")) - - @cache - def parse_path(self): # A CSP is a "cubic superpath". # # A "path" is a sequence of strung-together bezier curves. @@ -199,22 +199,40 @@ class EmbroideryElement(object): # In a path, each element in the 3-tuple is itself a tuple of (x, y). # Tuples all the way down. Hasn't anyone heard of using classes? - path = self.path + return cubicsuperpath.parsePath(self.node.get("d")) + + @cache + def parse_path(self): + return apply_transforms(self.path, self.node) - # start with the identity transform - transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + @property + def shape(self): + raise NotImplementedError("INTERNAL ERROR: %s must implement shape()", self.__class__) - # combine this node's transform with all parent groups' transforms - transform = simpletransform.composeParents(self.node, transform) + @property + @cache + def commands(self): + return find_commands(self.node) - # add in the transform implied by the viewBox - viewbox_transform = get_viewbox_transform(self.node.getroottree().getroot()) - transform = simpletransform.composeTransform(viewbox_transform, transform) + @cache + def get_commands(self, command): + return [c for c in self.commands if c.command == command] - # apply the combined transform to this node's path - simpletransform.applyTransformToPath(transform, path) + @cache + def has_command(self, command): + return len(self.get_commands(command)) > 0 - return path + @cache + def get_command(self, command): + commands = self.get_commands(command) + + if len(commands) == 1: + return commands[0] + elif len(commands) > 1: + raise ValueError(_("%(id)s has more than one command of type '%(command)s' linked to it") % + dict(id=self.node.get(id), command=command)) + else: + return None def strip_control_points(self, subpath): return [point for control_before, point, control_after in subpath] @@ -228,22 +246,10 @@ class EmbroideryElement(object): return [self.strip_control_points(subpath) for subpath in path] @property - @param('trim_after', - _('TRIM after'), - tooltip=_('Trim thread after this object (for supported machines and file formats)'), - type='boolean', - default=False, - sort_index=1000) def trim_after(self): return self.get_boolean_param('trim_after', False) @property - @param('stop_after', - _('STOP after'), - tooltip=_('Add STOP instruction after this object (for supported machines and file formats)'), - type='boolean', - default=False, - sort_index=1000) def stop_after(self): return self.get_boolean_param('stop_after', False) @@ -254,8 +260,8 @@ class EmbroideryElement(object): patches = self.to_patches(last_patch) if patches: - patches[-1].trim_after = self.trim_after - patches[-1].stop_after = self.stop_after + patches[-1].trim_after = self.has_command("trim") or self.trim_after + patches[-1].stop_after = self.has_command("stop") or self.stop_after return patches diff --git a/lib/elements/fill.py b/lib/elements/fill.py index 52a42260..8d1d35f2 100644 --- a/lib/elements/fill.py +++ b/lib/elements/fill.py @@ -27,7 +27,8 @@ class Fill(EmbroideryElement): @property def color(self): - return self.get_style("fill") + # SVG spec says the default fill is black + return self.get_style("fill", "#000000") @property @param('flip', _('Flip fill (start right-to-left)'), type='boolean', default=False) diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index 5c474237..b9ffdc0b 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -1,3 +1,5 @@ +from shapely import geometry as shgeo + from .element import param, EmbroideryElement, Patch from ..i18n import _ from ..utils.geometry import Point @@ -28,6 +30,11 @@ class Polyline(EmbroideryElement): return points @property + @cache + def shape(self): + return shgeo.LineString(self.points) + + @property def path(self): # A polyline is a series of connected line segments described by their # points. In order to make use of the existing logic for incorporating diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index 3593db64..2ceb38de 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -89,6 +89,17 @@ class SatinColumn(EmbroideryElement): @property @cache + def shape(self): + # This isn't used for satins at all, but other parts of the code + # may need to know the general shape of a satin column. + + flattened = self.flatten(self.parse_path()) + line_strings = [shgeo.LineString(path) for path in flattened] + + return shgeo.MultiLineString(line_strings) + + @property + @cache def csp(self): return self.parse_path() @@ -97,6 +108,8 @@ class SatinColumn(EmbroideryElement): def flattened_beziers(self): if len(self.csp) == 2: return self.simple_flatten_beziers() + elif len(self.csp) < 2: + self.fatal(_("satin column: %(id)s: at least two subpaths required (%(num)d found)") % dict(num=len(self.csp), id=self.node.get('id'))) else: return self.flatten_beziers_with_rungs() diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 48662b6d..e8eb4783 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -1,9 +1,11 @@ import sys +import shapely.geometry from .element import param, EmbroideryElement, Patch from ..i18n import _ from ..utils import cache, Point - +from ..stitches import running_stitch +from ..svg import parse_length_with_units warned_about_legacy_running_stitch = False @@ -50,6 +52,12 @@ class Stroke(EmbroideryElement): return self.flatten(path) @property + @cache + def shape(self): + line_strings = [shapely.geometry.LineString(path) for path in self.paths] + return shapely.geometry.MultiLineString(line_strings) + + @property @param('manual_stitch', _('Manual stitch placement'), tooltip=_("Stitch every node in the path. Stitch length and zig-zag spacing are ignored."), type='boolean', default=False) def manual_stitch_mode(self): return self.get_boolean_param('manual_stitch') @@ -57,10 +65,7 @@ class Stroke(EmbroideryElement): def is_running_stitch(self): # using stroke width <= 0.5 pixels to indicate running stitch is deprecated in favor of dashed lines - try: - stroke_width = float(self.get_style("stroke-width")) - except ValueError: - stroke_width = 1 + stroke_width, units = parse_length_with_units(self.get_style("stroke-width", "1")) if self.dashed: return True @@ -93,56 +98,50 @@ class Stroke(EmbroideryElement): else: return False - def stroke_points(self, emb_point_list, zigzag_spacing, stroke_width): - # TODO: use inkstitch.stitches.running_stitch + def simple_satin(self, path, zigzag_spacing, stroke_width): + "zig-zag along the path at the specified spacing and wdith" - patch = Patch(color=self.color) - p0 = emb_point_list[0] - rho = 0.0 - side = 1 - last_segment_direction = None + # `self.zigzag_spacing` is the length for a zig and a zag + # together (a V shape). Start with running stitch at half + # that length: + patch = self.running_stitch(path, zigzag_spacing / 2.0) - for repeat in xrange(self.repeats): - if repeat % 2 == 0: - order = range(1, len(emb_point_list)) - else: - order = range(-2, -len(emb_point_list) - 1, -1) + # Now move the points left and right. Consider each pair + # of points in turn, and move perpendicular to them, + # alternating left and right. - for segi in order: - p1 = emb_point_list[segi] + offset = stroke_width / 2.0 - # how far we have to go along segment - seg_len = (p1 - p0).length() - if (seg_len == 0): - continue + for i in xrange(len(patch) - 1): + start = patch.stitches[i] + end = patch.stitches[i + 1] + segment_direction = (end - start).unit() + zigzag_direction = segment_direction.rotate_left() - # vector pointing along segment - along = (p1 - p0).unit() + if i % 2 == 1: + zigzag_direction *= -1 - # vector pointing to edge of stroke width - perp = along.rotate_left() * (stroke_width * 0.5) + patch.stitches[i] += zigzag_direction * offset - if stroke_width == 0.0 and last_segment_direction is not None: - if abs(1.0 - along * last_segment_direction) > 0.5: - # if greater than 45 degree angle, stitch the corner - rho = zigzag_spacing - patch.add_stitch(p0) + return patch - # iteration variable: how far we are along segment - while (rho <= seg_len): - left_pt = p0 + along * rho + perp * side - patch.add_stitch(left_pt) - rho += zigzag_spacing - side = -side + def running_stitch(self, path, stitch_length): + repeated_path = [] - p0 = p1 - last_segment_direction = along - rho -= seg_len + # go back and forth along the path as specified by self.repeats + for i in xrange(self.repeats): + if i % 2 == 1: + # reverse every other pass + this_path = path[::-1] + else: + this_path = path[:] - if (p0 - patch.stitches[-1]).length() > 0.1: - patch.add_stitch(p0) + repeated_path.extend(this_path) + + stitches = running_stitch(repeated_path, stitch_length) + + return Patch(self.color, stitches) - return patch def to_patches(self, last_patch): patches = [] @@ -152,10 +151,11 @@ class Stroke(EmbroideryElement): if self.manual_stitch_mode: patch = Patch(color=self.color, stitches=path, stitch_as_is=True) elif self.is_running_stitch(): - patch = self.stroke_points(path, self.running_stitch_length, stroke_width=0.0) + patch = self.running_stitch(path, self.running_stitch_length) else: - patch = self.stroke_points(path, self.zigzag_spacing / 2.0, stroke_width=self.stroke_width) + patch = self.simple_satin(path, self.zigzag_spacing, self.stroke_width) - patches.append(patch) + if patch: + patches.append(patch) return patches diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index ebdd2fc9..8b243176 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -1,6 +1,10 @@ from embroider import Embroider -from palettes import Palettes +from install import Install from params import Params from print_pdf import Print from simulate import Simulate from input import Input +from output import Output +from zip import Zip +from flip import Flip +from commands import Commands diff --git a/lib/extensions/base.py b/lib/extensions/base.py index ff587ca5..d230f1b0 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -7,6 +7,7 @@ from collections import MutableMapping from ..svg.tags import * from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement from ..utils import cache +from ..commands import is_command SVG_METADATA_TAG = inkex.addNS("metadata", "svg") @@ -57,16 +58,12 @@ class InkStitchMetadata(MutableMapping): def __setitem__(self, name, value): item = self._find_item(name) + item.text = json.dumps(value) - if value: - item.text = json.dumps(value) - else: - item.getparent().remove(item) - - def _find_item(self, name): + def _find_item(self, name, create=True): tag = inkex.addNS(name, "inkstitch") item = self.metadata.find(tag) - if item is None: + if item is None and create: item = inkex.etree.SubElement(self.metadata, tag) return item @@ -80,9 +77,9 @@ class InkStitchMetadata(MutableMapping): return None def __delitem__(self, name): - item = self._find_item(name) + item = self._find_item(name, create=False) - if item: + if item is not None: self.metadata.remove(item) def __iter__(self): @@ -111,7 +108,7 @@ class InkstitchExtension(inkex.Effect): inkex.errormsg(_("No embroiderable paths selected.")) else: inkex.errormsg(_("No embroiderable paths found in document.")) - inkex.errormsg(_("Tip: use Path -> Object to Path to convert non-paths before embroidering.")) + inkex.errormsg(_("Tip: use Path -> Object to Path to convert non-paths.")) def descendants(self, node): nodes = [] @@ -158,14 +155,15 @@ class InkstitchExtension(inkex.Effect): else: classes = [] - if element.get_style("fill"): + if element.get_style("fill", "black"): if element.get_boolean_param("auto_fill", True): classes.append(AutoFill) else: classes.append(Fill) if element.get_style("stroke"): - classes.append(Stroke) + if not is_command(element.node): + classes.append(Stroke) if element.get_boolean_param("stroke_first", False): classes.reverse() @@ -200,6 +198,15 @@ class InkstitchExtension(inkex.Effect): def get_inkstitch_metadata(self): return InkStitchMetadata(self.document) + def get_base_file_name(self): + svg_filename = self.document.getroot().get(inkex.addNS('docname', 'sodipodi'), "embroidery.svg") + + if svg_filename.endswith('.svg'): + svg_filename = svg_filename[:-4] + + return svg_filename + + def parse(self): """Override inkex.Effect to add Ink/Stitch xml namespace""" diff --git a/lib/extensions/commands.py b/lib/extensions/commands.py new file mode 100644 index 00000000..2f3006ff --- /dev/null +++ b/lib/extensions/commands.py @@ -0,0 +1,161 @@ +import os +import sys +import inkex +import simpletransform +import cubicsuperpath +from copy import deepcopy +from random import random +from shapely import geometry as shgeo + +from .base import InkstitchExtension +from ..i18n import _ +from ..elements import SatinColumn +from ..utils import get_bundled_dir, cache +from ..svg.tags import SVG_DEFS_TAG, SVG_GROUP_TAG, SVG_USE_TAG, SVG_PATH_TAG, INKSCAPE_GROUPMODE, XLINK_HREF, CONNECTION_START, CONNECTION_END, CONNECTOR_TYPE +from ..svg import get_node_transform + + +class Commands(InkstitchExtension): + COMMANDS = ["fill_start", "fill_end", "stop", "trim"] + + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self, *args, **kwargs) + for command in self.COMMANDS: + self.OptionParser.add_option("--%s" % command, type="inkbool") + + @property + def symbols_path(self): + return os.path.join(get_bundled_dir("symbols"), "inkstitch.svg") + + @property + @cache + def symbols_svg(self): + with open(self.symbols_path) as symbols_file: + return inkex.etree.parse(symbols_file) + + @property + @cache + def symbol_defs(self): + return self.symbols_svg.find(SVG_DEFS_TAG) + + @property + @cache + def defs(self): + return self.document.find(SVG_DEFS_TAG) + + def ensure_symbol(self, command): + path = "./*[@id='inkstitch_%s']" % command + if self.defs.find(path) is None: + self.defs.append(deepcopy(self.symbol_defs.find(path))) + + def get_correction_transform(self, node): + # if we want to place our new nodes in the same group as this node, + # then we'll need to factor in the effects of any transforms set on + # the parents of this node. + + # we can ignore the transform on the node itself since it won't apply + # to the objects we add + transform = get_node_transform(node.getparent()) + + # now invert it, so that we can position our objects in absolute + # coordinates + transform = simpletransform.invertTransform(transform) + + return simpletransform.formatTransform(transform) + + 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;", + "transform": self.get_correction_transform(symbol), + CONNECTION_START: "#%s" % symbol.get('id'), + CONNECTION_END: "#%s" % element.node.get('id'), + CONNECTOR_TYPE: "polyline", + } + ) + + symbol.getparent().insert(symbol.getparent().index(symbol), 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_command(self, element, commands): + for i, command in enumerate(commands): + self.remove_legacy_param(element, command) + + pos = self.get_command_pos(element, i, len(commands)) + + symbol = inkex.etree.SubElement(element.node.getparent(), SVG_USE_TAG, + { + "id": self.uniqueId("use"), + XLINK_HREF: "#inkstitch_%s" % command, + "height": "100%", + "width": "100%", + "x": str(pos.x), + "y": str(pos.y), + "transform": self.get_correction_transform(element.node) + } + ) + + self.add_connector(symbol, element) + + def effect(self): + if not self.get_elements(): + return + + if not self.selected: + inkex.errormsg(_("Please select one or more objects to which to attach commands.")) + return + + self.svg = self.document.getroot() + + commands = [command for command in self.COMMANDS if getattr(self.options, command)] + + if not commands: + inkex.errormsg(_("Please choose one or more commands to attach.")) + return + + for command in commands: + self.ensure_symbol(command) + + # Each object (node) in the SVG may correspond to multiple Elements of different + # types (e.g. stroke + fill). We only want to process each one once. + seen_nodes = set() + + for element in self.elements: + if element.node not in seen_nodes: + self.add_command(element, commands) + seen_nodes.add(element.node) diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py index a213be64..1e994e27 100644 --- a/lib/extensions/embroider.py +++ b/lib/extensions/embroider.py @@ -44,8 +44,7 @@ class Embroider(InkstitchExtension): if self.options.output_file: output_path = os.path.join(self.options.path, self.options.output_file) else: - svg_filename = self.document.getroot().get(inkex.addNS('docname', 'sodipodi'), "embroidery.svg") - csv_filename = svg_filename.replace('.svg', '.%s' % self.options.output_format) + csv_filename = '%s.%s' % (self.get_base_file_name(), self.options.output_format) output_path = os.path.join(self.options.path, csv_filename) def add_suffix(path, suffix): diff --git a/lib/extensions/flip.py b/lib/extensions/flip.py new file mode 100644 index 00000000..d8d78cb5 --- /dev/null +++ b/lib/extensions/flip.py @@ -0,0 +1,40 @@ +import sys +import inkex +import cubicsuperpath +from shapely import geometry as shgeo + +from .base import InkstitchExtension +from ..i18n import _ +from ..elements import SatinColumn + +class Flip(InkstitchExtension): + def subpath_to_linestring(self, subpath): + return shgeo.LineString() + + def flip(self, satin): + csp = satin.path + + if len(csp) > 1: + flattened = satin.flatten(csp) + + # find the rails (the two longest paths) and swap them + indices = range(len(csp)) + indices.sort(key=lambda i: shgeo.LineString(flattened[i]).length, reverse=True) + + first = indices[0] + second = indices[1] + csp[first], csp[second] = csp[second], csp[first] + + satin.node.set("d", cubicsuperpath.formatPath(csp)) + + def effect(self): + if not self.get_elements(): + return + + if not self.selected: + inkex.errormsg(_("Please select one or more satin columns to flip.")) + return + + for element in self.elements: + if isinstance(element, SatinColumn): + self.flip(element) diff --git a/lib/extensions/input.py b/lib/extensions/input.py index 251859c5..cb5ac452 100644 --- a/lib/extensions/input.py +++ b/lib/extensions/input.py @@ -3,49 +3,29 @@ from os.path import realpath, dirname, join as path_join import sys from inkex import etree import inkex - -# help python find libembroidery when running in a local repo clone -if getattr(sys, 'frozen', None) is None: - sys.path.append(realpath(path_join(dirname(__file__), '..', '..'))) - -from libembroidery import * +import pyembroidery from ..svg import PIXELS_PER_MM, render_stitch_plan from ..svg.tags import INKSCAPE_LABEL from ..i18n import _ -from ..stitch_plan import StitchPlan +from ..stitch_plan import StitchPlan, ColorBlock +from ..utils.io import save_stdout class Input(object): - def pattern_stitches(self, pattern): - stitch_pointer = pattern.stitchList - while stitch_pointer: - yield stitch_pointer.stitch - stitch_pointer = stitch_pointer.next - - def affect(self, args): embroidery_file = args[0] - pattern = embPattern_create() - embPattern_read(pattern, embroidery_file) - embPattern_flipVertical(pattern) + pattern = pyembroidery.read(embroidery_file) stitch_plan = StitchPlan() color_block = None - current_color = None - - for stitch in self.pattern_stitches(pattern): - if stitch.color != current_color: - thread = embThreadList_getAt(pattern.threadList, stitch.color) - color = thread.color - color_block = stitch_plan.new_color_block((color.r, color.g, color.b)) - current_color = stitch.color - if not stitch.flags & END: - color_block.add_stitch(stitch.xx * PIXELS_PER_MM, stitch.yy * PIXELS_PER_MM, - jump=stitch.flags & JUMP, - color_change=stitch.flags & STOP, - trim=stitch.flags & TRIM) + for raw_stitches, thread in pattern.get_as_colorblocks(): + color_block = stitch_plan.new_color_block(thread) + for x, y, command in raw_stitches: + color_block.add_stitch(x * PIXELS_PER_MM / 10.0, y * PIXELS_PER_MM / 10.0, + jump=(command == pyembroidery.JUMP), + trim=(command == pyembroidery.TRIM)) extents = stitch_plan.extents svg = etree.Element("svg", nsmap=inkex.NSS, attrib= diff --git a/lib/extensions/palettes.py b/lib/extensions/install.py index f7a6c7a5..42a92113 100644 --- a/lib/extensions/palettes.py +++ b/lib/extensions/install.py @@ -1,3 +1,5 @@ +# -*- coding: UTF-8 -*- + import sys import traceback import os @@ -11,29 +13,29 @@ import logging import wx import inkex -from ..utils import guess_inkscape_config_path +from ..utils import guess_inkscape_config_path, get_bundled_dir -class InstallPalettesFrame(wx.Frame): +class InstallerFrame(wx.Frame): def __init__(self, *args, **kwargs): wx.Frame.__init__(self, *args, **kwargs) - default_path = os.path.join(guess_inkscape_config_path(), "palettes") + self.path = guess_inkscape_config_path() panel = wx.Panel(self) sizer = wx.BoxSizer(wx.VERTICAL) - text = wx.StaticText(panel, label=_("Directory in which to install palettes:")) - font = wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.NORMAL) - text.SetFont(font) - sizer.Add(text, proportion=0, flag=wx.ALL|wx.EXPAND, border=10) + text_sizer = wx.BoxSizer(wx.HORIZONTAL) - path_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.path_input = wx.TextCtrl(panel, wx.ID_ANY, value=default_path) - path_sizer.Add(self.path_input, proportion=3, flag=wx.RIGHT|wx.EXPAND, border=20) - chooser_button = wx.Button(panel, wx.ID_OPEN, _('Choose another directory...')) - path_sizer.Add(chooser_button, proportion=1, flag=wx.EXPAND) - sizer.Add(path_sizer, proportion=0, flag=wx.ALL|wx.EXPAND, border=10) + text = _('Ink/Stitch can install files ("add-ons") that make it easier to use Inkscape to create machine embroidery designs. These add-ons will be installed:') + \ + "\n\n • " + _("thread manufacturer color palettes") + \ + "\n • " + _("Ink/Stitch visual commands (Object -> Symbols...)") + + static_text = wx.StaticText(panel, label=text) + font = wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.NORMAL) + static_text.SetFont(font) + text_sizer.Add(static_text, proportion=0, flag=wx.ALL|wx.EXPAND, border=10) + sizer.Add(text_sizer, proportion=3, flag=wx.ALL|wx.EXPAND, border=0) buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) install_button = wx.Button(panel, wx.ID_ANY, _("Install")) @@ -41,15 +43,11 @@ class InstallPalettesFrame(wx.Frame): buttons_sizer.Add(install_button, proportion=0, flag=wx.ALIGN_RIGHT|wx.ALL, border=5) cancel_button = wx.Button(panel, wx.ID_CANCEL, _("Cancel")) buttons_sizer.Add(cancel_button, proportion=0, flag=wx.ALIGN_RIGHT|wx.ALL, border=5) - sizer.Add(buttons_sizer, proportion=0, flag=wx.ALIGN_RIGHT) + sizer.Add(buttons_sizer, proportion=1, flag=wx.ALIGN_RIGHT|wx.ALIGN_BOTTOM) - outer_sizer = wx.BoxSizer(wx.HORIZONTAL) - outer_sizer.Add(sizer, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) - - panel.SetSizer(outer_sizer) + panel.SetSizer(sizer) panel.Layout() - chooser_button.Bind(wx.EVT_BUTTON, self.chooser_button_clicked) cancel_button.Bind(wx.EVT_BUTTON, self.cancel_button_clicked) install_button.Bind(wx.EVT_BUTTON, self.install_button_clicked) @@ -57,36 +55,31 @@ class InstallPalettesFrame(wx.Frame): self.Destroy() def chooser_button_clicked(self, event): - dialog = wx.DirDialog(self, _("Choose Inkscape palettes directory")) + dialog = wx.DirDialog(self, _("Choose Inkscape directory")) if dialog.ShowModal() != wx.ID_CANCEL: self.path_input.SetValue(dialog.GetPath()) def install_button_clicked(self, event): try: - self.install_palettes() + self.install_addons('palettes') + self.install_addons('symbols') except Exception, e: wx.MessageDialog(self, - _('Thread palette installation failed') + ': \n' + traceback.format_exc(), + _('Inkscape add-on installation failed') + ': \n' + traceback.format_exc(), _('Installation Failed'), wx.OK).ShowModal() else: wx.MessageDialog(self, - _('Thread palette files have been installed. Please restart Inkscape to load the new palettes.'), + _('Inkscape add-on files have been installed. Please restart Inkscape to load the new add-ons.'), _('Installation Completed'), wx.OK).ShowModal() self.Destroy() - def install_palettes(self): - path = self.path_input.GetValue() - palettes_dir = self.get_bundled_palettes_dir() - self.copy_files(glob(os.path.join(palettes_dir, "*")), path) - - def get_bundled_palettes_dir(self): - if getattr(sys, 'frozen', None) is not None: - return realpath(os.path.join(sys._MEIPASS, '..', 'palettes')) - else: - return os.path.join(dirname(realpath(__file__)), 'palettes') + def install_addons(self, type): + path = os.path.join(self.path, type) + src_dir = get_bundled_dir(type) + self.copy_files(glob(os.path.join(src_dir, "*")), path) if (sys.platform == "win32"): # If we try to just use shutil.copy it says the operation requires elevation. @@ -104,9 +97,9 @@ class InstallPalettesFrame(wx.Frame): for palette_file in files: shutil.copy(palette_file, dest) -class Palettes(inkex.Effect): +class Install(inkex.Effect): def effect(self): app = wx.App() - installer_frame = InstallPalettesFrame(None, title=_("Ink/Stitch Thread Palette Installer"), size=(450, 200)) + installer_frame = InstallerFrame(None, title=_("Ink/Stitch Add-ons Installer"), size=(550, 250)) installer_frame.Show() app.MainLoop() diff --git a/lib/extensions/output.py b/lib/extensions/output.py new file mode 100644 index 00000000..1dc8d19d --- /dev/null +++ b/lib/extensions/output.py @@ -0,0 +1,48 @@ +import sys +import traceback +import os +import inkex +import tempfile + +from .base import InkstitchExtension +from ..i18n import _ +from ..output import write_embroidery_file +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan, PIXELS_PER_MM +from ..utils.io import save_stdout + +class Output(InkstitchExtension): + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self) + self.OptionParser.add_option("-c", "--collapse_len_mm", + action="store", type="float", + dest="collapse_length_mm", default=3.0, + help="max collapse length (mm)") + self.OptionParser.add_option("-f", "--format", + dest="file_extension", + help="file extension to output (example: DST)") + + def effect(self): + if not self.get_elements(): + return + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) + + temp_file = tempfile.NamedTemporaryFile(suffix=".%s" % self.options.file_extension, delete=False) + + # in windows, failure to close here will keep the file locked + temp_file.close() + + write_embroidery_file(temp_file.name, stitch_plan, self.document.getroot()) + + # inkscape will read the file contents from stdout and copy + # to the destination file that the user chose + with open(temp_file.name) as output_file: + sys.stdout.write(output_file.read()) + + # clean up the temp file + os.remove(temp_file.name) + + # don't let inkex output the SVG! + sys.exit(0) diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 03a6f3cc..58fedd6b 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -19,6 +19,7 @@ from ..stitch_plan import patches_to_stitch_plan from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn from ..utils import save_stderr, restore_stderr from ..simulator import EmbroiderySimulator +from ..commands import is_command def presets_path(): @@ -354,6 +355,9 @@ class SettingsFrame(wx.Frame): self.simulate_thread = None self.simulate_refresh_needed = Event() + # used when closing to avoid having the window reopen at the last second + self.disable_simulate_window = False + wx.CallLater(1000, self.update_simulator) self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) @@ -392,6 +396,9 @@ class SettingsFrame(wx.Frame): self.simulate_window.stop() self.simulate_window.clear() + if self.disable_simulate_window: + return + if not self.simulate_thread or not self.simulate_thread.is_alive(): self.simulate_thread = Thread(target=self.simulate_worker) self.simulate_thread.daemon = True @@ -586,6 +593,7 @@ class SettingsFrame(wx.Frame): self.close() def use_last(self, event): + self.disable_simulate_window = True self._load_preset("__LAST__") self.apply(event) @@ -632,6 +640,9 @@ class SettingsFrame(wx.Frame): self.Layout() # end wxGlade +class NoValidObjects(Exception): + pass + class Params(InkstitchExtension): def __init__(self, *args, **kwargs): self.cancelled = False @@ -645,7 +656,7 @@ class Params(InkstitchExtension): classes.append(AutoFill) classes.append(Fill) - if element.get_style("stroke"): + if element.get_style("stroke") and not is_command(node): classes.append(Stroke) if element.get_style("stroke-dasharray") is None: @@ -689,6 +700,11 @@ class Params(InkstitchExtension): def create_tabs(self, parent): tabs = [] + nodes_by_class = self.get_nodes_by_class() + + if not nodes_by_class: + raise NoValidObjects() + for cls, nodes in self.get_nodes_by_class(): params = cls.get_params() @@ -745,12 +761,15 @@ class Params(InkstitchExtension): self.cancelled = True def effect(self): - app = wx.App() - frame = SettingsFrame(tabs_factory=self.create_tabs, on_cancel=self.cancel) - frame.Show() - app.MainLoop() - - if self.cancelled: - # This prevents the superclass from outputting the SVG, because we - # may have modified the DOM. - sys.exit(0) + try: + app = wx.App() + frame = SettingsFrame(tabs_factory=self.create_tabs, on_cancel=self.cancel) + frame.Show() + app.MainLoop() + + if self.cancelled: + # This prevents the superclass from outputting the SVG, because we + # may have modified the DOM. + sys.exit(0) + except NoValidObjects: + self.no_elements_error() diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py index baeb7eba..6e2eff58 100644 --- a/lib/extensions/print_pdf.py +++ b/lib/extensions/print_pdf.py @@ -21,7 +21,7 @@ import requests from .base import InkstitchExtension from ..i18n import _, translation as inkstitch_translation from ..svg import PIXELS_PER_MM, render_stitch_plan -from ..svg.tags import SVG_GROUP_TAG +from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_GROUPMODE from ..stitch_plan import patches_to_stitch_plan from ..threads import ThreadCatalog @@ -94,6 +94,8 @@ class PrintPreviewServer(Thread): self.html = kwargs.pop('html') self.metadata = kwargs.pop('metadata') self.stitch_plan = kwargs.pop('stitch_plan') + self.realistic_overview_svg = kwargs.pop('realistic_overview_svg') + self.realistic_color_block_svgs = kwargs.pop('realistic_color_block_svgs') Thread.__init__(self, *args, **kwargs) self.daemon = True self.last_request_time = None @@ -202,6 +204,14 @@ class PrintPreviewServer(Thread): return jsonify(threads) + @self.app.route('/realistic/block<int:index>', methods=['GET']) + def get_realistic_block(index): + return Response(self.realistic_color_block_svgs[index], mimetype='image/svg+xml') + + @self.app.route('/realistic/overview', methods=['GET']) + def get_realistic_overview(): + return Response(self.realistic_overview_svg, mimetype='image/svg+xml') + def stop(self): # for whatever reason, shutting down only seems possible in # the context of a flask request, so we'll just make one @@ -295,38 +305,24 @@ class Print(InkstitchExtension): return env - def strip_namespaces(self): + def strip_namespaces(self, svg): # namespace prefixes seem to trip up HTML, so get rid of them - for element in self.document.iter(): + for element in svg.iter(): if element.tag[0]=='{': element.tag = element.tag[element.tag.index('}',1) + 1:] - def effect(self): - # It doesn't really make sense to print just a couple of selected - # objects. It's almost certain they meant to print the whole design. - # If they really wanted to print just a few objects, they could set - # the rest invisible temporarily. - self.selected = {} + def render_svgs(self, stitch_plan, realistic=False): + svg = deepcopy(self.document).getroot() + render_stitch_plan(svg, stitch_plan, realistic) - if not self.get_elements(): - return - - self.hide_all_layers() - - patches = self.elements_to_patches(self.elements) - stitch_plan = patches_to_stitch_plan(patches) - palette = ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette']) - render_stitch_plan(self.document.getroot(), stitch_plan) - - self.strip_namespaces() + self.strip_namespaces(svg) # Now the stitch plan layer will contain a set of groups, each # corresponding to a color block. We'll create a set of SVG files # corresponding to each individual color block and a final one # for all color blocks together. - svg = self.document.getroot() - layers = svg.findall("./g[@{http://www.inkscape.org/namespaces/inkscape}groupmode='layer']") + layers = svg.findall("./g[@%s='layer']" % INKSCAPE_GROUPMODE) stitch_plan_layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") # First, delete all of the other layers. We don't need them and they'll @@ -335,9 +331,9 @@ class Print(InkstitchExtension): if layer is not stitch_plan_layer: svg.remove(layer) - overview_svg = inkex.etree.tostring(self.document) - + overview_svg = inkex.etree.tostring(svg) color_block_groups = stitch_plan_layer.getchildren() + color_block_svgs = [] for i, group in enumerate(color_block_groups): # clear the stitch plan layer @@ -347,12 +343,15 @@ class Print(InkstitchExtension): stitch_plan_layer.append(group) # save an SVG preview - stitch_plan.color_blocks[i].svg_preview = inkex.etree.tostring(self.document) + color_block_svgs.append(inkex.etree.tostring(svg)) + return overview_svg, color_block_svgs + + def render_html(self, stitch_plan, overview_svg, selected_palette): env = self.build_environment() template = env.get_template('index.html') - html = template.render( + return template.render( view = {'client_overview': False, 'client_detailedview': False, 'operator_overview': True, 'operator_detailedview': True}, logo = {'src' : '', 'title' : 'LOGO'}, date = date.today(), @@ -371,14 +370,38 @@ class Print(InkstitchExtension): svg_overview = overview_svg, color_blocks = stitch_plan.color_blocks, palettes = ThreadCatalog().palette_names(), - selected_palette = palette, + selected_palette = selected_palette, ) - # We've totally mucked with the SVG. Restore it so that we can save - # metadata into it. - self.document = deepcopy(self.original_document) + def effect(self): + # It doesn't really make sense to print just a couple of selected + # objects. It's almost certain they meant to print the whole design. + # If they really wanted to print just a few objects, they could set + # the rest invisible temporarily. + self.selected = {} + + if not self.get_elements(): + return + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches) + palette = ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette']) + + overview_svg, color_block_svgs = self.render_svgs(stitch_plan, realistic=False) + realistic_overview_svg, realistic_color_block_svgs = self.render_svgs(stitch_plan, realistic=True) + + for i, svg in enumerate(color_block_svgs): + stitch_plan.color_blocks[i].svg_preview = svg + + html = self.render_html(stitch_plan, overview_svg, palette) - print_server = PrintPreviewServer(html=html, metadata=self.get_inkstitch_metadata(), stitch_plan=stitch_plan) + print_server = PrintPreviewServer( + html=html, + metadata=self.get_inkstitch_metadata(), + stitch_plan=stitch_plan, + realistic_overview_svg=realistic_overview_svg, + realistic_color_block_svgs=realistic_color_block_svgs + ) print_server.start() time.sleep(1) diff --git a/lib/extensions/zip.py b/lib/extensions/zip.py new file mode 100644 index 00000000..02f29e8a --- /dev/null +++ b/lib/extensions/zip.py @@ -0,0 +1,74 @@ +import sys +import traceback +import os +import inkex +import tempfile +from zipfile import ZipFile +import pyembroidery + +from .base import InkstitchExtension +from ..i18n import _ +from ..output import write_embroidery_file +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan, PIXELS_PER_MM +from ..utils.io import save_stdout + + +class Zip(InkstitchExtension): + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self) + self.OptionParser.add_option("-c", "--collapse_len_mm", + action="store", type="float", + dest="collapse_length_mm", default=3.0, + help="max collapse length (mm)") + + # it's kind of obnoxious that I have to do this... + self.formats = [] + for format in pyembroidery.supported_formats(): + if 'writer' in format and format['category'] == 'embroidery': + extension = format['extension'] + self.OptionParser.add_option('--format-%s' % extension, type="inkbool", dest=extension) + self.formats.append(extension) + + def effect(self): + if not self.get_elements(): + return + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) + + base_file_name = self.get_base_file_name() + path = tempfile.mkdtemp() + + files = [] + + for format in self.formats: + if getattr(self.options, format): + output_file = os.path.join(path, "%s.%s" % (base_file_name, format)) + write_embroidery_file(output_file, stitch_plan, self.document.getroot()) + files.append(output_file) + + if not files: + self.errormsg(_("No embroidery file formats selected.")) + + temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + + # in windows, failure to close here will keep the file locked + temp_file.close() + + with ZipFile(temp_file.name, "w") as zip_file: + for file in files: + zip_file.write(file, os.path.basename(file)) + + # inkscape will read the file contents from stdout and copy + # to the destination file that the user chose + with open(temp_file.name) as output_file: + sys.stdout.write(output_file.read()) + + os.remove(temp_file.name) + for file in files: + os.remove(file) + os.rmdir(path) + + # don't let inkex output the SVG! + sys.exit(0) diff --git a/lib/output.py b/lib/output.py index 84128a25..0d7f9918 100644 --- a/lib/output.py +++ b/lib/output.py @@ -1,4 +1,4 @@ -import libembroidery +import pyembroidery import inkex import simpletransform import shapely.geometry as shgeo @@ -7,36 +7,17 @@ from .utils import Point from .svg import PIXELS_PER_MM, get_doc_size, get_viewbox_transform -def make_thread(color): - thread = libembroidery.EmbThread() - thread.color = libembroidery.embColor_make(*color.rgb) - - thread.description = color.name - thread.catalogNumber = "" - - return thread - -def add_thread(pattern, thread): - """Add a thread to a pattern and return the thread's index""" - - libembroidery.embPattern_addThread(pattern, thread) - - return libembroidery.embThreadList_count(pattern.threadList) - 1 - -def get_flags(stitch): - flags = 0 - +def get_command(stitch): if stitch.jump: - flags |= libembroidery.JUMP - - if stitch.trim: - flags |= libembroidery.TRIM - - if stitch.color_change: - flags |= libembroidery.STOP - - return flags - + return pyembroidery.JUMP + elif stitch.trim: + return pyembroidery.TRIM + elif stitch.color_change: + return pyembroidery.COLOR_CHANGE + elif stitch.stop: + return pyembroidery.STOP + else: + return pyembroidery.NEEDLE_AT def _string_to_floats(string): floats = string.split(',') @@ -102,27 +83,32 @@ def get_origin(svg): def write_embroidery_file(file_path, stitch_plan, svg): origin = get_origin(svg) - pattern = libembroidery.embPattern_create() + pattern = pyembroidery.EmbPattern() for color_block in stitch_plan: - add_thread(pattern, make_thread(color_block.color)) + pattern.add_thread(color_block.color.pyembroidery_thread) for stitch in color_block: - if stitch.stop: - # This is the start of the extra color block added by the - # "STOP after" handler (see stitch_plan/stop.py). Assign it - # the same color. - add_thread(pattern, make_thread(color_block.color)) + command = get_command(stitch) + pattern.add_stitch_absolute(command, stitch.x, stitch.y) - flags = get_flags(stitch) - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, flags, 1) - - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, libembroidery.END, 1) + pattern.add_stitch_absolute(pyembroidery.END, stitch.x, stitch.y) # convert from pixels to millimeters - libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) + # also multiply by 10 to get tenths of a millimeter as required by pyembroidery + scale = 10 / PIXELS_PER_MM + + settings = { + # correct for the origin + "translate": -origin, + + # convert from pixels to millimeters + # also multiply by 10 to get tenths of a millimeter as required by pyembroidery + "scale": (scale, scale), - # SVG and embroidery disagree on the direction of the Y axis - libembroidery.embPattern_flipVertical(pattern) + # This forces a jump at the start of the design and after each trim, + # even if we're close enough not to need one. + "full_jump": True, + } - libembroidery.embPattern_write(pattern, file_path) + pyembroidery.write(pattern, file_path, settings) diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py index 12642a60..5230efec 100644 --- a/lib/stitch_plan/stitch.py +++ b/lib/stitch_plan/stitch.py @@ -2,7 +2,7 @@ from ..utils.geometry import Point class Stitch(Point): - def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, color_change=False, no_ties=False): + def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, color_change=False, fake_color_change=False, no_ties=False): self.x = x self.y = y self.color = color @@ -10,10 +10,20 @@ class Stitch(Point): self.trim = trim self.stop = stop self.color_change = color_change + self.fake_color_change = fake_color_change self.no_ties = no_ties def __repr__(self): - return "Stitch(%s, %s, %s, %s, %s, %s, %s)" % (self.x, self.y, self.color, "JUMP" if self.jump else " ", "TRIM" if self.trim else " ", "STOP" if self.stop else " ", "NO TIES" if self.no_ties else " ") + return "Stitch(%s, %s, %s, %s, %s, %s, %s, %s%s)" % (self.x, + self.y, + self.color, + "JUMP" if self.jump else " ", + "TRIM" if self.trim else " ", + "STOP" if self.stop else " ", + "NO TIES" if self.no_ties else " ", + "FAKE " if self.fake_color_change else "", + "COLOR CHANGE" if self.color_change else " " + ) def copy(self): return Stitch(self.x, self.y, self.color, self.jump, self.stop, self.trim, self.color_change, self.no_ties) diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py index 93bcd195..682ea09f 100644 --- a/lib/stitch_plan/stitch_plan.py +++ b/lib/stitch_plan/stitch_plan.py @@ -1,6 +1,4 @@ from .stitch import Stitch -from .stop import process_stop -from .trim import process_trim from .ties import add_ties from ..svg import PIXELS_PER_MM from ..utils.geometry import Point @@ -16,62 +14,45 @@ def patches_to_stitch_plan(patches, collapse_len=3.0 * PIXELS_PER_MM): """ stitch_plan = StitchPlan() - color_block = stitch_plan.new_color_block() - need_trim = False + if not patches: + return stitch_plan + + color_block = stitch_plan.new_color_block(color=patches[0].color) + for patch in patches: if not patch.stitches: continue - if not color_block.has_color(): - # set the color for the first color block - color_block.color = patch.color - - if color_block.color == patch.color: - if need_trim: - process_trim(color_block, patch.stitches[0]) - need_trim = False - - # add a jump stitch between patches if the distance is more - # than the collapse length - if color_block.last_stitch: - if (patch.stitches[0] - color_block.last_stitch).length() > collapse_len: - color_block.add_stitch(patch.stitches[0].x, patch.stitches[0].y, jump=True) - - else: - # add a color change (only if we didn't just do a "STOP after") - if not color_block.last_stitch.color_change: - stitch = color_block.last_stitch.copy() - stitch.color_change = True - color_block.add_stitch(stitch) + if color_block.color != patch.color: + if len(color_block) == 0: + # We just processed a stop, which created a new color block. + # We'll just claim this new block as ours: + color_block.color = patch.color + else: + # end the previous block with a color change + color_block.add_stitch(color_change=True) - color_block = stitch_plan.new_color_block() - color_block.color = patch.color + # make a new block of our color + color_block = stitch_plan.new_color_block(color=patch.color) - color_block.filter_duplicate_stitches() color_block.add_stitches(patch.stitches, no_ties=patch.stitch_as_is) if patch.trim_after: - # a trim needs to be followed by a jump to the next stitch, so - # we'll process it when we start the next patch - need_trim = True + color_block.add_stitch(trim=True) if patch.stop_after: - process_stop(color_block) - - add_jumps(stitch_plan) - add_ties(stitch_plan) - - return stitch_plan + color_block.add_stitch(stop=True) + color_block = stitch_plan.new_color_block(color_block.color) + if len(color_block) == 0: + # last block ended in a stop, so now we have an empty block + del stitch_plan.color_blocks[-1] -def add_jumps(stitch_plan): - """Add a JUMP stitch at the start of each color block.""" + stitch_plan.filter_duplicate_stitches() + stitch_plan.add_ties() - for color_block in stitch_plan: - stitch = color_block.stitches[0].copy() - stitch.jump = True - color_block.stitches.insert(0, stitch) + return stitch_plan class StitchPlan(object): @@ -85,6 +66,17 @@ class StitchPlan(object): self.color_blocks.append(color_block) return color_block + def add_color_block(self, color_block): + self.color_blocks.append(color_block) + + def filter_duplicate_stitches(self): + for color_block in self: + color_block.filter_duplicate_stitches() + + def add_ties(self): + # see ties.py + add_ties(self) + def __iter__(self): return iter(self.color_blocks) @@ -100,8 +92,12 @@ class StitchPlan(object): return len({block.color for block in self}) @property + def num_color_blocks(self): + return len(self.color_blocks) + + @property def num_stops(self): - return sum(block.num_stops for block in self) + return sum(1 for block in self if block.stop_after) @property def num_trims(self): @@ -137,6 +133,13 @@ class StitchPlan(object): dimensions = self.dimensions return (dimensions[0] / PIXELS_PER_MM, dimensions[1] / PIXELS_PER_MM) + @property + def last_color_block(self): + if self.color_blocks: + return self.color_blocks[-1] + else: + return None + class ColorBlock(object): """Holds a set of stitches, all with the same thread color.""" @@ -148,6 +151,9 @@ class ColorBlock(object): def __iter__(self): return iter(self.stitches) + def __len__(self): + return len(self.stitches) + def __repr__(self): return "ColorBlock(%s, %s)" % (self.color, self.stitches) @@ -180,20 +186,18 @@ class ColorBlock(object): return len(self.stitches) @property - def num_stops(self): - """Number of pauses in this color block.""" - - # Stops are encoded using two STOP stitches each. See the comment in - # stop.py for an explanation. - - return sum(1 for stitch in self if stitch.stop) / 2 - - @property def num_trims(self): """Number of trims in this color block.""" return sum(1 for stitch in self if stitch.trim) + @property + def stop_after(self): + if self.last_stitch is not None: + return self.last_stitch.stop + else: + return False + def filter_duplicate_stitches(self): if not self.stitches: return @@ -201,12 +205,12 @@ class ColorBlock(object): stitches = [self.stitches[0]] for stitch in self.stitches[1:]: - if stitches[-1].jump or stitch.stop or stitch.trim: - # Don't consider jumps, stops, or trims as candidates for filtering + if stitches[-1].jump or stitch.stop or stitch.trim or stitch.color_change: + # Don't consider jumps, stops, color changes, or trims as candidates for filtering pass else: l = (stitch - stitches[-1]).length() - if l <= 0.1: + if l <= 0.1 * PIXELS_PER_MM: # duplicate stitch, skip this one continue @@ -215,11 +219,21 @@ class ColorBlock(object): self.stitches = stitches def add_stitch(self, *args, **kwargs): + if not args: + # They're adding a command, e.g. `color_block.add_stitch(stop=True)``. + # Use the position from the last stitch. + if self.last_stitch: + args = (self.last_stitch.x, self.last_stitch.y) + else: + raise ValueError("internal error: can't add a command to an empty stitch block") + if isinstance(args[0], Stitch): self.stitches.append(args[0]) elif isinstance(args[0], Point): self.stitches.append(Stitch(args[0].x, args[0].y, *args[1:], **kwargs)) else: + if not args and self.last_stitch: + args = (self.last_stitch.x, self.last_stitch.y) self.stitches.append(Stitch(*args, **kwargs)) def add_stitches(self, stitches, *args, **kwargs): diff --git a/lib/stitch_plan/stop.py b/lib/stitch_plan/stop.py deleted file mode 100644 index 81dec1da..00000000 --- a/lib/stitch_plan/stop.py +++ /dev/null @@ -1,43 +0,0 @@ -def process_stop(color_block): - """Handle the "stop after" checkbox. - - The user wants the machine to pause after this patch. This can - be useful for applique and similar on multi-needle machines that - normally would not stop between colors. - - In machine embroidery files, there's no such thing as an actual - "STOP" instruction. All that exists is a "color change" command - (which libembroidery calls STOP just to be confusing). - - On multi-needle machines, the user assigns needles to the colors in - the design before starting stitching. C01, C02, etc are normal - needles, but C00 is special. For a block of stitches assigned - to C00, the machine will continue sewing with the last color it - had and pause after it completes the C00 block. - - That means we need to add an artificial color change instruction - shortly before the current stitch so that the user can set that color - block to C00. We'll go back 3 stitches and mark the start of the C00 - block: - """ - - if len(color_block.stitches) >= 3: - # make a copy of the stitch and set it as a color change - stitch = color_block.stitches[-3].copy() - stitch.color_change = True - - # mark this stitch as a "stop" so that we can avoid - # adding tie stitches in ties.py - stitch.stop = True - - # insert it after the stitch - color_block.stitches.insert(-2, stitch) - - # and also add a color change on this stitch, completing the C00 - # block: - - stitch = color_block.stitches[-1].copy() - stitch.color_change = True - color_block.add_stitch(stitch) - - # reference for the above: https://github.com/lexelby/inkstitch/pull/29#issuecomment-359175447 diff --git a/lib/stitch_plan/ties.py b/lib/stitch_plan/ties.py index 6d07ac71..573469f5 100644 --- a/lib/stitch_plan/ties.py +++ b/lib/stitch_plan/ties.py @@ -30,15 +30,16 @@ def add_tie_in(stitches, upcoming_stitches): def add_ties(stitch_plan): """Add tie-off before and after trims, jumps, and color changes.""" + need_tie_in = True for color_block in stitch_plan: - need_tie_in = True new_stitches = [] for i, stitch in enumerate(color_block.stitches): - # Tie before and after TRIMs, JUMPs, and color changes, but ignore - # the fake color change introduced by a "STOP after" (see stop.py). - is_special = stitch.trim or stitch.jump or (stitch.color_change and not stitch.stop) + is_special = stitch.trim or stitch.jump or stitch.color_change or stitch.stop - if is_special and not need_tie_in: + # see stop.py for an explanation of the fake color change + is_fake = stitch.fake_color_change + + if is_special and not is_fake and not need_tie_in: add_tie_off(new_stitches) new_stitches.append(stitch) need_tie_in = True @@ -49,7 +50,8 @@ def add_ties(stitch_plan): else: new_stitches.append(stitch) - if not need_tie_in: - add_tie_off(new_stitches) - color_block.replace_stitches(new_stitches) + + if not need_tie_in: + # tie off at the end if we haven't already + add_tie_off(color_block.stitches) diff --git a/lib/stitch_plan/trim.py b/lib/stitch_plan/trim.py deleted file mode 100644 index f692a179..00000000 --- a/lib/stitch_plan/trim.py +++ /dev/null @@ -1,23 +0,0 @@ -def process_trim(color_block, next_stitch): - """Handle the "trim after" checkbox. - - DST (and maybe other formats?) has no actual TRIM instruction. - Instead, 3 sequential JUMPs cause the machine to trim the thread. - - To support both DST and other formats, we'll add a TRIM and two - JUMPs. The TRIM will be converted to a JUMP by libembroidery - if saving to DST, resulting in the 3-jump sequence. - """ - - delta = next_stitch - color_block.last_stitch - delta = delta * (1/4.0) - - pos = color_block.last_stitch - - for i in xrange(3): - pos += delta - color_block.add_stitch(pos.x, pos.y, jump=True) - - # first one should be TRIM instead of JUMP - color_block.stitches[-3].jump = False - color_block.stitches[-3].trim = True diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 518a2812..6326ced2 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -2,7 +2,7 @@ import sys import shapely import networkx import math -from itertools import groupby +from itertools import groupby, izip from collections import deque from .fill import intersect_region_with_grating, row_num, stitch_row @@ -14,18 +14,38 @@ from ..utils.geometry import Point as InkstitchPoint class MaxQueueLengthExceeded(Exception): pass +class PathEdge(object): + OUTLINE_KEYS = ("outline", "extra", "initial") + SEGMENT_KEY = "segment" -def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_point=None): + def __init__(self, nodes, key): + self.nodes = nodes + self._sorted_nodes = tuple(sorted(self.nodes)) + self.key = key + + def __getitem__(self, item): + return self.nodes[item] + + def __hash__(self): + return hash((self._sorted_nodes, self.key)) + + def __eq__(self, other): + return self._sorted_nodes == other._sorted_nodes and self.key == other.key + + def is_outline(self): + return self.key in self.OUTLINE_KEYS + + def is_segment(self): + return self.key == self.SEGMENT_KEY + +def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_point, ending_point=None): stitches = [] rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) segments = [segment for row in rows_of_segments for segment in row] graph = build_graph(shape, segments, angle, row_spacing) - path = find_stitch_path(graph, segments) - - if starting_point: - stitches.extend(connect_points(shape, starting_point, path[0][0], running_stitch_length)) + path = find_stitch_path(graph, segments, starting_point, ending_point) stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers)) @@ -134,8 +154,6 @@ def build_graph(shape, segments, angle, row_spacing): else: edge_set = 1 - #print >> sys.stderr, outline_index, "es", edge_set, "rn", row_num, inkstitch.Point(*nodes[0]) * self.north(angle), inkstitch.Point(*nodes[1]) * self.north(angle) - # add an edge between each successive node for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])): graph.add_edge(node1, node2, key="outline") @@ -157,14 +175,20 @@ def node_list_to_edge_list(node_list): def bfs_for_loop(graph, starting_node, max_queue_length=2000): to_search = deque() - to_search.appendleft(([starting_node], set(), 0)) + to_search.append((None, set())) while to_search: if len(to_search) > max_queue_length: raise MaxQueueLengthExceeded() - path, visited_edges, visited_segments = to_search.pop() - ending_node = path[-1] + path, visited_edges = to_search.pop() + + if path is None: + # This is the very first time through the loop, so initialize. + path = [] + ending_node = starting_node + else: + ending_node = path[-1][-1] # get a list of neighbors paired with the key of the edge I can follow to get there neighbors = [ @@ -178,26 +202,21 @@ def bfs_for_loop(graph, starting_node, max_queue_length=2000): for next_node, key in neighbors: # skip if I've already followed this edge - edge = (tuple(sorted((ending_node, next_node))), key) + edge = PathEdge((ending_node, next_node), key) if edge in visited_edges: continue - new_path = path + [next_node] - - if key == "segment": - new_visited_segments = visited_segments + 1 - else: - new_visited_segments = visited_segments + new_path = path + [edge] if next_node == starting_node: # ignore trivial loops (down and back a doubled edge) if len(new_path) > 3: - return node_list_to_edge_list(new_path), new_visited_segments + return new_path new_visited_edges = visited_edges.copy() new_visited_edges.add(edge) - to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) + to_search.appendleft((new_path, new_visited_edges)) def find_loop(graph, starting_nodes): @@ -216,14 +235,6 @@ def find_loop(graph, starting_nodes): somewhere else. """ - #loop = self.simple_loop(graph, starting_nodes[-2]) - - #if loop: - # print >> sys.stderr, "simple_loop success" - # starting_nodes.pop() - # starting_nodes.pop() - # return loop - loop = None retry = [] max_queue_length = 2000 @@ -231,7 +242,6 @@ def find_loop(graph, starting_nodes): while not loop: while not loop and starting_nodes: starting_node = starting_nodes.pop() - #print >> sys.stderr, "find loop from", starting_node try: # Note: if bfs_for_loop() returns None, no loop can be @@ -240,12 +250,7 @@ def find_loop(graph, starting_nodes): # case we discard that node and try the next. loop = bfs_for_loop(graph, starting_node, max_queue_length) - #if not loop: - #print >> dbg, "failed on", starting_node - #dbg.flush() except MaxQueueLengthExceeded: - #print >> dbg, "gave up on", starting_node - #dbg.flush() # We're giving up on this node for now. We could try # this node again later, so add it to the bottm of the # stack. @@ -272,7 +277,7 @@ def insert_loop(path, loop): start and end point. The points will be specified in order, such that they will look like this: - ((p1, p2), (p2, p3), (p3, p4) ... (pn, p1)) + ((p1, p2), (p2, p3), (p3, p4), ...) path will be modified in place. """ @@ -282,11 +287,59 @@ def insert_loop(path, loop): for i, (start, end) in enumerate(path): if start == loop_start: break + else: + # if we didn't find the start of the loop in the list at all, it must + # be the endpoint of the last segment + i += 1 path[i:i] = loop +def nearest_node_on_outline(graph, point, outline_index=0): + point = shapely.geometry.Point(*point) + outline_nodes = [node for node, data in graph.nodes(data=True) if data['index'] == outline_index] + nearest = min(outline_nodes, key=lambda node: shapely.geometry.Point(*node).distance(point)) -def find_stitch_path(graph, segments): + return nearest + +def get_outline_nodes(graph, outline_index=0): + outline_nodes = [(node, data['projection']) \ + for node, data \ + in graph.nodes(data=True) \ + if data['index'] == outline_index] + outline_nodes.sort(key=lambda (node, projection): projection) + outline_nodes = [node for node, data in outline_nodes] + + return outline_nodes + +def find_initial_path(graph, starting_point, ending_point=None): + starting_node = nearest_node_on_outline(graph, starting_point) + + if ending_point is None: + # If they didn't give an ending point, pick either neighboring node + # along the outline -- doesn't matter which. We do this because + # the algorithm requires we start with _some_ path. + neighbors = [n for n, keys in graph.adj[starting_node].iteritems() if 'outline' in keys] + return [PathEdge((starting_node, neighbors[0]), "initial")] + else: + ending_node = nearest_node_on_outline(graph, ending_point) + outline_nodes = get_outline_nodes(graph) + + # Multiply the outline_nodes list by 2 (duplicate it) because + # the ending_node may occur first. + outline_nodes *= 2 + start_index = outline_nodes.index(starting_node) + end_index = outline_nodes.index(ending_node, start_index) + nodes = outline_nodes[start_index:end_index + 1] + + # we have a series of sequential points, but we need to + # turn it into an edge list + path = [] + for start, end in izip(nodes[:-1], nodes[1:]): + path.append(PathEdge((start, end), "initial")) + + return path + +def find_stitch_path(graph, segments, starting_point=None, ending_point=None): """find a path that visits every grating segment exactly once Theoretically, we just need to find an Eulerian Path in the graph. @@ -294,13 +347,14 @@ def find_stitch_path(graph, segments): The edges on the outline of the region are only there to help us get from one grating segment to the next. - We'll build a "cycle" (a path that ends where it starts) using - Hierholzer's algorithm. We'll stop once we've visited every grating - segment. + We'll build a Eulerian Path using Hierholzer's algorithm. A true + Eulerian Path would visit every single edge (including all the extras + we inserted in build_graph()),but we'll stop short once we've visited + every grating segment since that's all we really care about. Hierholzer's algorithm says to select an arbitrary starting node at each step. In order to produce a reasonable stitch path, we'll select - the vertex carefully such that we get back-and-forth traversal like + the starting node carefully such that we get back-and-forth traversal like mowing a lawn. To do this, we'll use a simple heuristic: try to start from nodes in @@ -313,40 +367,42 @@ def find_stitch_path(graph, segments): segments_visited = 0 nodes_visited = deque() - # start with a simple loop: down one segment and then back along the - # outer border to the starting point. - path = [segments[0], list(reversed(segments[0]))] + if starting_point is None: + starting_point = segments[0][0] + + path = find_initial_path(graph, starting_point, ending_point) - graph.remove_edges_from(path) + # Our graph is Eulerian: every node has an even degree. An Eulerian graph + # must have an Eulerian Circuit which visits every edge and ends where it + # starts. + # + # However, we're starting with a path and _not_ removing the edges of that + # path from the graph. By doing this, we're implicitly adding those edges + # to the graph, after which the starting and ending point (and only those + # two) will now have odd degree. A graph that's Eulerian except for two + # nodes must have an Eulerian Path that starts and ends at those two nodes. + # That's how we force the starting and ending point. - segments_visited += 1 - nodes_visited.extend(segments[0]) + nodes_visited.append(path[0][0]) while segments_visited < num_segments: - result = find_loop(graph, nodes_visited) + loop = find_loop(graph, nodes_visited) - if not result: + if not loop: print >> sys.stderr, _("Unexpected error while generating fill stitches. Please send your SVG file to lexelby@github.") break - loop, segments = result - - #print >> dbg, "found loop:", loop - #dbg.flush() - - segments_visited += segments - nodes_visited += [edge[0] for edge in loop] + segments_visited += sum(1 for edge in loop if edge.is_segment()) + nodes_visited.extend(edge[0] for edge in loop) graph.remove_edges_from(loop) insert_loop(path, loop) - #if segments_visited >= 12: - # break - - # Now we have a loop that covers every grating segment. It returns to - # where it started, which is unnecessary, so we'll snip the last bit off. - #while original_graph.has_edge(*path[-1], key="outline"): - # path.pop() + if ending_point is None: + # If they didn't specify an ending point, then the end of the path travels + # around the outline back to the start (see find_initial_path()). This + # isn't necessary, so remove it. + trim_end(path) return path @@ -363,10 +419,10 @@ def collapse_sequential_outline_edges(graph, path): new_path = [] for edge in path: - if graph.has_edge(*edge, key="segment"): + if edge.is_segment(): if start_of_run: # close off the last run - new_path.append((start_of_run, edge[0])) + new_path.append(PathEdge((start_of_run, edge[0]), "collapsed")) start_of_run = None new_path.append(edge) @@ -376,7 +432,7 @@ def collapse_sequential_outline_edges(graph, path): if start_of_run: # if we were still in a run, close it off - new_path.append((start_of_run, edge[1])) + new_path.append(PathEdge((start_of_run, edge[1]), "collapsed")) return new_path @@ -416,9 +472,6 @@ def connect_points(shape, start, end, running_stitch_length): direction = math.copysign(1.0, distance) one_stitch = running_stitch_length * direction - #print >> dbg, "connect_points:", outline_index, start, end, distance, stitches, direction - #dbg.flush() - stitches = [InkstitchPoint(*outline.interpolate(pos).coords[0])] for i in xrange(num_stitches): @@ -430,11 +483,11 @@ def connect_points(shape, start, end, running_stitch_length): if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: stitches.append(end) - #print >> dbg, "end connect_points" - #dbg.flush() - return stitches +def trim_end(path): + while path and path[-1].is_outline(): + path.pop() def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers): path = collapse_sequential_outline_edges(graph, path) @@ -442,7 +495,7 @@ def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, stitches = [] for edge in path: - if graph.has_edge(*edge, key="segment"): + if edge.is_segment(): stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers) else: stitches.extend(connect_points(shape, edge[0], edge[1], running_stitch_length)) diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py index 1895bba4..8e846555 100644 --- a/lib/svg/__init__.py +++ b/lib/svg/__init__.py @@ -1,2 +1,3 @@ from .svg import color_block_to_point_lists, render_stitch_plan from .units import * +from .path import apply_transforms, get_node_transform diff --git a/lib/svg/path.py b/lib/svg/path.py new file mode 100644 index 00000000..2d9c0ff3 --- /dev/null +++ b/lib/svg/path.py @@ -0,0 +1,25 @@ +import simpletransform +import cubicsuperpath + +from .units import get_viewbox_transform + +def apply_transforms(path, node): + transform = get_node_transform(node) + + # apply the combined transform to this node's path + simpletransform.applyTransformToPath(transform, path) + + return path + +def get_node_transform(node): + # start with the identity transform + transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + # combine this node's transform with all parent groups' transforms + transform = simpletransform.composeParents(node, transform) + + # add in the transform implied by the viewBox + viewbox_transform = get_viewbox_transform(node.getroottree().getroot()) + transform = simpletransform.composeTransform(viewbox_transform, transform) + + return transform diff --git a/lib/svg/realistic_rendering.py b/lib/svg/realistic_rendering.py new file mode 100644 index 00000000..e31534da --- /dev/null +++ b/lib/svg/realistic_rendering.py @@ -0,0 +1,129 @@ +import simplepath +import math + +from .units import PIXELS_PER_MM +from ..utils import cache, Point + +# The stitch vector path looks like this: +# _______ +# (_______) +# +# It's 0.32mm high, which is the approximate thickness of common machine +# embroidery threads. + +# 1.216 pixels = 0.32mm +stitch_height = 1.216 + +# This vector path starts at the upper right corner of the stitch shape and +# proceeds counter-clockwise.and contains a placeholder (%s) for the stitch +# length. +# +# It contains two invisible "whiskers" of zero width that go above and below +# to ensure that the SVG renderer allocates a large enough canvas area when +# computing the gaussian blur steps. Otherwise, we'd have to expand the +# width and height attributes of the <filter> tag to add more buffer space. +# The width and height are specified in multiples of the bounding box +# size, It's the bounding box aligned with the global SVG canvas's axes, not +# the axes of the stitch itself. That means that having a big enough value +# to add enough padding on the long sides of the stitch would waste a ton +# of space on the short sides and significantly slow down rendering. +stitch_path = "M0,0c0.4,0,0.4,0.3,0.4,0.6c0,0.3,-0.1,0.6,-0.4,0.6v0.2,-0.2h-%sc-0.4,0,-0.4,-0.3,-0.4,-0.6c0,-0.3,0.1,-0.6,0.4,-0.6v-0.2,0.2z" + +# This filter makes the above stitch path look like a real stitch with lighting. +realistic_filter = """ + <filter + style="color-interpolation-filters:sRGB" + id="realistic-stitch-filter" + x="-0.1" + width="1.2" + y="-0.1" + height="1.2"> + <feGaussianBlur + stdDeviation="1.5" + id="feGaussianBlur1542-6" + in="SourceAlpha" /> + <feComponentTransfer + id="feComponentTransfer1544-7" + result="result1"> + <feFuncR + id="feFuncR1546-5" + type="identity" /> + <feFuncG + id="feFuncG1548-3" + type="identity" /> + <feFuncB + id="feFuncB1550-5" + type="identity" + slope="4.5300000000000002" /> + <feFuncA + id="feFuncA1552-6" + type="gamma" + slope="0.14999999999999999" + intercept="0" + amplitude="3.1299999999999999" + offset="-0.33000000000000002" /> + </feComponentTransfer> + <feComposite + in2="SourceAlpha" + id="feComposite1558-2" + operator="in" /> + <feGaussianBlur + stdDeviation="0.089999999999999997" + id="feGaussianBlur1969" /> + <feMorphology + id="feMorphology1971" + operator="dilate" + radius="0.10000000000000001" /> + <feSpecularLighting + id="feSpecularLighting1973" + result="result2" + specularConstant="0.70899999" + surfaceScale="30"> + <fePointLight + id="fePointLight1975" + z="10" /> + </feSpecularLighting> + <feGaussianBlur + stdDeviation="0.040000000000000001" + id="feGaussianBlur1979" /> + <feComposite + in2="SourceGraphic" + id="feComposite1977" + operator="arithmetic" + k2="1" + k3="1" + result="result3" + k1="0" + k4="0" /> + <feComposite + in2="SourceAlpha" + id="feComposite1981" + operator="in" /> + </filter> +""" + +def realistic_stitch(start, end): + """Generate a stitch vector path given a start and end point.""" + + end = Point(*end) + start = Point(*start) + + stitch_length = (end - start).length() + stitch_center = (end + start) / 2.0 + stitch_direction = (end - start) + stitch_angle = math.atan2(stitch_direction.y, stitch_direction.x) + + stitch_length = max(0, stitch_length - 0.2 * PIXELS_PER_MM) + + # create the path by filling in the length in the template + path = simplepath.parsePath(stitch_path % stitch_length) + + # rotate the path to match the stitch + rotation_center_x = -stitch_length / 2.0 + rotation_center_y = stitch_height / 2.0 + simplepath.rotatePath(path, stitch_angle, cx=rotation_center_x, cy=rotation_center_y) + + # move the path to the location of the stitch + simplepath.translatePath(path, stitch_center.x - rotation_center_x, stitch_center.y - rotation_center_y) + + return simplepath.formatPath(path) diff --git a/lib/svg/svg.py b/lib/svg/svg.py index 852215f2..48b1343a 100644 --- a/lib/svg/svg.py +++ b/lib/svg/svg.py @@ -1,7 +1,8 @@ import simpletransform, simplestyle, inkex from .units import get_viewbox_transform -from .tags import SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG +from .tags import SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG, SVG_DEFS_TAG +from .realistic_rendering import realistic_stitch, realistic_filter from ..i18n import _ from ..utils import cache @@ -32,6 +33,31 @@ def get_correction_transform(svg): return transform +def color_block_to_realistic_stitches(color_block, svg): + paths = [] + + for point_list in color_block_to_point_lists(color_block): + if not point_list: + continue + + color = color_block.color.visible_on_white.darker.to_hex_str() + start = point_list[0] + for point in point_list[1:]: + paths.append(inkex.etree.Element( + SVG_PATH_TAG, + {'style': simplestyle.formatStyle( + { + 'fill': color, + 'stroke': 'none', + 'filter': 'url(#realistic-stitch-filter)' + }), + 'd': realistic_stitch(start, point), + 'transform': get_correction_transform(svg) + })) + start = point + + return paths + def color_block_to_paths(color_block, svg): paths = [] # We could emit just a single path with one subpath per point list, but @@ -56,8 +82,7 @@ def color_block_to_paths(color_block, svg): return paths - -def render_stitch_plan(svg, stitch_plan): +def render_stitch_plan(svg, stitch_plan, realistic=False): layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") if layer is None: layer = inkex.etree.Element(SVG_GROUP_TAG, @@ -76,6 +101,17 @@ def render_stitch_plan(svg, stitch_plan): SVG_GROUP_TAG, {'id': '__color_block_%d__' % i, INKSCAPE_LABEL: "color block %d" % (i + 1)}) - group.extend(color_block_to_paths(color_block, svg)) + if realistic: + group.extend(color_block_to_realistic_stitches(color_block, svg)) + else: + group.extend(color_block_to_paths(color_block, svg)) svg.append(layer) + + if realistic: + defs = svg.find(SVG_DEFS_TAG) + + if defs is None: + defs = inkex.etree.SubElement(svg, SVG_DEFS_TAG) + + defs.append(inkex.etree.fromstring(realistic_filter)) diff --git a/lib/svg/tags.py b/lib/svg/tags.py index fee59957..7eb87540 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -5,8 +5,14 @@ SVG_PATH_TAG = inkex.addNS('path', 'svg') SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') SVG_DEFS_TAG = inkex.addNS('defs', 'svg') SVG_GROUP_TAG = inkex.addNS('g', 'svg') +SVG_SYMBOL_TAG = inkex.addNS('symbol', 'svg') +SVG_USE_TAG = inkex.addNS('use', 'svg') INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') +CONNECTION_START = inkex.addNS('connection-start', 'inkscape') +CONNECTION_END = inkex.addNS('connection-end', 'inkscape') +CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape') +XLINK_HREF = inkex.addNS('href', 'xlink') EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/svg/units.py b/lib/svg/units.py index 015da60e..126027bc 100644 --- a/lib/svg/units.py +++ b/lib/svg/units.py @@ -75,11 +75,24 @@ def convert_length(length): raise ValueError(_("Unknown unit: %s") % units) +@cache +def get_viewbox(svg): + return svg.get('viewBox').strip().replace(',', ' ').split() + @cache def get_doc_size(svg): - doc_width = convert_length(svg.get('width')) - doc_height = convert_length(svg.get('height')) + width = svg.get('width') + height = svg.get('height') + + if width is None or height is None: + # fall back to the dimensions from the viewBox + viewbox = get_viewbox(svg) + width = viewbox[2] + height = viewbox[3] + + doc_width = convert_length(width) + doc_height = convert_length(height) return doc_width, doc_height @@ -88,7 +101,7 @@ def get_viewbox_transform(node): # somewhat cribbed from inkscape-silhouette doc_width, doc_height = get_doc_size(node) - viewbox = node.get('viewBox').strip().replace(',', ' ').split() + viewbox = get_viewbox(node) dx = -float(viewbox[0]) dy = -float(viewbox[1]) diff --git a/lib/threads/color.py b/lib/threads/color.py index af474127..cc6c0c48 100644 --- a/lib/threads/color.py +++ b/lib/threads/color.py @@ -1,7 +1,7 @@ import simplestyle import re import colorsys - +from pyembroidery.EmbThread import EmbThread class ThreadColor(object): hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I) @@ -9,6 +9,12 @@ class ThreadColor(object): def __init__(self, color, name=None, number=None, manufacturer=None): if color is None: self.rgb = (0, 0, 0) + elif isinstance(color, EmbThread): + self.name = color.description + self.number = color.catalog_number + self.manufacturer = color.brand + self.rgb = (color.get_red(), color.get_green(), color.get_blue()) + return elif isinstance(color, (list, tuple)): self.rgb = tuple(color) elif self.hex_str_re.match(color): @@ -39,6 +45,15 @@ class ThreadColor(object): return "#%s" % self.hex_digits @property + def pyembroidery_thread(self): + return { + "name": self.name, + "id": self.number, + "manufacturer": self.manufacturer, + "rgb": int(self.hex_digits, 16), + } + + @property def hex_digits(self): return "%02X%02X%02X" % self.rgb @@ -80,3 +95,18 @@ class ThreadColor(object): color = tuple(value * 255 for value in color) return ThreadColor(color, name=self.name, number=self.number, manufacturer=self.manufacturer) + + @property + def darker(self): + hls = list(colorsys.rgb_to_hls(*self.rgb_normalized)) + + # Capping lightness should make the color visible without changing it + # too much. + hls[1] *= 0.75 + + color = colorsys.hls_to_rgb(*hls) + + # convert back to values in the range of 0-255 + color = tuple(value * 255 for value in color) + + return ThreadColor(color, name=self.name, number=self.number, manufacturer=self.manufacturer) diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py index ff06d4a9..78d037f1 100644 --- a/lib/utils/__init__.py +++ b/lib/utils/__init__.py @@ -2,3 +2,4 @@ from geometry import * from cache import cache from io import * from inkscape import * +from paths import * diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 61b98bcb..d0cb96cf 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -65,12 +65,21 @@ class Point: else: raise ValueError("cannot multiply Point by %s" % type(other)) + def __neg__(self): + return self * -1 + def __rmul__(self, other): if isinstance(other, (int, float)): return self.__mul__(other) else: raise ValueError("cannot multiply Point by %s" % type(other)) + def __div__(self, other): + if isinstance(other, (int, float)): + return self * (1.0 / other) + else: + raise ValueErorr("cannot divide Point by %s" % type(other)) + def __repr__(self): return "Point(%s,%s)" % (self.x, self.y) diff --git a/lib/utils/io.py b/lib/utils/io.py index be1fdf24..e5a246f3 100644 --- a/lib/utils/io.py +++ b/lib/utils/io.py @@ -7,12 +7,27 @@ def save_stderr(): # GTK likes to spam stderr, which inkscape will show in a dialog. null = open(os.devnull, 'w') sys.stderr_dup = os.dup(sys.stderr.fileno()) + sys.real_stderr = os.fdopen(sys.stderr_dup, 'w') os.dup2(null.fileno(), 2) - sys.stderr_backup = sys.stderr sys.stderr = StringIO() def restore_stderr(): os.dup2(sys.stderr_dup, 2) - sys.stderr_backup.write(sys.stderr.getvalue()) - sys.stderr = sys.stderr_backup + sys.real_stderr.write(sys.stderr.getvalue()) + sys.stderr = sys.real_stderr + +# It's probably possible to generalize this code, but when I tried, +# the result was incredibly unreadable. +def save_stdout(): + null = open(os.devnull, 'w') + sys.stdout_dup = os.dup(sys.stdout.fileno()) + sys.real_stdout = os.fdopen(sys.stdout_dup, 'w') + os.dup2(null.fileno(), 1) + sys.stdout = StringIO() + + +def restore_stdout(): + os.dup2(sys.stdout_dup, 1) + sys.real_stdout.write(sys.stdout.getvalue()) + sys.stdout = sys.real_stdout diff --git a/lib/utils/paths.py b/lib/utils/paths.py new file mode 100644 index 00000000..863e8e69 --- /dev/null +++ b/lib/utils/paths.py @@ -0,0 +1,10 @@ +import sys +import os +from os.path import dirname, realpath + + +def get_bundled_dir(name): + if getattr(sys, 'frozen', None) is not None: + return realpath(os.path.join(sys._MEIPASS, "..", name)) + else: + return realpath(os.path.join(dirname(realpath(__file__)), '..', '..', name)) |
