From 88b4ff3e669fad0132b928a0c020e351440f47fb Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 27 Feb 2018 19:43:15 -0500 Subject: Tie-in and tie-off (#100) * turn inkstitch.py into a module * add running stitch library function * tie-in and tie-off * remove temporary testing code --- embroider.inx | 2 +- embroider.py | 64 +++- inkstitch.py | 597 ----------------------------------- inkstitch/__init__.py | 597 +++++++++++++++++++++++++++++++++++ inkstitch/stitches/__init__.py | 1 + inkstitch/stitches/running_stitch.py | 62 ++++ inkstitch/utils/__init__.py | 1 + inkstitch/utils/geometry.py | 41 +++ messages.po | 267 ---------------- 9 files changed, 764 insertions(+), 868 deletions(-) delete mode 100644 inkstitch.py create mode 100644 inkstitch/__init__.py create mode 100644 inkstitch/stitches/__init__.py create mode 100644 inkstitch/stitches/running_stitch.py create mode 100644 inkstitch/utils/__init__.py create mode 100644 inkstitch/utils/geometry.py delete mode 100644 messages.po diff --git a/embroider.inx b/embroider.inx index a139ad78..74217b73 100644 --- a/embroider.inx +++ b/embroider.inx @@ -4,7 +4,7 @@ jonh.embroider embroider.py inkex.py - 0.0 + _gui-description="Jump stitches smaller than this will be treated as normal stitches.">3.0 true <_option value="csv">Comma Separated Values Format(.CSV) diff --git a/embroider.py b/embroider.py index 265cbe0f..eb822bae 100644 --- a/embroider.py +++ b/embroider.py @@ -36,6 +36,8 @@ from pprint import pformat import inkstitch from inkstitch import _, cache, dbg, param, EmbroideryElement, get_nodes, SVG_POLYLINE_TAG, SVG_GROUP_TAG, PIXELS_PER_MM, get_viewbox_transform +from inkstitch.stitches import running_stitch +from inkstitch.utils import cut_path class Fill(EmbroideryElement): element_name = _("Fill") @@ -934,7 +936,6 @@ class Stroke(EmbroideryElement): return self.dashed or self.width <= 0.5 def stroke_points(self, emb_point_list, zigzag_spacing, stroke_width): - patch = Patch(color=self.color) p0 = emb_point_list[0] rho = 0.0 side = 1 @@ -1556,7 +1557,62 @@ def process_trim(stitches, next_stitch): stitches[-3].trim = True -def patches_to_stitches(patch_list, collapse_len_px=0): +def add_tie(stitches, tie_path): + color = tie_path[0].color + + tie_path = cut_path(tie_path, 0.6) + tie_stitches = running_stitch(tie_path, 0.3) + tie_stitches = [inkstitch.Stitch(*stitch, color=color) for stitch in tie_stitches] + + stitches.extend(tie_stitches[1:]) + stitches.extend(list(reversed(tie_stitches))[1:]) + + +def add_tie_off(stitches): + if not stitches: + return + + add_tie(stitches, list(reversed(stitches))) + + +def add_tie_in(stitches, upcoming_stitches): + if not upcoming_stitches: + return + + add_tie(stitches, upcoming_stitches) + + +def add_ties(original_stitches): + """Add tie-off before and after trims, jumps, and color changes.""" + + # we're going to copy most stitches over, adding tie in/off as needed + stitches = [] + + need_tie_in = True + + for i, stitch in enumerate(original_stitches): + is_special = stitch.trim or stitch.jump or stitch.stop + + if is_special and not need_tie_in: + add_tie_off(stitches) + stitches.append(stitch) + need_tie_in = True + elif need_tie_in and not is_special: + stitches.append(stitch) + add_tie_in(stitches, original_stitches[i:]) + need_tie_in = False + else: + stitches.append(stitch) + + # add tie-off at the end if we ended on a normal stitch + if not is_special: + add_tie_off(stitches) + + # overwrite the stitch plan with our new one that contains ties + original_stitches[:] = stitches + + +def patches_to_stitches(patch_list, collapse_len_px=3.0): stitches = [] last_stitch = None @@ -1604,6 +1660,8 @@ def patches_to_stitches(patch_list, collapse_len_px=0): if patch.stop_after: process_stop_after(stitches) + add_ties(stitches) + return stitches def stitches_to_polylines(stitches): @@ -1677,7 +1735,7 @@ class Embroider(inkex.Effect): inkex.Effect.__init__(self) self.OptionParser.add_option("-c", "--collapse_len_mm", action="store", type="float", - dest="collapse_length_mm", default=0.0, + dest="collapse_length_mm", default=3.0, help="max collapse length (mm)") self.OptionParser.add_option("--hide_layers", action="store", type="choice", diff --git a/inkstitch.py b/inkstitch.py deleted file mode 100644 index ba120be0..00000000 --- a/inkstitch.py +++ /dev/null @@ -1,597 +0,0 @@ -#!/usr/bin/env python -# http://www.achatina.de/sewing/main/TECHNICL.HTM - -import os -import sys -import gettext -from copy import deepcopy -import math -import libembroidery -import inkex -import simplepath -import simplestyle -import simpletransform -from bezmisc import bezierlength, beziertatlength, bezierpointatt -from cspsubdiv import cspsubdiv -import cubicsuperpath -from shapely import geometry as shgeo - - -try: - from functools import lru_cache -except ImportError: - from backports.functools_lru_cache import lru_cache - -# modern versions of Inkscape use 96 pixels per inch as per the CSS standard -PIXELS_PER_MM = 96 / 25.4 - -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') - -EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) - -dbg = open(os.devnull, "w") - -_ = lambda message: message - -# simplify use of lru_cache decorator -def cache(*args, **kwargs): - return lru_cache(maxsize=None)(*args, **kwargs) - -def localize(): - if getattr(sys, 'frozen', False): - # we are in a pyinstaller installation - locale_dir = sys._MEIPASS - else: - locale_dir = os.path.dirname(__file__) - - locale_dir = os.path.join(locale_dir, 'locales') - - translation = gettext.translation("inkstitch", locale_dir, fallback=True) - - global _ - _ = translation.gettext - -localize() - -# cribbed from inkscape-silhouette -def parse_length_with_units( str ): - - ''' - Parse an SVG value which may or may not have units attached - This version is greatly simplified in that it only allows: no units, - units of px, mm, and %. Everything else, it returns None for. - There is a more general routine to consider in scour.py if more - generality is ever needed. - ''' - - u = 'px' - s = str.strip() - if s[-2:] == 'px': - s = s[:-2] - elif s[-2:] == 'mm': - u = 'mm' - s = s[:-2] - elif s[-2:] == 'pt': - u = 'pt' - s = s[:-2] - elif s[-2:] == 'pc': - u = 'pc' - s = s[:-2] - elif s[-2:] == 'cm': - u = 'cm' - s = s[:-2] - elif s[-2:] == 'in': - u = 'in' - s = s[:-2] - elif s[-1:] == '%': - u = '%' - s = s[:-1] - try: - v = float( s ) - except: - raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) - - return v, u - - -def convert_length(length): - value, units = parse_length_with_units(length) - - if not units or units == "px": - return value - - if units == 'pt': - value /= 72 - units = 'in' - - if units == 'pc': - value /= 6 - units = 'in' - - if units == 'cm': - value *= 10 - units = 'mm' - - if units == 'mm': - value = value / 25.4 - units = 'in' - - if units == 'in': - # modern versions of Inkscape use CSS's 96 pixels per inch. When you - # open an old document, inkscape will add a viewbox for you. - return value * 96 - - raise ValueError(_("Unknown unit: %s") % units) - - -@cache -def get_doc_size(svg): - doc_width = convert_length(svg.get('width')) - doc_height = convert_length(svg.get('height')) - - return doc_width, doc_height - -@cache -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() - - dx = -float(viewbox[0]) - dy = -float(viewbox[1]) - transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) - - try: - sx = doc_width / float(viewbox[2]) - sy = doc_height / float(viewbox[3]) - scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) - transform = simpletransform.composeTransform(transform, scale_transform) - except ZeroDivisionError: - pass - - return transform - -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 - self.description = description - self.unit = unit - self.values = values or [""] - self.type = type - self.group = group - self.inverse = inverse - self.default = default - self.tooltip = tooltip - self.sort_index = sort_index - - def __repr__(self): - return "Param(%s)" % vars(self) - - -# Decorate a member function or property with information about -# the embroidery parameter it corresponds to -def param(*args, **kwargs): - p = Param(*args, **kwargs) - - def decorator(func): - func.param = p - return func - - return decorator - - -class EmbroideryElement(object): - def __init__(self, node): - self.node = node - - @property - def id(self): - return self.node.get('id') - - @classmethod - def get_params(cls): - params = [] - for attr in dir(cls): - prop = getattr(cls, attr) - if isinstance(prop, property): - # The 'param' attribute is set by the 'param' decorator defined above. - if hasattr(prop.fget, 'param'): - params.append(prop.fget.param) - - return params - - @cache - def get_param(self, param, default): - value = self.node.get("embroider_" + param, "").strip() - - return value or default - - @cache - def get_boolean_param(self, param, default=None): - value = self.get_param(param, default) - - if isinstance(value, bool): - return value - else: - return value and (value.lower() in ('yes', 'y', 'true', 't', '1')) - - @cache - def get_float_param(self, param, default=None): - try: - value = float(self.get_param(param, default)) - except (TypeError, ValueError): - return default - - if param.endswith('_mm'): - value = value * PIXELS_PER_MM - - return value - - @cache - def get_int_param(self, param, default=None): - try: - value = int(self.get_param(param, default)) - except (TypeError, ValueError): - return default - - if param.endswith('_mm'): - value = int(value * PIXELS_PER_MM) - - return value - - def set_param(self, name, value): - self.node.set("embroider_%s" % name, str(value)) - - @cache - def get_style(self, style_name): - style = simplestyle.parseStyle(self.node.get("style")) - if (style_name not in style): - return None - value = style[style_name] - if value == 'none': - return None - return value - - @cache - def has_style(self, style_name): - style = simplestyle.parseStyle(self.node.get("style")) - return style_name in style - - @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. - # - # A "superpath" is a collection of paths that are all in one object. - # - # The "cubic" bit in "cubic superpath" is because the bezier curves - # inkscape uses involve cubic polynomials. - # - # Each path is a collection of tuples, each of the form: - # - # (control_before, point, control_after) - # - # A bezier curve segment is defined by an endpoint, a control point, - # a second control point, and a final endpoint. A path is a bunch of - # bezier curves strung together. One could represent a path as a set - # of four-tuples, but there would be redundancy because the ending - # point of one bezier is the starting point of the next. Instead, a - # path is a set of 3-tuples as shown above, and one must construct - # each bezier curve by taking the appropriate endpoints and control - # points. Bleh. It should be noted that a straight segment is - # represented by having the control point on each end equal to that - # end's point. - # - # 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 - - # 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(self.node, transform) - - # add in the transform implied by the viewBox - viewbox_transform = get_viewbox_transform(self.node.getroottree().getroot()) - transform = simpletransform.composeTransform(viewbox_transform, transform) - - # apply the combined transform to this node's path - simpletransform.applyTransformToPath(transform, path) - - - return path - - def flatten(self, path): - """approximate a path containing beziers with a series of points""" - - path = deepcopy(path) - - cspsubdiv(path, 0.1) - - flattened = [] - - for comp in path: - vertices = [] - for ctl in comp: - vertices.append((ctl[1][0], ctl[1][1])) - flattened.append(vertices) - - return flattened - - @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) - - def to_patches(self, last_patch): - raise NotImplementedError("%s must implement to_patches()" % self.__class__.__name__) - - def embroider(self, last_patch): - patches = self.to_patches(last_patch) - - if patches: - patches[-1].trim_after = self.trim_after - patches[-1].stop_after = self.stop_after - - return patches - - def fatal(self, message): - print >> sys.stderr, "error:", message - sys.exit(1) - - -class Point: - def __init__(self, x, y): - self.x = x - self.y = y - - def __add__(self, other): - return Point(self.x + other.x, self.y + other.y) - - def __sub__(self, other): - return Point(self.x - other.x, self.y - other.y) - - def mul(self, scalar): - return Point(self.x * scalar, self.y * scalar) - - def __mul__(self, other): - if isinstance(other, Point): - # dot product - return self.x * other.x + self.y * other.y - elif isinstance(other, (int, float)): - return Point(self.x * other, self.y * other) - else: - raise ValueError("cannot multiply Point by %s" % type(other)) - - def __rmul__(self, other): - if isinstance(other, (int, float)): - return self.__mul__(other) - else: - raise ValueError("cannot multiply Point by %s" % type(other)) - - def __repr__(self): - return "Point(%s,%s)" % (self.x, self.y) - - def length(self): - return math.sqrt(math.pow(self.x, 2.0) + math.pow(self.y, 2.0)) - - def unit(self): - return self.mul(1.0 / self.length()) - - def rotate_left(self): - return Point(-self.y, self.x) - - def rotate(self, angle): - return Point(self.x * math.cos(angle) - self.y * math.sin(angle), self.y * math.cos(angle) + self.x * math.sin(angle)) - - def as_int(self): - return Point(int(round(self.x)), int(round(self.y))) - - def as_tuple(self): - return (self.x, self.y) - - def __cmp__(self, other): - return cmp(self.as_tuple(), other.as_tuple()) - - def __getitem__(self, item): - return self.as_tuple()[item] - - def __len__(self): - return 2 - - -class Stitch(Point): - def __init__(self, x, y, color=None, jump=False, stop=False, trim=False): - self.x = x - self.y = y - self.color = color - self.jump = jump - self.trim = trim - self.stop = stop - - def __repr__(self): - return "Stitch(%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 "") - - -def descendants(node): - nodes = [] - element = EmbroideryElement(node) - - if element.has_style('display') and element.get_style('display') is None: - return [] - - if node.tag == SVG_DEFS_TAG: - return [] - - for child in node: - nodes.extend(descendants(child)) - - if node.tag in EMBROIDERABLE_TAGS: - nodes.append(node) - - return nodes - - -def get_nodes(effect): - """Get all XML nodes, or just those selected - - effect is an instance of a subclass of inkex.Effect. - """ - - if effect.selected: - nodes = [] - for node in effect.document.getroot().iter(): - if node.get("id") in effect.selected: - nodes.extend(descendants(node)) - else: - nodes = descendants(effect.document.getroot()) - - return nodes - - -def make_thread(color): - # strip off the leading "#" - if color.startswith("#"): - color = color[1:] - - thread = libembroidery.EmbThread() - thread.color = libembroidery.embColor_fromHexStr(color) - - thread.description = color - 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 - - if stitch.jump: - flags |= libembroidery.JUMP - - if stitch.trim: - flags |= libembroidery.TRIM - - if stitch.stop: - flags |= libembroidery.STOP - - return flags - - -def _string_to_floats(string): - floats = string.split(',') - return [float(num) for num in floats] - - -def get_origin(svg): - # The user can specify the embroidery origin by defining two guides - # named "embroidery origin" that intersect. - - namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) - all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) - label_attribute = inkex.addNS('label', 'inkscape') - guides = [guide for guide in all_guides - if guide.get(label_attribute, "").startswith("embroidery origin")] - - # document size used below - doc_size = list(get_doc_size(svg)) - - # convert the size from viewbox-relative to real-world pixels - viewbox_transform = get_viewbox_transform(svg) - simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) - - default = [doc_size[0] / 2.0, doc_size[1] / 2.0] - simpletransform.applyTransformToPoint(viewbox_transform, default) - default = Point(*default) - - if len(guides) < 2: - return default - - # Find out where the guides intersect. Only pay attention to the first two. - guides = guides[:2] - - lines = [] - for guide in guides: - # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates - position = Point(*_string_to_floats(guide.get('position'))) - position.y = doc_size[1] - position.y - - - # This one baffles me. I think inkscape might have gotten the order of - # their vector wrong? - parts = _string_to_floats(guide.get('orientation')) - direction = Point(parts[1], parts[0]) - - # We have a theoretically infinite line defined by a point on the line - # and a vector direction. Shapely can only deal in concrete line - # segments, so we'll pick points really far in either direction on the - # line and call it good enough. - lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) - - intersection = lines[0].intersection(lines[1]) - - if isinstance(intersection, shgeo.Point): - origin = [intersection.x, intersection.y] - simpletransform.applyTransformToPoint(viewbox_transform, origin) - return Point(*origin) - else: - # Either the two guides are the same line, or they're parallel. - return default - - -def write_embroidery_file(file_path, stitches, svg): - origin = get_origin(svg) - - pattern = libembroidery.embPattern_create() - last_color = None - - for stitch in stitches: - if stitch.color != last_color: - add_thread(pattern, make_thread(stitch.color)) - last_color = stitch.color - - 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) - - # convert from pixels to millimeters - libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) - - # SVG and embroidery disagree on the direction of the Y axis - libembroidery.embPattern_flipVertical(pattern) - - libembroidery.embPattern_write(pattern, file_path) diff --git a/inkstitch/__init__.py b/inkstitch/__init__.py new file mode 100644 index 00000000..ba120be0 --- /dev/null +++ b/inkstitch/__init__.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python +# http://www.achatina.de/sewing/main/TECHNICL.HTM + +import os +import sys +import gettext +from copy import deepcopy +import math +import libembroidery +import inkex +import simplepath +import simplestyle +import simpletransform +from bezmisc import bezierlength, beziertatlength, bezierpointatt +from cspsubdiv import cspsubdiv +import cubicsuperpath +from shapely import geometry as shgeo + + +try: + from functools import lru_cache +except ImportError: + from backports.functools_lru_cache import lru_cache + +# modern versions of Inkscape use 96 pixels per inch as per the CSS standard +PIXELS_PER_MM = 96 / 25.4 + +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') + +EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) + +dbg = open(os.devnull, "w") + +_ = lambda message: message + +# simplify use of lru_cache decorator +def cache(*args, **kwargs): + return lru_cache(maxsize=None)(*args, **kwargs) + +def localize(): + if getattr(sys, 'frozen', False): + # we are in a pyinstaller installation + locale_dir = sys._MEIPASS + else: + locale_dir = os.path.dirname(__file__) + + locale_dir = os.path.join(locale_dir, 'locales') + + translation = gettext.translation("inkstitch", locale_dir, fallback=True) + + global _ + _ = translation.gettext + +localize() + +# cribbed from inkscape-silhouette +def parse_length_with_units( str ): + + ''' + Parse an SVG value which may or may not have units attached + This version is greatly simplified in that it only allows: no units, + units of px, mm, and %. Everything else, it returns None for. + There is a more general routine to consider in scour.py if more + generality is ever needed. + ''' + + u = 'px' + s = str.strip() + if s[-2:] == 'px': + s = s[:-2] + elif s[-2:] == 'mm': + u = 'mm' + s = s[:-2] + elif s[-2:] == 'pt': + u = 'pt' + s = s[:-2] + elif s[-2:] == 'pc': + u = 'pc' + s = s[:-2] + elif s[-2:] == 'cm': + u = 'cm' + s = s[:-2] + elif s[-2:] == 'in': + u = 'in' + s = s[:-2] + elif s[-1:] == '%': + u = '%' + s = s[:-1] + try: + v = float( s ) + except: + raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) + + return v, u + + +def convert_length(length): + value, units = parse_length_with_units(length) + + if not units or units == "px": + return value + + if units == 'pt': + value /= 72 + units = 'in' + + if units == 'pc': + value /= 6 + units = 'in' + + if units == 'cm': + value *= 10 + units = 'mm' + + if units == 'mm': + value = value / 25.4 + units = 'in' + + if units == 'in': + # modern versions of Inkscape use CSS's 96 pixels per inch. When you + # open an old document, inkscape will add a viewbox for you. + return value * 96 + + raise ValueError(_("Unknown unit: %s") % units) + + +@cache +def get_doc_size(svg): + doc_width = convert_length(svg.get('width')) + doc_height = convert_length(svg.get('height')) + + return doc_width, doc_height + +@cache +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() + + dx = -float(viewbox[0]) + dy = -float(viewbox[1]) + transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) + + try: + sx = doc_width / float(viewbox[2]) + sy = doc_height / float(viewbox[3]) + scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) + transform = simpletransform.composeTransform(transform, scale_transform) + except ZeroDivisionError: + pass + + return transform + +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 + self.description = description + self.unit = unit + self.values = values or [""] + self.type = type + self.group = group + self.inverse = inverse + self.default = default + self.tooltip = tooltip + self.sort_index = sort_index + + def __repr__(self): + return "Param(%s)" % vars(self) + + +# Decorate a member function or property with information about +# the embroidery parameter it corresponds to +def param(*args, **kwargs): + p = Param(*args, **kwargs) + + def decorator(func): + func.param = p + return func + + return decorator + + +class EmbroideryElement(object): + def __init__(self, node): + self.node = node + + @property + def id(self): + return self.node.get('id') + + @classmethod + def get_params(cls): + params = [] + for attr in dir(cls): + prop = getattr(cls, attr) + if isinstance(prop, property): + # The 'param' attribute is set by the 'param' decorator defined above. + if hasattr(prop.fget, 'param'): + params.append(prop.fget.param) + + return params + + @cache + def get_param(self, param, default): + value = self.node.get("embroider_" + param, "").strip() + + return value or default + + @cache + def get_boolean_param(self, param, default=None): + value = self.get_param(param, default) + + if isinstance(value, bool): + return value + else: + return value and (value.lower() in ('yes', 'y', 'true', 't', '1')) + + @cache + def get_float_param(self, param, default=None): + try: + value = float(self.get_param(param, default)) + except (TypeError, ValueError): + return default + + if param.endswith('_mm'): + value = value * PIXELS_PER_MM + + return value + + @cache + def get_int_param(self, param, default=None): + try: + value = int(self.get_param(param, default)) + except (TypeError, ValueError): + return default + + if param.endswith('_mm'): + value = int(value * PIXELS_PER_MM) + + return value + + def set_param(self, name, value): + self.node.set("embroider_%s" % name, str(value)) + + @cache + def get_style(self, style_name): + style = simplestyle.parseStyle(self.node.get("style")) + if (style_name not in style): + return None + value = style[style_name] + if value == 'none': + return None + return value + + @cache + def has_style(self, style_name): + style = simplestyle.parseStyle(self.node.get("style")) + return style_name in style + + @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. + # + # A "superpath" is a collection of paths that are all in one object. + # + # The "cubic" bit in "cubic superpath" is because the bezier curves + # inkscape uses involve cubic polynomials. + # + # Each path is a collection of tuples, each of the form: + # + # (control_before, point, control_after) + # + # A bezier curve segment is defined by an endpoint, a control point, + # a second control point, and a final endpoint. A path is a bunch of + # bezier curves strung together. One could represent a path as a set + # of four-tuples, but there would be redundancy because the ending + # point of one bezier is the starting point of the next. Instead, a + # path is a set of 3-tuples as shown above, and one must construct + # each bezier curve by taking the appropriate endpoints and control + # points. Bleh. It should be noted that a straight segment is + # represented by having the control point on each end equal to that + # end's point. + # + # 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 + + # 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(self.node, transform) + + # add in the transform implied by the viewBox + viewbox_transform = get_viewbox_transform(self.node.getroottree().getroot()) + transform = simpletransform.composeTransform(viewbox_transform, transform) + + # apply the combined transform to this node's path + simpletransform.applyTransformToPath(transform, path) + + + return path + + def flatten(self, path): + """approximate a path containing beziers with a series of points""" + + path = deepcopy(path) + + cspsubdiv(path, 0.1) + + flattened = [] + + for comp in path: + vertices = [] + for ctl in comp: + vertices.append((ctl[1][0], ctl[1][1])) + flattened.append(vertices) + + return flattened + + @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) + + def to_patches(self, last_patch): + raise NotImplementedError("%s must implement to_patches()" % self.__class__.__name__) + + def embroider(self, last_patch): + patches = self.to_patches(last_patch) + + if patches: + patches[-1].trim_after = self.trim_after + patches[-1].stop_after = self.stop_after + + return patches + + def fatal(self, message): + print >> sys.stderr, "error:", message + sys.exit(1) + + +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + def __add__(self, other): + return Point(self.x + other.x, self.y + other.y) + + def __sub__(self, other): + return Point(self.x - other.x, self.y - other.y) + + def mul(self, scalar): + return Point(self.x * scalar, self.y * scalar) + + def __mul__(self, other): + if isinstance(other, Point): + # dot product + return self.x * other.x + self.y * other.y + elif isinstance(other, (int, float)): + return Point(self.x * other, self.y * other) + else: + raise ValueError("cannot multiply Point by %s" % type(other)) + + def __rmul__(self, other): + if isinstance(other, (int, float)): + return self.__mul__(other) + else: + raise ValueError("cannot multiply Point by %s" % type(other)) + + def __repr__(self): + return "Point(%s,%s)" % (self.x, self.y) + + def length(self): + return math.sqrt(math.pow(self.x, 2.0) + math.pow(self.y, 2.0)) + + def unit(self): + return self.mul(1.0 / self.length()) + + def rotate_left(self): + return Point(-self.y, self.x) + + def rotate(self, angle): + return Point(self.x * math.cos(angle) - self.y * math.sin(angle), self.y * math.cos(angle) + self.x * math.sin(angle)) + + def as_int(self): + return Point(int(round(self.x)), int(round(self.y))) + + def as_tuple(self): + return (self.x, self.y) + + def __cmp__(self, other): + return cmp(self.as_tuple(), other.as_tuple()) + + def __getitem__(self, item): + return self.as_tuple()[item] + + def __len__(self): + return 2 + + +class Stitch(Point): + def __init__(self, x, y, color=None, jump=False, stop=False, trim=False): + self.x = x + self.y = y + self.color = color + self.jump = jump + self.trim = trim + self.stop = stop + + def __repr__(self): + return "Stitch(%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 "") + + +def descendants(node): + nodes = [] + element = EmbroideryElement(node) + + if element.has_style('display') and element.get_style('display') is None: + return [] + + if node.tag == SVG_DEFS_TAG: + return [] + + for child in node: + nodes.extend(descendants(child)) + + if node.tag in EMBROIDERABLE_TAGS: + nodes.append(node) + + return nodes + + +def get_nodes(effect): + """Get all XML nodes, or just those selected + + effect is an instance of a subclass of inkex.Effect. + """ + + if effect.selected: + nodes = [] + for node in effect.document.getroot().iter(): + if node.get("id") in effect.selected: + nodes.extend(descendants(node)) + else: + nodes = descendants(effect.document.getroot()) + + return nodes + + +def make_thread(color): + # strip off the leading "#" + if color.startswith("#"): + color = color[1:] + + thread = libembroidery.EmbThread() + thread.color = libembroidery.embColor_fromHexStr(color) + + thread.description = color + 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 + + if stitch.jump: + flags |= libembroidery.JUMP + + if stitch.trim: + flags |= libembroidery.TRIM + + if stitch.stop: + flags |= libembroidery.STOP + + return flags + + +def _string_to_floats(string): + floats = string.split(',') + return [float(num) for num in floats] + + +def get_origin(svg): + # The user can specify the embroidery origin by defining two guides + # named "embroidery origin" that intersect. + + namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) + all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) + label_attribute = inkex.addNS('label', 'inkscape') + guides = [guide for guide in all_guides + if guide.get(label_attribute, "").startswith("embroidery origin")] + + # document size used below + doc_size = list(get_doc_size(svg)) + + # convert the size from viewbox-relative to real-world pixels + viewbox_transform = get_viewbox_transform(svg) + simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) + + default = [doc_size[0] / 2.0, doc_size[1] / 2.0] + simpletransform.applyTransformToPoint(viewbox_transform, default) + default = Point(*default) + + if len(guides) < 2: + return default + + # Find out where the guides intersect. Only pay attention to the first two. + guides = guides[:2] + + lines = [] + for guide in guides: + # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates + position = Point(*_string_to_floats(guide.get('position'))) + position.y = doc_size[1] - position.y + + + # This one baffles me. I think inkscape might have gotten the order of + # their vector wrong? + parts = _string_to_floats(guide.get('orientation')) + direction = Point(parts[1], parts[0]) + + # We have a theoretically infinite line defined by a point on the line + # and a vector direction. Shapely can only deal in concrete line + # segments, so we'll pick points really far in either direction on the + # line and call it good enough. + lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) + + intersection = lines[0].intersection(lines[1]) + + if isinstance(intersection, shgeo.Point): + origin = [intersection.x, intersection.y] + simpletransform.applyTransformToPoint(viewbox_transform, origin) + return Point(*origin) + else: + # Either the two guides are the same line, or they're parallel. + return default + + +def write_embroidery_file(file_path, stitches, svg): + origin = get_origin(svg) + + pattern = libembroidery.embPattern_create() + last_color = None + + for stitch in stitches: + if stitch.color != last_color: + add_thread(pattern, make_thread(stitch.color)) + last_color = stitch.color + + 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) + + # convert from pixels to millimeters + libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) + + # SVG and embroidery disagree on the direction of the Y axis + libembroidery.embPattern_flipVertical(pattern) + + libembroidery.embPattern_write(pattern, file_path) diff --git a/inkstitch/stitches/__init__.py b/inkstitch/stitches/__init__.py new file mode 100644 index 00000000..7959ef62 --- /dev/null +++ b/inkstitch/stitches/__init__.py @@ -0,0 +1 @@ +from running_stitch import * diff --git a/inkstitch/stitches/running_stitch.py b/inkstitch/stitches/running_stitch.py new file mode 100644 index 00000000..3b1663dd --- /dev/null +++ b/inkstitch/stitches/running_stitch.py @@ -0,0 +1,62 @@ +""" Utility functions to produce running stitches. """ + + +def running_stitch(points, stitch_length): + """Generate running stitch along a path. + + Given a path and a stitch length, walk along the path in increments of the + stitch length. If sharp corners are encountered, an extra stitch will be + added at the corner to avoid rounding the corner. The starting and ending + point are always stitched. + + The path is described by a set of line segments, each connected to the next. + The line segments are described by a sequence of points. + """ + + if len(points) < 2: + return [] + + output = [points[0]] + segment_start = points[0] + last_segment_direction = None + + # This tracks the distance we've travelled along the current segment so + # far. Each time we make a stitch, we add the stitch_length to this + # value. If we fall off the end of the current segment, we carry over + # the remainder to the next segment. + distance = 0.0 + + for segment_end in points[1:]: + segment = segment_end - segment_start + segment_length = segment.length() + segment_direction = segment.unit() + + # corner detection + if last_segment_direction: + cos_angle_between = segment_direction * last_segment_direction + + # This checks whether the corner is sharper than 45 degrees. + if cos_angle_between < 0.5: + # Only add the corner point if it's more than 0.1mm away to + # avoid a double-stitch. + if (segment_start - output[-1]).length() > 0.1: + # add a stitch at the corner + output.append(segment_start) + + # next stitch needs to be stitch_length along this segment + distance = stitch_length + + while distance < segment_length: + output.append(segment_start + distance * segment_direction) + distance += stitch_length + + # prepare for the next segment + segment_start = segment_end + last_segment_direction = segment_direction + distance -= segment_length + + # stitch the last point unless we're already almos there + if (segment_start - points[-1]).length() > 0.1: + output.append(segment_start) + + return output diff --git a/inkstitch/utils/__init__.py b/inkstitch/utils/__init__.py new file mode 100644 index 00000000..f0d5783b --- /dev/null +++ b/inkstitch/utils/__init__.py @@ -0,0 +1 @@ +from geometry import * diff --git a/inkstitch/utils/geometry.py b/inkstitch/utils/geometry.py new file mode 100644 index 00000000..8b622554 --- /dev/null +++ b/inkstitch/utils/geometry.py @@ -0,0 +1,41 @@ +from .. import Point as InkstitchPoint +from shapely.geometry import LineString, Point as ShapelyPoint + +def cut(line, distance): + """ Cuts a LineString in two at a distance from its starting point. + + This is an example in the Shapely documentation. + """ + if distance <= 0.0 or distance >= line.length: + return [LineString(line)] + coords = list(line.coords) + for i, p in enumerate(coords): + pd = line.project(ShapelyPoint(p)) + if pd == distance: + return [ + LineString(coords[:i+1]), + LineString(coords[i:])] + if pd > distance: + cp = line.interpolate(distance) + return [ + LineString(coords[:i] + [(cp.x, cp.y)]), + LineString([(cp.x, cp.y)] + coords[i:])] + + +def cut_path(points, length): + """Return a subsection of at the start of the path that is length units long. + + Given a path denoted by a set of points, walk along it until we've travelled + the specified length and return a new path up to that point. + + If the original path isn't that long, just return it as is. + """ + + if len(points) < 2: + return points + + path = LineString(points) + subpath, rest = cut(path, length) + + return [InkstitchPoint(*point) for point in subpath.coords] + diff --git a/messages.po b/messages.po deleted file mode 100644 index c0653e4e..00000000 --- a/messages.po +++ /dev/null @@ -1,267 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-02-24 20:39-0500\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" -"Content-Transfer-Encoding: 8bit\n" - -msgid "Fill" -msgstr "" - -msgid "Manually routed fill stitching" -msgstr "" - -msgid "Angle of lines of stitches" -msgstr "" - -msgid "Flip fill (start right-to-left)" -msgstr "" - -msgid "Spacing between rows" -msgstr "" - -msgid "Maximum fill stitch length" -msgstr "" - -msgid "Stagger rows this many times before repeating" -msgstr "" - -msgid "Auto-Fill" -msgstr "" - -msgid "Automatically routed fill stitching" -msgstr "" - -msgid "Running stitch length (traversal between sections)" -msgstr "" - -msgid "Underlay" -msgstr "" - -msgid "AutoFill Underlay" -msgstr "" - -msgid "Fill angle (default: fill angle + 90 deg)" -msgstr "" - -msgid "Row spacing (default: 3x fill row spacing)" -msgstr "" - -msgid "Max stitch length" -msgstr "" - -msgid "" -"Unable to autofill. This most often happens because your shape is made up " -"of multiple sections that aren't connected." -msgstr "" - -msgid "" -"Unexpected error while generating fill stitches. Please send your SVG file " -"to lexelby@github." -msgstr "" - -msgid "Satin stitch along paths" -msgstr "" - -msgid "Running stitch length" -msgstr "" - -msgid "Zig-zag spacing (peak-to-peak)" -msgstr "" - -msgid "Repeats" -msgstr "" - -msgid "Satin Column" -msgstr "" - -msgid "Custom satin column" -msgstr "" - -msgid "Pull compensation" -msgstr "" - -msgid "Contour underlay" -msgstr "" - -msgid "Contour Underlay" -msgstr "" - -msgid "Stitch length" -msgstr "" - -msgid "Contour underlay inset amount" -msgstr "" - -msgid "Center-walk underlay" -msgstr "" - -msgid "Center-Walk Underlay" -msgstr "" - -msgid "Zig-zag underlay" -msgstr "" - -msgid "Zig-zag Underlay" -msgstr "" - -msgid "Zig-Zag spacing (peak-to-peak)" -msgstr "" - -msgid "Inset amount (default: half of contour underlay inset)" -msgstr "" - -msgid "" -"One or more rails crosses itself, and this is not allowed. Please split " -"into multiple satin columns." -msgstr "" - -msgid "satin column: One or more of the rungs doesn't intersect both rails." -msgstr "" - -msgid "Each rail should intersect both rungs once." -msgstr "" - -msgid "" -"satin column: One or more of the rungs intersects the rails more than once." -msgstr "" - -#, python-format -msgid "satin column: object %s has a fill (but should not)" -msgstr "" - -#, python-format -msgid "" -"satin column: object %(id)s has two paths with an unequal number of points " -"(%(length1)d and %(length2)d)" -msgstr "" - -msgid "" -"\n" -"\n" -"Seeing a 'no such option' message? Please restart Inkscape to fix." -msgstr "" - -msgid "No embroiderable paths selected." -msgstr "" - -msgid "No embroiderable paths found in document." -msgstr "" - -msgid "" -"Tip: use Path -> Object to Path to convert non-paths before embroidering." -msgstr "" - -msgid "Embroidery" -msgstr "" - -msgid "These settings will be applied to 1 object." -msgstr "" - -#, python-format -msgid "These settings will be applied to %d objects." -msgstr "" - -msgid "" -"Some settings had different values across objects. Select a value from the " -"dropdown or enter a new one." -msgstr "" - -#, python-format -msgid "Disabling this tab will disable the following %d tabs." -msgstr "" - -msgid "Disabling this tab will disable the following tab." -msgstr "" - -#, python-format -msgid "Enabling this tab will disable %s and vice-versa." -msgstr "" - -msgid "Inkscape objects" -msgstr "" - -msgid "Embroidery Params" -msgstr "" - -msgid "Presets" -msgstr "" - -msgid "Load" -msgstr "" - -msgid "Add" -msgstr "" - -msgid "Overwrite" -msgstr "" - -msgid "Delete" -msgstr "" - -msgid "Cancel" -msgstr "" - -msgid "Use Last Settings" -msgstr "" - -msgid "Apply and Quit" -msgstr "" - -msgid "Preview" -msgstr "" - -msgid "Internal Error" -msgstr "" - -msgid "Please enter or select a preset name first." -msgstr "" - -msgid "Preset" -msgstr "" - -#, python-format -msgid "Preset \"%s\" not found." -msgstr "" - -#, python-format -msgid "" -"Preset \"%s\" already exists. Please use another name or press \"Overwrite\"" -msgstr "" - -msgid "Embroidery Simulation" -msgstr "" - -#, python-format -msgid "parseLengthWithUnits: unknown unit %s" -msgstr "" - -#, python-format -msgid "Unknown unit: %s" -msgstr "" - -msgid "TRIM after" -msgstr "" - -msgid "Trim thread after this object (for supported machines and file formats)" -msgstr "" - -msgid "STOP after" -msgstr "" - -msgid "" -"Add STOP instruction after this object (for supported machines and file " -"formats)" -msgstr "" -- cgit v1.2.3