From 1b31806423c8fec4040fed6d1009db016860b763 Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 1 May 2018 20:37:51 -0400 Subject: rename inkstitch/ to lib/ You can't have a module and a package named the same thing. PyInstaller wants to import the main script as if it were a module, and this doesn't work unless there's no directory of the same name with a __init__.py in it. --- inkstitch.py | 4 +- inkstitch/__init__.py | 298 -------------- inkstitch/elements/__init__.py | 6 - inkstitch/elements/auto_fill.py | 108 ----- inkstitch/elements/element.py | 254 ------------ inkstitch/elements/fill.py | 97 ----- inkstitch/elements/polyline.py | 72 ---- inkstitch/elements/satin_column.py | 403 ------------------- inkstitch/elements/stroke.py | 160 -------- inkstitch/extensions/__init__.py | 6 - inkstitch/extensions/base.py | 222 ---------- inkstitch/extensions/embroider.py | 86 ---- inkstitch/extensions/input.py | 66 --- inkstitch/extensions/palettes.py | 111 ----- inkstitch/extensions/params.py | 756 ----------------------------------- inkstitch/extensions/print_pdf.py | 391 ------------------ inkstitch/extensions/simulate.py | 27 -- inkstitch/simulator.py | 252 ------------ inkstitch/stitch_plan/__init__.py | 1 - inkstitch/stitch_plan/stitch_plan.py | 227 ----------- inkstitch/stitch_plan/stop.py | 27 -- inkstitch/stitch_plan/ties.py | 51 --- inkstitch/stitch_plan/trim.py | 23 -- inkstitch/stitches/__init__.py | 3 - inkstitch/stitches/auto_fill.py | 447 --------------------- inkstitch/stitches/fill.py | 245 ------------ inkstitch/stitches/running_stitch.py | 66 --- inkstitch/svg.py | 76 ---- inkstitch/threads/__init__.py | 3 - inkstitch/threads/catalog.py | 95 ----- inkstitch/threads/color.py | 82 ---- inkstitch/threads/palette.py | 72 ---- inkstitch/utils/__init__.py | 4 - inkstitch/utils/cache.py | 8 - inkstitch/utils/geometry.py | 102 ----- inkstitch/utils/inkscape.py | 15 - inkstitch/utils/io.py | 17 - lib/__init__.py | 298 ++++++++++++++ lib/elements/__init__.py | 6 + lib/elements/auto_fill.py | 108 +++++ lib/elements/element.py | 254 ++++++++++++ lib/elements/fill.py | 97 +++++ lib/elements/polyline.py | 72 ++++ lib/elements/satin_column.py | 403 +++++++++++++++++++ lib/elements/stroke.py | 160 ++++++++ lib/extensions/__init__.py | 6 + lib/extensions/base.py | 222 ++++++++++ lib/extensions/embroider.py | 86 ++++ lib/extensions/input.py | 66 +++ lib/extensions/palettes.py | 111 +++++ lib/extensions/params.py | 756 +++++++++++++++++++++++++++++++++++ lib/extensions/print_pdf.py | 391 ++++++++++++++++++ lib/extensions/simulate.py | 27 ++ lib/simulator.py | 252 ++++++++++++ lib/stitch_plan/__init__.py | 1 + lib/stitch_plan/stitch_plan.py | 227 +++++++++++ lib/stitch_plan/stop.py | 27 ++ lib/stitch_plan/ties.py | 51 +++ lib/stitch_plan/trim.py | 23 ++ lib/stitches/__init__.py | 3 + lib/stitches/auto_fill.py | 447 +++++++++++++++++++++ lib/stitches/fill.py | 245 ++++++++++++ lib/stitches/running_stitch.py | 66 +++ lib/svg.py | 76 ++++ lib/threads/__init__.py | 3 + lib/threads/catalog.py | 95 +++++ lib/threads/color.py | 82 ++++ lib/threads/palette.py | 72 ++++ lib/utils/__init__.py | 4 + lib/utils/cache.py | 8 + lib/utils/geometry.py | 102 +++++ lib/utils/inkscape.py | 15 + lib/utils/io.py | 17 + 73 files changed, 4881 insertions(+), 4881 deletions(-) delete mode 100644 inkstitch/__init__.py delete mode 100644 inkstitch/elements/__init__.py delete mode 100644 inkstitch/elements/auto_fill.py delete mode 100644 inkstitch/elements/element.py delete mode 100644 inkstitch/elements/fill.py delete mode 100644 inkstitch/elements/polyline.py delete mode 100644 inkstitch/elements/satin_column.py delete mode 100644 inkstitch/elements/stroke.py delete mode 100644 inkstitch/extensions/__init__.py delete mode 100644 inkstitch/extensions/base.py delete mode 100644 inkstitch/extensions/embroider.py delete mode 100644 inkstitch/extensions/input.py delete mode 100644 inkstitch/extensions/palettes.py delete mode 100644 inkstitch/extensions/params.py delete mode 100644 inkstitch/extensions/print_pdf.py delete mode 100644 inkstitch/extensions/simulate.py delete mode 100644 inkstitch/simulator.py delete mode 100644 inkstitch/stitch_plan/__init__.py delete mode 100644 inkstitch/stitch_plan/stitch_plan.py delete mode 100644 inkstitch/stitch_plan/stop.py delete mode 100644 inkstitch/stitch_plan/ties.py delete mode 100644 inkstitch/stitch_plan/trim.py delete mode 100644 inkstitch/stitches/__init__.py delete mode 100644 inkstitch/stitches/auto_fill.py delete mode 100644 inkstitch/stitches/fill.py delete mode 100644 inkstitch/stitches/running_stitch.py delete mode 100644 inkstitch/svg.py delete mode 100644 inkstitch/threads/__init__.py delete mode 100644 inkstitch/threads/catalog.py delete mode 100644 inkstitch/threads/color.py delete mode 100644 inkstitch/threads/palette.py delete mode 100644 inkstitch/utils/__init__.py delete mode 100644 inkstitch/utils/cache.py delete mode 100644 inkstitch/utils/geometry.py delete mode 100644 inkstitch/utils/inkscape.py delete mode 100644 inkstitch/utils/io.py create mode 100644 lib/__init__.py create mode 100644 lib/elements/__init__.py create mode 100644 lib/elements/auto_fill.py create mode 100644 lib/elements/element.py create mode 100644 lib/elements/fill.py create mode 100644 lib/elements/polyline.py create mode 100644 lib/elements/satin_column.py create mode 100644 lib/elements/stroke.py create mode 100644 lib/extensions/__init__.py create mode 100644 lib/extensions/base.py create mode 100644 lib/extensions/embroider.py create mode 100644 lib/extensions/input.py create mode 100644 lib/extensions/palettes.py create mode 100644 lib/extensions/params.py create mode 100644 lib/extensions/print_pdf.py create mode 100644 lib/extensions/simulate.py create mode 100644 lib/simulator.py create mode 100644 lib/stitch_plan/__init__.py create mode 100644 lib/stitch_plan/stitch_plan.py create mode 100644 lib/stitch_plan/stop.py create mode 100644 lib/stitch_plan/ties.py create mode 100644 lib/stitch_plan/trim.py create mode 100644 lib/stitches/__init__.py create mode 100644 lib/stitches/auto_fill.py create mode 100644 lib/stitches/fill.py create mode 100644 lib/stitches/running_stitch.py create mode 100644 lib/svg.py create mode 100644 lib/threads/__init__.py create mode 100644 lib/threads/catalog.py create mode 100644 lib/threads/color.py create mode 100644 lib/threads/palette.py create mode 100644 lib/utils/__init__.py create mode 100644 lib/utils/cache.py create mode 100644 lib/utils/geometry.py create mode 100644 lib/utils/inkscape.py create mode 100644 lib/utils/io.py diff --git a/inkstitch.py b/inkstitch.py index fe8d6ecb..a9ce829e 100644 --- a/inkstitch.py +++ b/inkstitch.py @@ -1,8 +1,8 @@ import sys import traceback from argparse import ArgumentParser -from inkstitch.utils import save_stderr, restore_stderr -from inkstitch import extensions +from lib.utils import save_stderr, restore_stderr +from lib import extensions parser = ArgumentParser() diff --git a/inkstitch/__init__.py b/inkstitch/__init__.py deleted file mode 100644 index 45eed3a6..00000000 --- a/inkstitch/__init__.py +++ /dev/null @@ -1,298 +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 -from inkstitch.utils import cache -from inkstitch.utils.geometry import Point - -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 - - -# 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') -INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') -INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') - -EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) - -dbg = open(os.devnull, "w") - -translation = None -_ = lambda message: message - - -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') - - global translation, _ - - translation = gettext.translation("inkstitch", locale_dir, fallback=True) - _ = 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 - -@cache -def get_stroke_scale(node): - doc_width, doc_height = get_doc_size(node) - viewbox = node.get('viewBox').strip().replace(',', ' ').split() - return doc_width / float(viewbox[2]) - - -class Stitch(Point): - def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): - self.x = x - self.y = y - self.color = color - self.jump = jump - self.trim = trim - self.stop = stop - 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 " ") - - -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 - - 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, stitch_plan, svg): - origin = get_origin(svg) - - pattern = libembroidery.embPattern_create() - - for color_block in stitch_plan: - add_thread(pattern, make_thread(color_block.color)) - - for stitch in color_block: - if stitch.stop and stitch is not color_block.last_stitch: - # A STOP stitch that is not at the end of a color block - # occurs when the user specified "STOP after". "STOP" is the - # same thing as a color change, and the user will assign a - # special color at the machine that tells it to pause after. - # We need to add another copy of the same color here so that - # the stitches after the STOP are still the same color. - add_thread(pattern, make_thread(color_block.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/elements/__init__.py b/inkstitch/elements/__init__.py deleted file mode 100644 index 7e05e19c..00000000 --- a/inkstitch/elements/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from auto_fill import AutoFill -from fill import Fill -from stroke import Stroke -from satin_column import SatinColumn -from element import EmbroideryElement -from polyline import Polyline diff --git a/inkstitch/elements/auto_fill.py b/inkstitch/elements/auto_fill.py deleted file mode 100644 index 6eb1f10c..00000000 --- a/inkstitch/elements/auto_fill.py +++ /dev/null @@ -1,108 +0,0 @@ -import math -from .. import _ -from .element import param, Patch -from ..utils import cache -from .fill import Fill -from shapely import geometry as shgeo -from ..stitches import auto_fill - - -class AutoFill(Fill): - element_name = _("Auto-Fill") - - @property - @param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True) - def auto_fill(self): - return self.get_boolean_param('auto_fill', True) - - @property - @cache - def outline(self): - return self.shape.boundary[0] - - @property - @cache - def outline_length(self): - return self.outline.length - - @property - def flip(self): - return False - - @property - @param('running_stitch_length_mm', _('Running stitch length (traversal between sections)'), unit='mm', type='float', default=1.5) - def running_stitch_length(self): - return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) - - @property - @param('fill_underlay', _('Underlay'), type='toggle', group=_('AutoFill Underlay'), default=False) - def fill_underlay(self): - return self.get_boolean_param("fill_underlay", default=False) - - @property - @param('fill_underlay_angle', _('Fill angle (default: fill angle + 90 deg)'), unit='deg', group=_('AutoFill Underlay'), type='float') - @cache - def fill_underlay_angle(self): - underlay_angle = self.get_float_param("fill_underlay_angle") - - if underlay_angle: - return math.radians(underlay_angle) - else: - return self.angle + math.pi / 2.0 - - @property - @param('fill_underlay_row_spacing_mm', _('Row spacing (default: 3x fill row spacing)'), unit='mm', group=_('AutoFill Underlay'), type='float') - @cache - def fill_underlay_row_spacing(self): - return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3 - - @property - @param('fill_underlay_max_stitch_length_mm', _('Max stitch length'), unit='mm', group=_('AutoFill Underlay'), type='float') - @cache - def fill_underlay_max_stitch_length(self): - 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) - 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) - if not isinstance(shape, shgeo.MultiPolygon): - shape = shgeo.MultiPolygon([shape]) - return shape - else: - return self.shape - - def to_patches(self, last_patch): - stitches = [] - - if last_patch is None: - starting_point = None - else: - starting_point = last_patch.stitches[-1] - - if self.fill_underlay: - stitches.extend(auto_fill(self.underlay_shape, - self.fill_underlay_angle, - self.fill_underlay_row_spacing, - self.fill_underlay_row_spacing, - self.fill_underlay_max_stitch_length, - self.running_stitch_length, - self.staggers, - starting_point)) - starting_point = stitches[-1] - - stitches.extend(auto_fill(self.shape, - self.angle, - self.row_spacing, - self.end_row_spacing, - self.max_stitch_length, - self.running_stitch_length, - self.staggers, - starting_point)) - - return [Patch(stitches=stitches, color=self.color)] diff --git a/inkstitch/elements/element.py b/inkstitch/elements/element.py deleted file mode 100644 index cfca3782..00000000 --- a/inkstitch/elements/element.py +++ /dev/null @@ -1,254 +0,0 @@ -import sys -from copy import deepcopy - -from ..utils import cache -from shapely import geometry as shgeo -from .. import _, PIXELS_PER_MM, get_viewbox_transform, get_stroke_scale, convert_length - -# inkscape-provided utilities -import simpletransform -import simplestyle -import cubicsuperpath -from cspsubdiv import cspsubdiv - -class Patch: - """A raw collection of stitches with attached instructions.""" - - def __init__(self, color=None, stitches=None, trim_after=False, stop_after=False, stitch_as_is=False): - self.color = color - self.stitches = stitches or [] - self.trim_after = trim_after - self.stop_after = stop_after - self.stitch_as_is = stitch_as_is - - def __add__(self, other): - if isinstance(other, Patch): - return Patch(self.color, self.stitches + other.stitches) - else: - raise TypeError("Patch can only be added to another Patch") - - def add_stitch(self, stitch): - self.stitches.append(stitch) - - def reverse(self): - 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 - 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): - value = default - - if value is None: - return value - - 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 - @cache - def stroke_width(self): - width = self.get_style("stroke-width") - - if width is None: - return 1.0 - - width = convert_length(width) - - return width * get_stroke_scale(self.node.getroottree().getroot()) - - @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 strip_control_points(self, subpath): - return [point for control_before, point, control_after in subpath] - - def flatten(self, path): - """approximate a path containing beziers with a series of points""" - - path = deepcopy(path) - cspsubdiv(path, 0.1) - - 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) - - 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) diff --git a/inkstitch/elements/fill.py b/inkstitch/elements/fill.py deleted file mode 100644 index a74a897d..00000000 --- a/inkstitch/elements/fill.py +++ /dev/null @@ -1,97 +0,0 @@ -from .. import _, PIXELS_PER_MM -from .element import param, EmbroideryElement, Patch -from ..utils import cache -from shapely import geometry as shgeo -import math -from ..stitches import running_stitch, auto_fill, legacy_fill - -class Fill(EmbroideryElement): - element_name = _("Fill") - - def __init__(self, *args, **kwargs): - super(Fill, self).__init__(*args, **kwargs) - - @property - @param('auto_fill', _('Manually routed fill stitching'), type='toggle', inverse=True, default=True) - def auto_fill(self): - return self.get_boolean_param('auto_fill', True) - - @property - @param('angle', _('Angle of lines of stitches'), unit='deg', type='float', default=0) - @cache - def angle(self): - return math.radians(self.get_float_param('angle', 0)) - - @property - def color(self): - return self.get_style("fill") - - @property - @param('flip', _('Flip fill (start right-to-left)'), type='boolean', default=False) - def flip(self): - return self.get_boolean_param("flip", False) - - @property - @param('row_spacing_mm', _('Spacing between rows'), unit='mm', type='float', default=0.25) - def row_spacing(self): - return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM) - - @property - def end_row_spacing(self): - return self.get_float_param("end_row_spacing_mm") - - @property - @param('max_stitch_length_mm', _('Maximum fill stitch length'), unit='mm', type='float', default=3.0) - def max_stitch_length(self): - return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM) - - @property - @param('staggers', _('Stagger rows this many times before repeating'), type='int', default=4) - def staggers(self): - return self.get_int_param("staggers", 4) - - @property - @cache - def paths(self): - return self.flatten(self.parse_path()) - - @property - @cache - def shape(self): - poly_ary = [] - for sub_path in self.paths: - point_ary = [] - last_pt = None - for pt in sub_path: - if (last_pt is not None): - vp = (pt[0] - last_pt[0], pt[1] - last_pt[1]) - dp = math.sqrt(math.pow(vp[0], 2.0) + math.pow(vp[1], 2.0)) - # dbg.write("dp %s\n" % dp) - if (dp > 0.01): - # I think too-close points confuse shapely. - point_ary.append(pt) - last_pt = pt - else: - last_pt = pt - if point_ary: - poly_ary.append(point_ary) - - # shapely's idea of "holes" are to subtract everything in the second set - # from the first. So let's at least make sure the "first" thing is the - # biggest path. - # TODO: actually figure out which things are holes and which are shells - poly_ary.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True) - - polygon = shgeo.MultiPolygon([(poly_ary[0], poly_ary[1:])]) - # print >> sys.stderr, "polygon valid:", polygon.is_valid - return polygon - - def to_patches(self, last_patch): - stitch_lists = legacy_fill(self.shape, - self.angle, - self.row_spacing, - self.end_row_spacing, - self.max_stitch_length, - self.flip, - self.staggers) - return [Patch(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists] diff --git a/inkstitch/elements/polyline.py b/inkstitch/elements/polyline.py deleted file mode 100644 index 6ded9fd1..00000000 --- a/inkstitch/elements/polyline.py +++ /dev/null @@ -1,72 +0,0 @@ -from .. import _, Point -from .element import param, EmbroideryElement, Patch -from ..utils import cache - - -class Polyline(EmbroideryElement): - # Handle a element, which is treated as a set of points to - # stitch exactly. - # - # elements are pretty rare in SVG, from what I can tell. - # Anything you can do with a can also be done with a

, and - # much more. - # - # Notably, EmbroiderModder2 uses elements when converting from - # common machine embroidery file formats to SVG. Handling those here lets - # users use File -> Import to pull in existing designs they may have - # obtained, for example purchased fonts. - - @property - def points(self): - # example: "1,2 0,0 1.5,3 4,2" - - points = self.node.get('points') - points = points.split(" ") - points = [[float(coord) for coord in point.split(",")] for point in points] - - return 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 - # svg transforms that is in our superclass, we'll convert the polyline - # to a degenerate cubic superpath in which the bezier handles are on - # the segment endpoints. - - path = [[[point[:], point[:], point[:]] for point in self.points]] - - return path - - @property - @cache - def csp(self): - csp = self.parse_path() - - return csp - - @property - def color(self): - # EmbroiderModder2 likes to use the `stroke` property directly instead - # of CSS. - return self.get_style("stroke") or self.node.get("stroke") - - @property - def stitches(self): - # For a , we'll stitch the points exactly as they exist in - # the SVG, with no stitch spacing interpolation, flattening, etc. - - # See the comments in the parent class's parse_path method for a - # description of the CSP data structure. - - stitches = [point for handle_before, point, handle_after in self.csp[0]] - - return stitches - - def to_patches(self, last_patch): - patch = Patch(color=self.color) - - for stitch in self.stitches: - patch.add_stitch(Point(*stitch)) - - return [patch] diff --git a/inkstitch/elements/satin_column.py b/inkstitch/elements/satin_column.py deleted file mode 100644 index d22f5145..00000000 --- a/inkstitch/elements/satin_column.py +++ /dev/null @@ -1,403 +0,0 @@ -from itertools import chain, izip - -from .. import _, Point -from .element import param, EmbroideryElement, Patch -from ..utils import cache -from shapely import geometry as shgeo, ops as shops - - -class SatinColumn(EmbroideryElement): - element_name = _("Satin Column") - - def __init__(self, *args, **kwargs): - super(SatinColumn, self).__init__(*args, **kwargs) - - @property - @param('satin_column', _('Custom satin column'), type='toggle') - def satin_column(self): - return self.get_boolean_param("satin_column") - - @property - def color(self): - return self.get_style("stroke") - - @property - @param('zigzag_spacing_mm', _('Zig-zag spacing (peak-to-peak)'), unit='mm', type='float', default=0.4) - def zigzag_spacing(self): - # peak-to-peak distance between zigzags - return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01) - - @property - @param('pull_compensation_mm', _('Pull compensation'), unit='mm', type='float') - def pull_compensation(self): - # In satin stitch, the stitches have a tendency to pull together and - # narrow the entire column. We can compensate for this by stitching - # wider than we desire the column to end up. - return self.get_float_param("pull_compensation_mm", 0) - - @property - @param('contour_underlay', _('Contour underlay'), type='toggle', group=_('Contour Underlay')) - def contour_underlay(self): - # "Contour underlay" is stitching just inside the rectangular shape - # of the satin column; that is, up one side and down the other. - return self.get_boolean_param("contour_underlay") - - @property - @param('contour_underlay_stitch_length_mm', _('Stitch length'), unit='mm', group=_('Contour Underlay'), type='float', default=1.5) - def contour_underlay_stitch_length(self): - return max(self.get_float_param("contour_underlay_stitch_length_mm", 1.5), 0.01) - - @property - @param('contour_underlay_inset_mm', _('Contour underlay inset amount'), unit='mm', group=_('Contour Underlay'), type='float', default=0.4) - def contour_underlay_inset(self): - # how far inside the edge of the column to stitch the underlay - return self.get_float_param("contour_underlay_inset_mm", 0.4) - - @property - @param('center_walk_underlay', _('Center-walk underlay'), type='toggle', group=_('Center-Walk Underlay')) - def center_walk_underlay(self): - # "Center walk underlay" is stitching down and back in the centerline - # between the two sides of the satin column. - return self.get_boolean_param("center_walk_underlay") - - @property - @param('center_walk_underlay_stitch_length_mm', _('Stitch length'), unit='mm', group=_('Center-Walk Underlay'), type='float', default=1.5) - def center_walk_underlay_stitch_length(self): - return max(self.get_float_param("center_walk_underlay_stitch_length_mm", 1.5), 0.01) - - @property - @param('zigzag_underlay', _('Zig-zag underlay'), type='toggle', group=_('Zig-zag Underlay')) - def zigzag_underlay(self): - return self.get_boolean_param("zigzag_underlay") - - @property - @param('zigzag_underlay_spacing_mm', _('Zig-Zag spacing (peak-to-peak)'), unit='mm', group=_('Zig-zag Underlay'), type='float', default=3) - def zigzag_underlay_spacing(self): - return max(self.get_float_param("zigzag_underlay_spacing_mm", 3), 0.01) - - @property - @param('zigzag_underlay_inset_mm', _('Inset amount (default: half of contour underlay inset)'), unit='mm', group=_('Zig-zag Underlay'), type='float') - def zigzag_underlay_inset(self): - # how far in from the edge of the satin the points in the zigzags - # should be - - # Default to half of the contour underlay inset. That is, if we're - # doing both contour underlay and zigzag underlay, make sure the - # points of the zigzag fall outside the contour underlay but inside - # the edges of the satin column. - return self.get_float_param("zigzag_underlay_inset_mm") or self.contour_underlay_inset / 2.0 - - @property - @cache - def csp(self): - return self.parse_path() - - @property - @cache - def flattened_beziers(self): - if len(self.csp) == 2: - return self.simple_flatten_beziers() - else: - return self.flatten_beziers_with_rungs() - - - def flatten_beziers_with_rungs(self): - input_paths = [self.flatten([path]) for path in self.csp] - input_paths = [shgeo.LineString(path[0]) for path in input_paths] - - paths = input_paths[:] - paths.sort(key=lambda path: path.length, reverse=True) - - # Imagine a satin column as a curvy ladder. - # The two long paths are the "rails" of the ladder. The remainder are - # the "rungs". - rails = paths[:2] - rungs = shgeo.MultiLineString(paths[2:]) - - # The rails should stay in the order they were in the original CSP. - # (this lets the user control where the satin starts and ends) - rails.sort(key=lambda rail: input_paths.index(rail)) - - result = [] - - for rail in rails: - if not rail.is_simple: - self.fatal(_("One or more rails crosses itself, and this is not allowed. Please split into multiple satin columns.")) - - # handle null intersections here? - linestrings = shops.split(rail, rungs) - - #print >> dbg, "rails and rungs", [str(rail) for rail in rails], [str(rung) for rung in rungs] - if len(linestrings.geoms) < len(rungs.geoms) + 1: - self.fatal(_("satin column: One or more of the rungs doesn't intersect both rails.") + " " + _("Each rail should intersect both rungs once.")) - elif len(linestrings.geoms) > len(rungs.geoms) + 1: - self.fatal(_("satin column: One or more of the rungs intersects the rails more than once.") + " " + _("Each rail should intersect both rungs once.")) - - paths = [[Point(*coord) for coord in ls.coords] for ls in linestrings.geoms] - result.append(paths) - - return zip(*result) - - - def simple_flatten_beziers(self): - # Given a pair of paths made up of bezier segments, flatten - # each individual bezier segment into line segments that approximate - # the curves. Retain the divisions between beziers -- we'll use those - # later. - - paths = [] - - for path in self.csp: - # See the documentation in the parent class for parse_path() for a - # description of the format of the CSP. Each bezier is constructed - # using two neighboring 3-tuples in the list. - - flattened_path = [] - - # iterate over pairs of 3-tuples - for prev, current in zip(path[:-1], path[1:]): - flattened_segment = self.flatten([[prev, current]]) - flattened_segment = [Point(x, y) for x, y in flattened_segment[0]] - flattened_path.append(flattened_segment) - - paths.append(flattened_path) - - return zip(*paths) - - def validate_satin_column(self): - # The node should have exactly two paths with no fill. Each - # path should have the same number of points, meaning that they - # will both be made up of the same number of bezier curves. - - node_id = self.node.get("id") - - if self.get_style("fill") is not None: - self.fatal(_("satin column: object %s has a fill (but should not)") % node_id) - - if len(self.csp) == 2: - if len(self.csp[0]) != len(self.csp[1]): - self.fatal(_("satin column: object %(id)s has two paths with an unequal number of points (%(length1)d and %(length2)d)") % \ - dict(id=node_id, length1=len(self.csp[0]), length2=len(self.csp[1]))) - - def offset_points(self, pos1, pos2, offset_px): - # Expand or contract two points about their midpoint. This is - # useful for pull compensation and insetting underlay. - - distance = (pos1 - pos2).length() - - if distance < 0.0001: - # if they're the same point, we don't know which direction - # to offset in, so we have to just return the points - return pos1, pos2 - - # don't contract beyond the midpoint, or we'll start expanding - if offset_px < -distance / 2.0: - offset_px = -distance / 2.0 - - pos1 = pos1 + (pos1 - pos2).unit() * offset_px - pos2 = pos2 + (pos2 - pos1).unit() * offset_px - - return pos1, pos2 - - def walk(self, path, start_pos, start_index, distance): - # Move pixels along , which is a sequence of line - # segments defined by points. - - # is the index of the line segment in that - # we're currently on. is where along that line - # segment we are. Return a new position and index. - - # print >> dbg, "walk", start_pos, start_index, distance - - pos = start_pos - index = start_index - last_index = len(path) - 1 - distance_remaining = distance - - while True: - if index >= last_index: - return pos, index - - segment_end = path[index + 1] - segment = segment_end - pos - segment_length = segment.length() - - if segment_length > distance_remaining: - # our walk ends partway along this segment - return pos + segment.unit() * distance_remaining, index - else: - # our walk goes past the end of this segment, so advance - # one point - index += 1 - distance_remaining -= segment_length - pos = segment_end - - def walk_paths(self, spacing, offset): - # Take a bezier segment from each path in turn, and plot out an - # equal number of points on each bezier. Return the points plotted. - # The points will be contracted or expanded by offset using - # offset_points(). - - points = [[], []] - - def add_pair(pos1, pos2): - pos1, pos2 = self.offset_points(pos1, pos2, offset) - points[0].append(pos1) - points[1].append(pos2) - - # We may not be able to fit an even number of zigzags in each pair of - # beziers. We'll store the remaining bit of the beziers after handling - # each section. - remainder_path1 = [] - remainder_path2 = [] - - for segment1, segment2 in self.flattened_beziers: - subpath1 = remainder_path1 + segment1 - subpath2 = remainder_path2 + segment2 - - len1 = shgeo.LineString(subpath1).length - len2 = shgeo.LineString(subpath2).length - - # Base the number of stitches in each section on the _longest_ of - # the two beziers. Otherwise, things could get too sparse when one - # side is significantly longer (e.g. when going around a corner). - # The risk here is that we poke a hole in the fabric if we try to - # cram too many stitches on the short bezier. The user will need - # to avoid this through careful construction of paths. - # - # TODO: some commercial machine embroidery software compensates by - # pulling in some of the "inner" stitches toward the center a bit. - - # note, this rounds down using integer-division - num_points = max(len1, len2) / spacing - - spacing1 = len1 / num_points - spacing2 = len2 / num_points - - pos1 = subpath1[0] - index1 = 0 - - pos2 = subpath2[0] - index2 = 0 - - for i in xrange(int(num_points)): - add_pair(pos1, pos2) - - pos1, index1 = self.walk(subpath1, pos1, index1, spacing1) - pos2, index2 = self.walk(subpath2, pos2, index2, spacing2) - - if index1 < len(subpath1) - 1: - remainder_path1 = [pos1] + subpath1[index1 + 1:] - else: - remainder_path1 = [] - - if index2 < len(subpath2) - 1: - remainder_path2 = [pos2] + subpath2[index2 + 1:] - else: - remainder_path2 = [] - - # We're off by one in the algorithm above, so we need one more - # pair of points. We also want to add points at the very end to - # make sure we match the vectors on screen as best as possible. - # Try to avoid doing both if they're going to stack up too - # closely. - - end1 = remainder_path1[-1] - end2 = remainder_path2[-1] - - if (end1 - pos1).length() > 0.3 * spacing: - add_pair(pos1, pos2) - - add_pair(end1, end2) - - return points - - def do_contour_underlay(self): - # "contour walk" underlay: do stitches up one side and down the - # other. - forward, back = self.walk_paths(self.contour_underlay_stitch_length, - -self.contour_underlay_inset) - return Patch(color=self.color, stitches=(forward + list(reversed(back)))) - - def do_center_walk(self): - # Center walk underlay is just a running stitch down and back on the - # center line between the bezier curves. - - # Do it like contour underlay, but inset all the way to the center. - forward, back = self.walk_paths(self.center_walk_underlay_stitch_length, - -100000) - return Patch(color=self.color, stitches=(forward + list(reversed(back)))) - - def do_zigzag_underlay(self): - # zigzag underlay, usually done at a much lower density than the - # satin itself. It looks like this: - # - # \/\/\/\/\/\/\/\/\/\/| - # /\/\/\/\/\/\/\/\/\/\| - # - # In combination with the "contour walk" underlay, this is the - # "German underlay" described here: - # http://www.mrxstitch.com/underlay-what-lies-beneath-machine-embroidery/ - - patch = Patch(color=self.color) - - sides = self.walk_paths(self.zigzag_underlay_spacing / 2.0, - -self.zigzag_underlay_inset) - - # This organizes the points in each side in the order that they'll be - # visited. - sides = [sides[0][::2] + list(reversed(sides[0][1::2])), - sides[1][1::2] + list(reversed(sides[1][::2]))] - - # This fancy bit of iterable magic just repeatedly takes a point - # from each side in turn. - for point in chain.from_iterable(izip(*sides)): - patch.add_stitch(point) - - return patch - - def do_satin(self): - # satin: do a zigzag pattern, alternating between the paths. The - # zigzag looks like this to make the satin stitches look perpendicular - # to the column: - # - # /|/|/|/|/|/|/|/| - - # print >> dbg, "satin", self.zigzag_spacing, self.pull_compensation - - patch = Patch(color=self.color) - - sides = self.walk_paths(self.zigzag_spacing, self.pull_compensation) - - # Like in zigzag_underlay(): take a point from each side in turn. - for point in chain.from_iterable(izip(*sides)): - patch.add_stitch(point) - - return patch - - def to_patches(self, last_patch): - # Stitch a variable-width satin column, zig-zagging between two paths. - - # The algorithm will draw zigzags between each consecutive pair of - # beziers. The boundary points between beziers serve as "checkpoints", - # allowing the user to control how the zigzags flow around corners. - - # First, verify that we have valid paths. - self.validate_satin_column() - - patches = [] - - if self.center_walk_underlay: - patches.append(self.do_center_walk()) - - if self.contour_underlay: - patches.append(self.do_contour_underlay()) - - if self.zigzag_underlay: - # zigzag underlay comes after contour walk underlay, so that the - # zigzags sit on the contour walk underlay like rail ties on rails. - patches.append(self.do_zigzag_underlay()) - - patches.append(self.do_satin()) - - return patches diff --git a/inkstitch/elements/stroke.py b/inkstitch/elements/stroke.py deleted file mode 100644 index 360e3744..00000000 --- a/inkstitch/elements/stroke.py +++ /dev/null @@ -1,160 +0,0 @@ -import sys -from .. import _, Point -from .element import param, EmbroideryElement, Patch -from ..utils import cache - - -warned_about_legacy_running_stitch = False - - -class Stroke(EmbroideryElement): - element_name = "Stroke" - - @property - @param('satin_column', _('Satin stitch along paths'), type='toggle', inverse=True) - def satin_column(self): - return self.get_boolean_param("satin_column") - - @property - def color(self): - return self.get_style("stroke") - - @property - def dashed(self): - return self.get_style("stroke-dasharray") is not None - - @property - @param('running_stitch_length_mm', _('Running stitch length'), unit='mm', type='float', default=1.5) - def running_stitch_length(self): - return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) - - @property - @param('zigzag_spacing_mm', _('Zig-zag spacing (peak-to-peak)'), unit='mm', type='float', default=0.4) - @cache - def zigzag_spacing(self): - return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01) - - @property - @param('repeats', _('Repeats'), type='int', default="1") - def repeats(self): - return self.get_int_param("repeats", 1) - - @property - def paths(self): - path = self.parse_path() - - if self.manual_stitch_mode: - return [self.strip_control_points(subpath) for subpath in path] - else: - return self.flatten(path) - - @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') - - 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 - - if self.dashed: - return True - elif stroke_width <= 0.5 and self.get_float_param('running_stitch_length_mm', None) is not None: - # if they use a stroke width less than 0.5 AND they specifically set a running stitch - # length, then assume they intend to use the deprecated <= 0.5 method to set running - # stitch. - # - # Note that we use self.get_style("stroke_width") _not_ self.stroke_width above. We - # explicitly want the stroke width in "user units" ("document units") -- that is, what - # the user sees in inkscape's stroke settings. - # - # Also note that we don't use self.running_stitch_length_mm above. This is because we - # want to see if they set a running stitch length at all, and the property will apply - # a default value. - # - # Thsi is so tricky, and and intricate that's a major reason that we deprecated the - # 0.5 units rule. - - # Warn them the first time. - global warned_about_legacy_running_stitch - if not warned_about_legacy_running_stitch: - warned_about_legacy_running_stitch = True - print >> sys.stderr, _("Legacy running stitch setting detected!\n\nIt looks like you're using a stroke " + \ - "smaller than 0.5 units to indicate a running stitch, which is deprecated. Instead, please set " + \ - "your stroke to be dashed to indicate running stitch. Any kind of dash will work.") - - # still allow the deprecated setting to work in order to support old files - return True - else: - return False - - def stroke_points(self, emb_point_list, zigzag_spacing, stroke_width): - # TODO: use inkstitch.stitches.running_stitch - - patch = Patch(color=self.color) - p0 = emb_point_list[0] - rho = 0.0 - side = 1 - last_segment_direction = None - - 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) - - for segi in order: - p1 = emb_point_list[segi] - - # how far we have to go along segment - seg_len = (p1 - p0).length() - if (seg_len == 0): - continue - - # vector pointing along segment - along = (p1 - p0).unit() - - # vector pointing to edge of stroke width - perp = along.rotate_left() * (stroke_width * 0.5) - - 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) - - # 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 - - p0 = p1 - last_segment_direction = along - rho -= seg_len - - if (p0 - patch.stitches[-1]).length() > 0.1: - patch.add_stitch(p0) - - return patch - - def to_patches(self, last_patch): - patches = [] - - for path in self.paths: - path = [Point(x, y) for x, y in path] - 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) - else: - patch = self.stroke_points(path, self.zigzag_spacing / 2.0, stroke_width=self.stroke_width) - - patches.append(patch) - - return patches diff --git a/inkstitch/extensions/__init__.py b/inkstitch/extensions/__init__.py deleted file mode 100644 index ebdd2fc9..00000000 --- a/inkstitch/extensions/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from embroider import Embroider -from palettes import Palettes -from params import Params -from print_pdf import Print -from simulate import Simulate -from input import Input diff --git a/inkstitch/extensions/base.py b/inkstitch/extensions/base.py deleted file mode 100644 index 91e050eb..00000000 --- a/inkstitch/extensions/base.py +++ /dev/null @@ -1,222 +0,0 @@ -import inkex -import re -import json -from copy import deepcopy -from collections import MutableMapping -from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement -from .. import SVG_POLYLINE_TAG, SVG_GROUP_TAG, SVG_DEFS_TAG, INKSCAPE_GROUPMODE, EMBROIDERABLE_TAGS, PIXELS_PER_MM -from ..utils import cache - - -SVG_METADATA_TAG = inkex.addNS("metadata", "svg") - - -def strip_namespace(tag): - """Remove xml namespace from a tag name. - - >>> {http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}namedview - <<< namedview - """ - - match = re.match('^\{[^}]+\}(.+)$', tag) - - if match: - return match.group(1) - else: - return tag - - -class InkStitchMetadata(MutableMapping): - """Helper class to get and set inkstitch-specific metadata attributes. - - Operates on a document and acts like a dict. Setting an item adds or - updates a metadata element in the document. Getting an item retrieves - a metadata element's text contents or None if an element by that name - doesn't exist. - """ - - def __init__(self, document): - self.document = document - self.metadata = self._get_or_create_metadata() - - def _get_or_create_metadata(self): - metadata = self.document.find(SVG_METADATA_TAG) - - if metadata is None: - metadata = inkex.etree.SubElement(self.document.getroot(), SVG_METADATA_TAG) - - # move it so that it goes right after the first element, sodipodi:namedview - self.document.getroot().remove(metadata) - self.document.getroot().insert(1, metadata) - - return metadata - - # Because this class inherints from MutableMapping, all we have to do is - # implement these five methods and we get a full dict-like interface. - - def __setitem__(self, name, value): - item = self._find_item(name) - - if value: - item.text = json.dumps(value) - else: - item.getparent().remove(item) - - def _find_item(self, name): - tag = inkex.addNS(name, "inkstitch") - item = self.metadata.find(tag) - if item is None: - item = inkex.etree.SubElement(self.metadata, tag) - - return item - - def __getitem__(self, name): - item = self._find_item(name) - - try: - return json.loads(item.text) - except (ValueError, TypeError): - return None - - def __delitem__(self, name): - item = self._find_item(name) - - if item: - self.metadata.remove(item) - - def __iter__(self): - for child in self.metadata: - if child.prefix == "inkstitch": - yield strip_namespace(child.tag) - - def __len__(self): - i = 0 - for i, item in enumerate(self): - pass - - return i + 1 - - -class InkstitchExtension(inkex.Effect): - """Base class for Inkstitch extensions. Not intended for direct use.""" - - def hide_all_layers(self): - for g in self.document.getroot().findall(SVG_GROUP_TAG): - if g.get(INKSCAPE_GROUPMODE) == "layer": - g.set("style", "display:none") - - def no_elements_error(self): - if self.selected: - 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.")) - - def descendants(self, 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(self.descendants(child)) - - if node.tag in EMBROIDERABLE_TAGS: - nodes.append(node) - - return nodes - - def get_nodes(self): - """Get all XML nodes, or just those selected - - effect is an instance of a subclass of inkex.Effect. - """ - - if self.selected: - nodes = [] - for node in self.document.getroot().iter(): - if node.get("id") in self.selected: - nodes.extend(self.descendants(node)) - else: - nodes = self.descendants(self.document.getroot()) - - return nodes - - def detect_classes(self, node): - if node.tag == SVG_POLYLINE_TAG: - return [Polyline] - else: - element = EmbroideryElement(node) - - if element.get_boolean_param("satin_column"): - return [SatinColumn] - else: - classes = [] - - if element.get_style("fill"): - if element.get_boolean_param("auto_fill", True): - classes.append(AutoFill) - else: - classes.append(Fill) - - if element.get_style("stroke"): - classes.append(Stroke) - - if element.get_boolean_param("stroke_first", False): - classes.reverse() - - return classes - - - def get_elements(self): - self.elements = [] - for node in self.get_nodes(): - classes = self.detect_classes(node) - self.elements.extend(cls(node) for cls in classes) - - if self.elements: - return True - else: - self.no_elements_error() - return False - - def elements_to_patches(self, elements): - patches = [] - for element in elements: - if patches: - last_patch = patches[-1] - else: - last_patch = None - - patches.extend(element.embroider(last_patch)) - - return patches - - def get_inkstitch_metadata(self): - return InkStitchMetadata(self.document) - - def parse(self): - """Override inkex.Effect to add Ink/Stitch xml namespace""" - - # SVG parsers don't actually look for anything at this URL. They just - # care that it's unique. That defines a "namespace" of element and - # attribute names to disambiguate conflicts with element and - # attribute names other XML namespaces. - # - # Updating inkex.NSS here allows us to pass 'inkstitch' into - # inkex.addNS(). - inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' - - # call the superclass's method first - inkex.Effect.parse(self) - - # This is the only way I could find to add a namespace to an existing - # element tree at the top without getting ugly prefixes like "ns0". - inkex.etree.cleanup_namespaces(self.document, - top_nsmap=inkex.NSS, - keep_ns_prefixes=inkex.NSS.keys()) - self.original_document = deepcopy(self.document) diff --git a/inkstitch/extensions/embroider.py b/inkstitch/extensions/embroider.py deleted file mode 100644 index 564e96ca..00000000 --- a/inkstitch/extensions/embroider.py +++ /dev/null @@ -1,86 +0,0 @@ -import sys -import traceback -import os - -import inkex -from .. import _, PIXELS_PER_MM, write_embroidery_file -from .base import InkstitchExtension -from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan - - -class Embroider(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("--hide_layers", - action="store", type="choice", - choices=["true", "false"], - dest="hide_layers", default="true", - help="Hide all other layers when the embroidery layer is generated") - self.OptionParser.add_option("-O", "--output_format", - action="store", type="string", - dest="output_format", default="csv", - help="Output file extenstion (default: csv)") - self.OptionParser.add_option("-P", "--path", - action="store", type="string", - dest="path", default=".", - help="Directory in which to store output file") - self.OptionParser.add_option("-F", "--output-file", - action="store", type="string", - dest="output_file", - help="Output filename.") - self.OptionParser.add_option("-b", "--max-backups", - action="store", type="int", - dest="max_backups", default=5, - help="Max number of backups of output files to keep.") - self.OptionParser.usage += _("\n\nSeeing a 'no such option' message? Please restart Inkscape to fix.") - - def get_output_path(self): - 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) - output_path = os.path.join(self.options.path, csv_filename) - - def add_suffix(path, suffix): - if suffix > 0: - path = "%s.%s" % (path, suffix) - - return path - - def move_if_exists(path, suffix=0): - source = add_suffix(path, suffix) - - if suffix >= self.options.max_backups: - return - - dest = add_suffix(path, suffix + 1) - - if os.path.exists(source): - move_if_exists(path, suffix + 1) - - if os.path.exists(dest): - os.remove(dest) - - os.rename(source, dest) - - move_if_exists(output_path) - - return output_path - - def effect(self): - if not self.get_elements(): - return - - if self.options.hide_layers: - self.hide_all_layers() - - patches = self.elements_to_patches(self.elements) - stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) - write_embroidery_file(self.get_output_path(), stitch_plan, self.document.getroot()) - render_stitch_plan(self.document.getroot(), stitch_plan) diff --git a/inkstitch/extensions/input.py b/inkstitch/extensions/input.py deleted file mode 100644 index bd3db0ed..00000000 --- a/inkstitch/extensions/input.py +++ /dev/null @@ -1,66 +0,0 @@ -import os -from os.path import realpath, dirname, join as path_join -import sys - -# 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 * -from inkex import etree -import inkex -from .. import PIXELS_PER_MM, INKSCAPE_LABEL, _ -from ..stitch_plan import StitchPlan -from ..svg import render_stitch_plan - - -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) - - 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, - stop=stitch.flags & STOP, - trim=stitch.flags & TRIM) - - extents = stitch_plan.extents - svg = etree.Element("svg", nsmap=inkex.NSS, attrib= - { - "width": str(extents[0] * 2), - "height": str(extents[1] * 2), - "viewBox": "0 0 %s %s" % (extents[0] * 2, extents[1] * 2), - }) - render_stitch_plan(svg, stitch_plan) - - # rename the Stitch Plan layer so that it doesn't get overwritten by Embroider - layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - layer.set(INKSCAPE_LABEL, os.path.basename(embroidery_file)) - layer.attrib.pop('id') - - # Shift the design so that its origin is at the center of the canvas - # Note: this is NOT the same as centering the design in the canvas! - layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1])) - - print etree.tostring(svg) diff --git a/inkstitch/extensions/palettes.py b/inkstitch/extensions/palettes.py deleted file mode 100644 index 269dc6dc..00000000 --- a/inkstitch/extensions/palettes.py +++ /dev/null @@ -1,111 +0,0 @@ -import sys -import traceback -import os -from os.path import realpath, dirname -from glob import glob -from threading import Thread -import socket -import errno -import time -import logging -import wx -import inkex -from ..utils import guess_inkscape_config_path - - -class InstallPalettesFrame(wx.Frame): - def __init__(self, *args, **kwargs): - wx.Frame.__init__(self, *args, **kwargs) - - default_path = os.path.join(guess_inkscape_config_path(), "palettes") - - 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) - - 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) - - buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) - install_button = wx.Button(panel, wx.ID_ANY, _("Install")) - install_button.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK)) - 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) - - outer_sizer = wx.BoxSizer(wx.HORIZONTAL) - outer_sizer.Add(sizer, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) - - panel.SetSizer(outer_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) - - def cancel_button_clicked(self, event): - self.Destroy() - - def chooser_button_clicked(self, event): - dialog = wx.DirDialog(self, _("Choose Inkscape palettes directory")) - if dialog.ShowModal() != wx.ID_CANCEL: - self.path_input.SetValue(dialog.GetPath()) - - def install_button_clicked(self, event): - try: - self.install_palettes() - except Exception, e: - wx.MessageDialog(self, - _('Thread palette 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.'), - _('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') - - if (sys.platform == "win32"): - # If we try to just use shutil.copy it says the operation requires elevation. - def copy_files(self, files, dest): - import winutils - - winutils.copy(files, dest) - else: - def copy_files(self, files, dest): - import shutil - - if not os.path.exists(dest): - os.makedirs(dest) - - for palette_file in files: - shutil.copy(palette_file, dest) - -class Palettes(inkex.Effect): - def effect(self): - app = wx.App() - installer_frame = InstallPalettesFrame(None, title=_("Ink/Stitch Thread Palette Installer"), size=(450, 200)) - installer_frame.Show() - app.MainLoop() diff --git a/inkstitch/extensions/params.py b/inkstitch/extensions/params.py deleted file mode 100644 index 881dab49..00000000 --- a/inkstitch/extensions/params.py +++ /dev/null @@ -1,756 +0,0 @@ -# -*- coding: UTF-8 -*- - -import os -import sys -import json -import traceback -import time -from threading import Thread, Event -from copy import copy -import wx -from wx.lib.scrolledpanel import ScrolledPanel -from collections import defaultdict -from functools import partial -from itertools import groupby - -from .. import _ -from .base import InkstitchExtension -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 - - -def presets_path(): - try: - import appdirs - config_path = appdirs.user_config_dir('inkstitch') - except ImportError: - config_path = os.path.expanduser('~/.inkstitch') - - if not os.path.exists(config_path): - os.makedirs(config_path) - return os.path.join(config_path, 'presets.json') - - -def load_presets(): - try: - with open(presets_path(), 'r') as presets: - presets = json.load(presets) - return presets - except: - return {} - - -def save_presets(presets): - with open(presets_path(), 'w') as presets_file: - json.dump(presets, presets_file) - - -def load_preset(name): - return load_presets().get(name) - - -def save_preset(name, data): - presets = load_presets() - presets[name] = data - save_presets(presets) - - -def delete_preset(name): - presets = load_presets() - presets.pop(name, None) - save_presets(presets) - - -def confirm_dialog(parent, question, caption = 'ink/stitch'): - dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION) - result = dlg.ShowModal() == wx.ID_YES - dlg.Destroy() - return result - - -def info_dialog(parent, message, caption = 'ink/stitch'): - dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) - dlg.ShowModal() - dlg.Destroy() - - -class ParamsTab(ScrolledPanel): - def __init__(self, *args, **kwargs): - self.params = kwargs.pop('params', []) - self.name = kwargs.pop('name', None) - self.nodes = kwargs.pop('nodes') - kwargs["style"] = wx.TAB_TRAVERSAL - ScrolledPanel.__init__(self, *args, **kwargs) - self.SetupScrolling() - - self.changed_inputs = set() - self.dependent_tabs = [] - self.parent_tab = None - self.param_inputs = {} - self.paired_tab = None - self.disable_notify_pair = False - - toggles = [param for param in self.params if param.type == 'toggle'] - - if toggles: - self.toggle = toggles[0] - self.params.remove(self.toggle) - self.toggle_checkbox = wx.CheckBox(self, label=self.toggle.description) - - value = any(self.toggle.values) - if self.toggle.inverse: - value = not value - self.toggle_checkbox.SetValue(value) - - self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.update_toggle_state) - self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.changed) - - self.param_inputs[self.toggle.name] = self.toggle_checkbox - else: - self.toggle = None - - self.settings_grid = wx.FlexGridSizer(rows=0, cols=3, hgap=10, vgap=10) - self.settings_grid.AddGrowableCol(0, 1) - self.settings_grid.SetFlexibleDirection(wx.HORIZONTAL) - - self.__set_properties() - self.__do_layout() - - if self.toggle: - self.update_toggle_state() - # end wxGlade - - def pair(self, tab): - # print self.name, "paired with", tab.name - self.paired_tab = tab - self.update_description() - - def add_dependent_tab(self, tab): - self.dependent_tabs.append(tab) - self.update_description() - - def set_parent_tab(self, tab): - self.parent_tab = tab - - def is_dependent_tab(self): - return self.parent_tab is not None - - def enabled(self): - if self.toggle_checkbox: - return self.toggle_checkbox.IsChecked() - else: - return True - - def update_toggle_state(self, event=None, notify_pair=True): - enable = self.enabled() - # print self.name, "update_toggle_state", enable - for child in self.settings_grid.GetChildren(): - widget = child.GetWindow() - if widget: - child.GetWindow().Enable(enable) - - if notify_pair and self.paired_tab: - self.paired_tab.pair_changed(enable) - - for tab in self.dependent_tabs: - tab.dependent_enable(enable) - - if event: - event.Skip() - - def pair_changed(self, value): - # print self.name, "pair_changed", value - new_value = not value - - if self.enabled() != new_value: - self.set_toggle_state(not value) - self.update_toggle_state(notify_pair=False) - - if self.on_change_hook: - self.on_change_hook(self) - - def dependent_enable(self, enable): - if enable: - self.toggle_checkbox.Enable() - else: - self.set_toggle_state(False) - self.toggle_checkbox.Disable() - self.update_toggle_state() - - if self.on_change_hook: - self.on_change_hook(self) - - def set_toggle_state(self, value): - if self.toggle_checkbox: - self.toggle_checkbox.SetValue(value) - self.changed_inputs.add(self.toggle_checkbox) - - def get_values(self): - values = {} - - if self.toggle: - checked = self.enabled() - if self.toggle_checkbox in self.changed_inputs and not self.toggle.inverse: - values[self.toggle.name] = checked - - if not checked: - # Ignore params on this tab if the toggle is unchecked, - # because they're grayed out anyway. - return values - - for name, input in self.param_inputs.iteritems(): - if input in self.changed_inputs and input != self.toggle_checkbox: - values[name] = input.GetValue() - - return values - - def apply(self): - values = self.get_values() - for node in self.nodes: - # print >> sys.stderr, "apply: ", self.name, node.id, values - for name, value in values.iteritems(): - node.set_param(name, value) - - def on_change(self, callable): - self.on_change_hook = callable - - def changed(self, event): - self.changed_inputs.add(event.GetEventObject()) - event.Skip() - - if self.on_change_hook: - self.on_change_hook(self) - - def load_preset(self, preset): - preset_data = preset.get(self.name, {}) - - for name, value in preset_data.iteritems(): - if name in self.param_inputs: - self.param_inputs[name].SetValue(value) - self.changed_inputs.add(self.param_inputs[name]) - - self.update_toggle_state() - - def save_preset(self, storage): - preset = storage[self.name] = {} - for name, input in self.param_inputs.iteritems(): - preset[name] = input.GetValue() - - def update_description(self): - if len(self.nodes) == 1: - description = _("These settings will be applied to 1 object.") - else: - description = _("These settings will be applied to %d objects.") % len(self.nodes) - - if any(len(param.values) > 1 for param in self.params): - description += "\n • " + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.") - - if self.dependent_tabs: - if len(self.dependent_tabs) == 1: - description += "\n • " + _("Disabling this tab will disable the following %d tabs.") % len(self.dependent_tabs) - else: - description += "\n • " + _("Disabling this tab will disable the following tab.") - - if self.paired_tab: - description += "\n • " + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name - - self.description_text = description - - def resized(self, event): - if not hasattr(self, 'rewrap_timer'): - self.rewrap_timer = wx.Timer() - self.rewrap_timer.Bind(wx.EVT_TIMER, self.rewrap) - - # If we try to rewrap every time we get EVT_SIZE then a resize is - # extremely slow. - self.rewrap_timer.Start(50, oneShot=True) - event.Skip() - - def rewrap(self, event=None): - self.description.SetLabel(self.description_text) - self.description.Wrap(self.GetSize().x - 20) - self.description_container.Layout() - if event: - event.Skip() - - def __set_properties(self): - # begin wxGlade: SatinPane.__set_properties - # end wxGlade - pass - - def __do_layout(self): - # just to add space around the settings - box = wx.BoxSizer(wx.VERTICAL) - - summary_box = wx.StaticBox(self, wx.ID_ANY, label=_("Inkscape objects")) - sizer = wx.StaticBoxSizer(summary_box, wx.HORIZONTAL) -# sizer = wx.BoxSizer(wx.HORIZONTAL) - self.description = wx.StaticText(self) - self.update_description() - self.description.SetLabel(self.description_text) - self.description_container = box - self.Bind(wx.EVT_SIZE, self.resized) - sizer.Add(self.description, proportion=0, flag=wx.EXPAND|wx.ALL, border=5) - box.Add(sizer, proportion=0, flag=wx.ALL, border=5) - - if self.toggle: - box.Add(self.toggle_checkbox, proportion=0, flag=wx.BOTTOM, border=10) - - for param in self.params: - description = wx.StaticText(self, label=param.description) - description.SetToolTip(param.tooltip) - - self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND|wx.RIGHT, border=40) - - if param.type == 'boolean': - - if len(param.values) > 1: - input = wx.CheckBox(self, style=wx.CHK_3STATE) - input.Set3StateValue(wx.CHK_UNDETERMINED) - else: - input = wx.CheckBox(self) - if param.values: - input.SetValue(param.values[0]) - - input.Bind(wx.EVT_CHECKBOX, self.changed) - elif len(param.values) > 1: - input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(param.values), style=wx.CB_DROPDOWN) - input.Bind(wx.EVT_COMBOBOX, self.changed) - input.Bind(wx.EVT_TEXT, self.changed) - else: - value = param.values[0] if param.values else "" - input = wx.TextCtrl(self, wx.ID_ANY, value=str(value)) - input.Bind(wx.EVT_TEXT, self.changed) - - self.param_inputs[param.name] = input - - self.settings_grid.Add(input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) - self.settings_grid.Add(wx.StaticText(self, label=param.unit or ""), proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) - - box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10) - self.SetSizer(box) - - self.Layout() - -# end of class SatinPane - -class SettingsFrame(wx.Frame): - def __init__(self, *args, **kwargs): - # begin wxGlade: MyFrame.__init__ - self.tabs_factory = kwargs.pop('tabs_factory', []) - self.cancel_hook = kwargs.pop('on_cancel', None) - wx.Frame.__init__(self, None, wx.ID_ANY, - _("Embroidery Params") - ) - self.notebook = wx.Notebook(self, wx.ID_ANY) - self.tabs = self.tabs_factory(self.notebook) - - for tab in self.tabs: - tab.on_change(self.update_simulator) - - self.simulate_window = None - self.simulate_thread = None - self.simulate_refresh_needed = Event() - - wx.CallLater(1000, self.update_simulator) - - self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) - - self.preset_chooser = wx.ComboBox(self, wx.ID_ANY) - self.update_preset_list() - - self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load")) - self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_preset) - - self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add")) - self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset) - - self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite")) - self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset) - - self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete")) - self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset) - - self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) - self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) - self.Bind(wx.EVT_CLOSE, self.cancel) - - self.use_last_button = wx.Button(self, wx.ID_ANY, _("Use Last Settings")) - self.use_last_button.Bind(wx.EVT_BUTTON, self.use_last) - - self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) - self.apply_button.Bind(wx.EVT_BUTTON, self.apply) - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def update_simulator(self, tab=None): - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.clear() - - 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 - self.simulate_thread.start() - - self.simulate_refresh_needed.set() - - def simulate_worker(self): - while True: - self.simulate_refresh_needed.wait() - self.simulate_refresh_needed.clear() - self.update_patches() - - def update_patches(self): - patches = self.generate_patches() - - if patches and not self.simulate_refresh_needed.is_set(): - wx.CallAfter(self.refresh_simulator, patches) - - def refresh_simulator(self, patches): - stitch_plan = patches_to_stitch_plan(patches) - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.load(stitch_plan=stitch_plan) - else: - my_rect = self.GetRect() - simulator_pos = my_rect.GetTopRight() - simulator_pos.x += 5 - - screen_rect = wx.Display(0).ClientArea - max_width = screen_rect.GetWidth() - my_rect.GetWidth() - max_height = screen_rect.GetHeight() - - try: - self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"), - simulator_pos, - size=(300, 300), - stitch_plan=stitch_plan, - on_close=self.simulate_window_closed, - target_duration=5, - max_width=max_width, - max_height=max_height) - except: - error = traceback.format_exc() - - try: - # a window may have been created, so we need to destroy it - # or the app will never exit - wx.Window.FindWindowByName("Preview").Destroy() - except: - pass - - info_dialog(self, error, _("Internal Error")) - - self.simulate_window.Show() - wx.CallLater(10, self.Raise) - - wx.CallAfter(self.simulate_window.go) - - def simulate_window_closed(self): - self.simulate_window = None - - def generate_patches(self): - patches = [] - nodes = [] - - for tab in self.tabs: - tab.apply() - - if tab.enabled() and not tab.is_dependent_tab(): - nodes.extend(tab.nodes) - - # sort nodes into the proper stacking order - nodes.sort(key=lambda node: node.order) - - try: - for node in nodes: - if self.simulate_refresh_needed.is_set(): - # cancel; params were updated and we need to start over - return [] - - # Making a copy of the embroidery element is an easy - # way to drop the cache in the @cache decorators used - # for many params in embroider.py. - - patches.extend(copy(node).embroider(None)) - except SystemExit: - raise - except: - # Ignore errors. This can be things like incorrect paths for - # satins or division by zero caused by incorrect param values. - pass - - return patches - - def update_preset_list(self): - preset_names = load_presets().keys() - preset_names = [preset for preset in preset_names if preset != "__LAST__"] - self.preset_chooser.SetItems(sorted(preset_names)) - - def get_preset_name(self): - preset_name = self.preset_chooser.GetValue().strip() - if preset_name: - return preset_name - else: - info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset')) - return - - def check_and_load_preset(self, preset_name): - preset = load_preset(preset_name) - if not preset: - info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset')) - - return preset - - def get_preset_data(self): - preset = {} - - current_tab = self.tabs[self.notebook.GetSelection()] - while current_tab.parent_tab: - current_tab = current_tab.parent_tab - - tabs = [current_tab] - if current_tab.paired_tab: - tabs.append(current_tab.paired_tab) - tabs.extend(current_tab.paired_tab.dependent_tabs) - tabs.extend(current_tab.dependent_tabs) - - for tab in tabs: - tab.save_preset(preset) - - return preset - - def add_preset(self, event, overwrite=False): - preset_name = self.get_preset_name() - if not preset_name: - return - - if not overwrite and load_preset(preset_name): - info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset')) - - save_preset(preset_name, self.get_preset_data()) - self.update_preset_list() - - event.Skip() - - def overwrite_preset(self, event): - self.add_preset(event, overwrite=True) - - - def _load_preset(self, preset_name): - preset = self.check_and_load_preset(preset_name) - if not preset: - return - - for tab in self.tabs: - tab.load_preset(preset) - - - def load_preset(self, event): - preset_name = self.get_preset_name() - if not preset_name: - return - - self._load_preset(preset_name) - - event.Skip() - - - def delete_preset(self, event): - preset_name = self.get_preset_name() - if not preset_name: - return - - preset = self.check_and_load_preset(preset_name) - if not preset: - return - - delete_preset(preset_name) - self.update_preset_list() - self.preset_chooser.SetValue("") - - event.Skip() - - def _apply(self): - for tab in self.tabs: - tab.apply() - - def apply(self, event): - self._apply() - save_preset("__LAST__", self.get_preset_data()) - self.close() - - def use_last(self, event): - self._load_preset("__LAST__") - self.apply(event) - - def close(self): - if self.simulate_window: - self.simulate_window.stop() - self.simulate_window.Close() - - self.Destroy() - - def cancel(self, event): - if self.cancel_hook: - self.cancel_hook() - - self.close() - - def __set_properties(self): - # begin wxGlade: MyFrame.__set_properties - self.notebook.SetMinSize((800, 600)) - self.preset_chooser.SetSelection(-1) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: MyFrame.__do_layout - sizer_1 = wx.BoxSizer(wx.VERTICAL) - #self.sizer_3_staticbox.Lower() - sizer_2 = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL) - sizer_3 = wx.BoxSizer(wx.HORIZONTAL) - for tab in self.tabs: - self.notebook.AddPage(tab, tab.name) - sizer_1.Add(self.notebook, 1, wx.EXPAND|wx.LEFT|wx.TOP|wx.RIGHT, 10) - sizer_2.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) - sizer_2.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) - sizer_2.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) - sizer_2.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) - sizer_2.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) - sizer_1.Add(sizer_2, 0, flag=wx.EXPAND|wx.ALL, border=10) - sizer_3.Add(self.cancel_button, 0, wx.ALIGN_RIGHT|wx.RIGHT, 5) - sizer_3.Add(self.use_last_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) - sizer_3.Add(self.apply_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) - sizer_1.Add(sizer_3, 0, wx.ALIGN_RIGHT, 0) - self.SetSizer(sizer_1) - sizer_1.Fit(self) - self.Layout() - # end wxGlade - -class Params(InkstitchExtension): - def __init__(self, *args, **kwargs): - self.cancelled = False - InkstitchExtension.__init__(self, *args, **kwargs) - - def embroidery_classes(self, node): - element = EmbroideryElement(node) - classes = [] - - if element.get_style("fill"): - classes.append(AutoFill) - classes.append(Fill) - - if element.get_style("stroke"): - classes.append(Stroke) - - if element.get_style("stroke-dasharray") is None: - classes.append(SatinColumn) - - return classes - - def get_nodes_by_class(self): - nodes = self.get_nodes() - nodes_by_class = defaultdict(list) - - for z, node in enumerate(nodes): - for cls in self.embroidery_classes(node): - element = cls(node) - element.order = z - nodes_by_class[cls].append(element) - - return sorted(nodes_by_class.items(), key=lambda (cls, nodes): cls.__name__) - - def get_values(self, param, nodes): - getter = 'get_param' - - if param.type in ('toggle', 'boolean'): - getter = 'get_boolean_param' - else: - getter = 'get_param' - - values = filter(lambda item: item is not None, - (getattr(node, getter)(param.name, str(param.default)) for node in nodes)) - - return values - - def group_params(self, params): - def by_group_and_sort_index(param): - return param.group, param.sort_index - - def by_group(param): - return param.group - - return groupby(sorted(params, key=by_group_and_sort_index), by_group) - - def create_tabs(self, parent): - tabs = [] - for cls, nodes in self.get_nodes_by_class(): - params = cls.get_params() - - for param in params: - param.values = list(set(self.get_values(param, nodes))) - - parent_tab = None - new_tabs = [] - for group, params in self.group_params(params): - tab = ParamsTab(parent, id=wx.ID_ANY, name=group or cls.element_name, params=list(params), nodes=nodes) - new_tabs.append(tab) - - if group is None: - parent_tab = tab - - for tab in new_tabs: - if tab != parent_tab: - parent_tab.add_dependent_tab(tab) - tab.set_parent_tab(parent_tab) - - tabs.extend(new_tabs) - - for tab in tabs: - if tab.toggle and tab.toggle.inverse: - for other_tab in tabs: - if other_tab != tab and other_tab.toggle.name == tab.toggle.name: - tab.pair(other_tab) - other_tab.pair(tab) - - def tab_sort_key(tab): - parent = tab.parent_tab or tab - - sort_key = ( - # For Stroke and SatinColumn, place the one that's - # enabled first. Place dependent tabs first too. - parent.toggle and parent.toggle_checkbox.IsChecked(), - - # If multiple tabs are enabled, make sure dependent - # tabs are grouped with the parent. - parent, - - # Within parent/dependents, put the parent first. - tab == parent - ) - - return sort_key - - tabs.sort(key=tab_sort_key, reverse=True) - - return tabs - - - def cancel(self): - 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) diff --git a/inkstitch/extensions/print_pdf.py b/inkstitch/extensions/print_pdf.py deleted file mode 100644 index 5d462c0f..00000000 --- a/inkstitch/extensions/print_pdf.py +++ /dev/null @@ -1,391 +0,0 @@ -import sys -import traceback -import os -from threading import Thread -import socket -import errno -import time -import logging -from copy import deepcopy -import wx -import appdirs -import json - -import inkex -from .. import _, PIXELS_PER_MM, SVG_GROUP_TAG, translation as inkstitch_translation -from .base import InkstitchExtension -from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan -from ..threads import ThreadCatalog - -from jinja2 import Environment, FileSystemLoader, select_autoescape -from datetime import date -import base64 - -from flask import Flask, request, Response, send_from_directory, jsonify -import webbrowser -import requests - - -def datetimeformat(value, format='%Y/%m/%d'): - return value.strftime(format) - - -def defaults_path(): - defaults_dir = appdirs.user_config_dir('inkstitch') - - if not os.path.exists(defaults_dir): - os.makedirs(defaults_dir) - - return os.path.join(defaults_dir, 'print_settings.json') - - -def load_defaults(): - try: - with open(defaults_path(), 'r') as defaults_file: - defaults = json.load(defaults_file) - return defaults - except: - return {} - - -def save_defaults(defaults): - with open(defaults_path(), 'w') as defaults_file: - json.dump(defaults, defaults_file) - - -def open_url(url): - # Avoid spurious output from xdg-open. Any output on stdout will crash - # inkscape. - null = open(os.devnull, 'w') - old_stdout = os.dup(sys.stdout.fileno()) - os.dup2(null.fileno(), sys.stdout.fileno()) - - if getattr(sys, 'frozen', False): - - # PyInstaller sets LD_LIBRARY_PATH. We need to temporarily clear it - # to avoid confusing xdg-open, which webbrowser will run. - - # The following code is adapted from PyInstaller's documentation - # http://pyinstaller.readthedocs.io/en/stable/runtime-information.html - - old_environ = dict(os.environ) # make a copy of the environment - lp_key = 'LD_LIBRARY_PATH' # for Linux and *BSD. - lp_orig = os.environ.get(lp_key + '_ORIG') # pyinstaller >= 20160820 has this - if lp_orig is not None: - os.environ[lp_key] = lp_orig # restore the original, unmodified value - else: - os.environ.pop(lp_key, None) # last resort: remove the env var - - webbrowser.open(url) - - # restore the old environ - os.environ.clear() - os.environ.update(old_environ) - else: - webbrowser.open(url) - - # restore file descriptors - os.dup2(old_stdout, sys.stdout.fileno()) - os.close(old_stdout) - - -class PrintPreviewServer(Thread): - def __init__(self, *args, **kwargs): - self.html = kwargs.pop('html') - self.metadata = kwargs.pop('metadata') - self.stitch_plan = kwargs.pop('stitch_plan') - Thread.__init__(self, *args, **kwargs) - self.daemon = True - self.last_request_time = None - self.shutting_down = False - - self.__setup_app() - - def __set_resources_path(self): - if getattr(sys, 'frozen', False): - self.resources_path = os.path.join(sys._MEIPASS, 'print', 'resources') - else: - self.resources_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', 'print', 'resources')) - - def __setup_app(self): - self.__set_resources_path() - self.app = Flask(__name__) - - @self.app.before_request - def request_started(): - self.last_request_time = time.time() - - @self.app.before_first_request - def start_watcher(): - self.watcher_thread = Thread(target=self.watch) - self.watcher_thread.daemon = True - self.watcher_thread.start() - - @self.app.route('/') - def index(): - return self.html - - @self.app.route('/shutdown', methods=['POST']) - def shutdown(): - self.shutting_down = True - request.environ.get('werkzeug.server.shutdown')() - return _('Closing...') + '

' + _('It is safe to close this window now.') - - @self.app.route('/resources/', methods=['GET']) - def resources(resource): - return send_from_directory(self.resources_path, resource, cache_timeout=1) - - @self.app.route('/ping') - def ping(): - # Javascript is letting us know it's still there. This resets self.last_request_time. - return "pong" - - @self.app.route('/printing/start') - def printing_start(): - # temporarily turn off the watcher while the print dialog is up, - # because javascript will be frozen - self.last_request_time = None - return "OK" - - @self.app.route('/printing/end') - def printing_end(): - # nothing to do here -- request_started() will restart the watcher - return "OK" - - @self.app.route('/settings/', methods=['POST']) - def set_field(field_name): - self.metadata[field_name] = request.json['value'] - return "OK" - - @self.app.route('/settings/', methods=['GET']) - def get_field(field_name): - return jsonify(self.metadata[field_name]) - - @self.app.route('/settings', methods=['GET']) - def get_settings(): - settings = {} - settings.update(load_defaults()) - settings.update(self.metadata) - return jsonify(settings) - - @self.app.route('/defaults', methods=['POST']) - def set_defaults(): - save_defaults(request.json['value']) - return "OK" - - @self.app.route('/palette', methods=['POST']) - def set_palette(): - name = request.json['name'] - catalog = ThreadCatalog() - palette = catalog.get_palette_by_name(name) - catalog.apply_palette(self.stitch_plan, palette) - - # clear any saved color or thread names - for field in self.metadata: - if field.startswith('color-') or field.startswith('thread-'): - del self.metadata[field] - - self.metadata['thread-palette'] = name - - return "OK" - - @self.app.route('/threads', methods=['GET']) - def get_threads(): - threads = [] - for color_block in self.stitch_plan: - threads.append({ - 'hex': color_block.color.hex_digits, - 'name': color_block.color.name, - 'manufacturer': color_block.color.manufacturer, - 'number': color_block.color.number, - }) - - return jsonify(threads) - - def stop(self): - # for whatever reason, shutting down only seems possible in - # the context of a flask request, so we'll just make one - requests.post("http://%s:%s/shutdown" % (self.host, self.port)) - - def watch(self): - try: - while True: - time.sleep(1) - if self.shutting_down: - break - - if self.last_request_time is not None and \ - (time.time() - self.last_request_time) > 3: - self.stop() - break - except: - # seems like sometimes this thread blows up during shutdown - pass - - def disable_logging(self): - logging.getLogger('werkzeug').setLevel(logging.ERROR) - - def run(self): - self.disable_logging() - - self.host = "127.0.0.1" - self.port = 5000 - - while True: - try: - self.app.run(self.host, self.port, threaded=True) - except socket.error, e: - if e.errno == errno.EADDRINUSE: - self.port += 1 - continue - else: - raise - else: - break - - -class PrintInfoFrame(wx.Frame): - def __init__(self, *args, **kwargs): - self.print_server = kwargs.pop("print_server") - wx.Frame.__init__(self, *args, **kwargs) - - panel = wx.Panel(self) - sizer = wx.BoxSizer(wx.VERTICAL) - - text = wx.StaticText(panel, label=_("A print preview has been opened in your web browser. This window will stay open in order to communicate with the JavaScript code running in your browser.\n\nThis window will close after you close the print preview in your browser, or you can close it manually if necessary.")) - font = wx.Font(14, wx.DEFAULT, wx.NORMAL, wx.NORMAL) - text.SetFont(font) - sizer.Add(text, proportion=1, flag=wx.ALL|wx.EXPAND, border=20) - - stop_button = wx.Button(panel, id=wx.ID_CLOSE) - stop_button.Bind(wx.EVT_BUTTON, self.close_button_clicked) - sizer.Add(stop_button, proportion=0, flag=wx.ALIGN_CENTER|wx.ALL, border=10) - - panel.SetSizer(sizer) - panel.Layout() - - self.timer = wx.PyTimer(self.__watcher) - self.timer.Start(250) - - def close_button_clicked(self, event): - self.print_server.stop() - - def __watcher(self): - if not self.print_server.is_alive(): - self.timer.Stop() - self.timer = None - self.Destroy() - - -class Print(InkstitchExtension): - def build_environment(self): - if getattr( sys, 'frozen', False ) : - template_dir = os.path.join(sys._MEIPASS, "print", "templates") - else: - template_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..", "print", "templates")) - - env = Environment( - loader = FileSystemLoader(template_dir), - autoescape=select_autoescape(['html', 'xml']), - extensions=['jinja2.ext.i18n'] - ) - - env.filters['datetimeformat'] = datetimeformat - env.install_gettext_translations(inkstitch_translation) - - return env - - def strip_namespaces(self): - # namespace prefixes seem to trip up HTML, so get rid of them - for element in self.document.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 = {} - - 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() - - # 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']") - stitch_plan_layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - - # First, delete all of the other layers. We don't need them and they'll - # just bulk up the SVG. - for layer in layers: - if layer is not stitch_plan_layer: - svg.remove(layer) - - overview_svg = inkex.etree.tostring(self.document) - - color_block_groups = stitch_plan_layer.getchildren() - - for i, group in enumerate(color_block_groups): - # clear the stitch plan layer - del stitch_plan_layer[:] - - # add in just this group - stitch_plan_layer.append(group) - - # save an SVG preview - stitch_plan.color_blocks[i].svg_preview = inkex.etree.tostring(self.document) - - env = self.build_environment() - template = env.get_template('index.html') - - html = template.render( - view = {'client_overview': False, 'client_detailedview': False, 'operator_overview': True, 'operator_detailedview': True}, - logo = {'src' : '', 'title' : 'LOGO'}, - date = date.today(), - client = "", - job = { - 'title': '', - 'num_colors': stitch_plan.num_colors, - 'num_color_blocks': len(stitch_plan), - 'num_stops': stitch_plan.num_stops, - 'num_trims': stitch_plan.num_trims, - 'dimensions': stitch_plan.dimensions_mm, - 'num_stitches': stitch_plan.num_stitches, - 'estimated_time': '', # TODO - 'estimated_thread': '', # TODO - }, - svg_overview = overview_svg, - color_blocks = stitch_plan.color_blocks, - palettes = ThreadCatalog().palette_names(), - selected_palette = palette.name, - ) - - # We've totally mucked with the SVG. Restore it so that we can save - # metadata into it. - self.document = deepcopy(self.original_document) - - print_server = PrintPreviewServer(html=html, metadata=self.get_inkstitch_metadata(), stitch_plan=stitch_plan) - print_server.start() - - time.sleep(1) - open_url("http://%s:%s/" % (print_server.host, print_server.port)) - - app = wx.App() - info_frame = PrintInfoFrame(None, title=_("Ink/Stitch Print"), size=(450, 350), print_server=print_server) - info_frame.Show() - app.MainLoop() diff --git a/inkstitch/extensions/simulate.py b/inkstitch/extensions/simulate.py deleted file mode 100644 index 75bc62c7..00000000 --- a/inkstitch/extensions/simulate.py +++ /dev/null @@ -1,27 +0,0 @@ -import wx - -from .base import InkstitchExtension -from ..simulator import EmbroiderySimulator -from ..stitch_plan import patches_to_stitch_plan - - -class Simulate(InkstitchExtension): - def __init__(self): - InkstitchExtension.__init__(self) - self.OptionParser.add_option("-P", "--path", - action="store", type="string", - dest="path", default=".", - help="Directory in which to store output file") - - def effect(self): - if not self.get_elements(): - return - - patches = self.elements_to_patches(self.elements) - stitch_plan = patches_to_stitch_plan(patches) - app = wx.App() - frame = EmbroiderySimulator(None, -1, _("Embroidery Simulation"), wx.DefaultPosition, size=(1000, 1000), stitch_plan=stitch_plan) - app.SetTopWindow(frame) - frame.Show() - wx.CallAfter(frame.go) - app.MainLoop() diff --git a/inkstitch/simulator.py b/inkstitch/simulator.py deleted file mode 100644 index cc9442ea..00000000 --- a/inkstitch/simulator.py +++ /dev/null @@ -1,252 +0,0 @@ -import sys -import numpy -import wx -import colorsys -from itertools import izip - -from . import PIXELS_PER_MM -from .svg import color_block_to_point_lists - - -class EmbroiderySimulator(wx.Frame): - def __init__(self, *args, **kwargs): - stitch_plan = kwargs.pop('stitch_plan', None) - self.on_close_hook = kwargs.pop('on_close', None) - self.frame_period = kwargs.pop('frame_period', 80) - self.stitches_per_frame = kwargs.pop('stitches_per_frame', 1) - self.target_duration = kwargs.pop('target_duration', None) - - self.margin = 10 - - screen_rect = wx.Display(0).ClientArea - self.max_width = kwargs.pop('max_width', screen_rect.GetWidth()) - self.max_height = kwargs.pop('max_height', screen_rect.GetHeight()) - self.scale = 1 - - wx.Frame.__init__(self, *args, **kwargs) - - self.panel = wx.Panel(self, wx.ID_ANY) - self.panel.SetFocus() - - self.load(stitch_plan) - - if self.target_duration: - self.adjust_speed(self.target_duration) - - self.buffer = wx.Bitmap(self.width * self.scale + self.margin * 2, self.height * self.scale + self.margin * 2) - self.dc = wx.MemoryDC() - self.dc.SelectObject(self.buffer) - self.canvas = wx.GraphicsContext.Create(self.dc) - - self.clear() - - self.Bind(wx.EVT_SIZE, self.on_size) - self.panel.Bind(wx.EVT_PAINT, self.on_paint) - self.panel.Bind(wx.EVT_KEY_DOWN, self.on_key_down) - - self.timer = None - - self.last_pos = None - - self.Bind(wx.EVT_CLOSE, self.on_close) - - def load(self, stitch_plan=None): - if stitch_plan: - self.mirror = False - self.segments = self._stitch_plan_to_segments(stitch_plan) - else: - return - - self.trim_margins() - self.calculate_dimensions() - - def adjust_speed(self, duration): - self.frame_period = 1000 * float(duration) / len(self.segments) - self.stitches_per_frame = 1 - - while self.frame_period < 1.0: - self.frame_period *= 2 - self.stitches_per_frame *= 2 - - def on_key_down(self, event): - keycode = event.GetKeyCode() - - if keycode == ord("+") or keycode == ord("=") or keycode == wx.WXK_UP: - if self.frame_period == 1: - self.stitches_per_frame *= 2 - else: - self.frame_period = self.frame_period / 2 - elif keycode == ord("-") or keycode == ord("_") or keycode == wx.WXK_DOWN: - if self.stitches_per_frame == 1: - self.frame_period *= 2 - else: - self.stitches_per_frame /= 2 - elif keycode == ord("Q"): - self.Close() - elif keycode == ord('P'): - if self.timer.IsRunning(): - self.timer.Stop() - else: - self.timer.Start(self.frame_period) - elif keycode == ord("R"): - self.stop() - self.clear() - self.go() - - self.frame_period = max(1, self.frame_period) - self.stitches_per_frame = max(self.stitches_per_frame, 1) - - if self.timer.IsRunning(): - self.timer.Stop() - self.timer.Start(self.frame_period) - - def _strip_quotes(self, string): - if string.startswith('"') and string.endswith('"'): - string = string[1:-1] - - return string - - def color_to_pen(self, color): - return wx.Pen(color.visible_on_white.rgb) - - def _stitch_plan_to_segments(self, stitch_plan): - segments = [] - - for color_block in stitch_plan: - pen = self.color_to_pen(color_block.color) - - for point_list in color_block_to_point_lists(color_block): - # if there's only one point, there's nothing to do, so skip - if len(point_list) < 2: - continue - - for start, end in izip(point_list[:-1], point_list[1:]): - segments.append(((start, end), pen)) - - return segments - - def all_coordinates(self): - for segment in self.segments: - start, end = segment[0] - - yield start - yield end - - def trim_margins(self): - """remove any unnecessary whitespace around the design""" - - min_x = sys.maxint - min_y = sys.maxint - - for x, y in self.all_coordinates(): - min_x = min(min_x, x) - min_y = min(min_y, y) - - new_segments = [] - - for segment in self.segments: - (start, end), color = segment - - new_segment = ( - ( - (start[0] - min_x, start[1] - min_y), - (end[0] - min_x, end[1] - min_y), - ), - color - ) - - new_segments.append(new_segment) - - self.segments = new_segments - - def calculate_dimensions(self): - # 0.01 avoids a division by zero below for designs with no width or - # height (e.g. a straight vertical or horizontal line) - width = 0.01 - height = 0.01 - - for x, y in self.all_coordinates(): - width = max(width, x) - height = max(height, y) - - self.width = width - self.height = height - self.scale = min(float(self.max_width) / width, float(self.max_height) / height) - - # make room for decorations and the margin - self.scale *= 0.95 - - def go(self): - self.clear() - - self.current_stitch = 0 - - if not self.timer: - self.timer = wx.PyTimer(self.draw_one_frame) - - self.timer.Start(self.frame_period) - - def on_close(self, event): - self.stop() - - if self.on_close_hook: - self.on_close_hook() - - # If we keep a reference here, wx crashes when the process exits. - self.canvas = None - - self.Destroy() - - def stop(self): - if self.timer: - self.timer.Stop() - - def clear(self): - self.dc.SetBackground(wx.Brush('white')) - self.dc.Clear() - self.last_pos = None - self.Refresh() - - def on_size(self, e): - # ensure that the whole canvas is visible - window_width, window_height = self.GetSize() - client_width, client_height = self.GetClientSize() - - decorations_width = window_width - client_width - decorations_height = window_height - client_height - - self.SetSize((self.width * self.scale + decorations_width + self.margin * 2, - self.height * self.scale + decorations_height + self.margin * 2)) - - e.Skip() - - def on_paint(self, e): - dc = wx.PaintDC(self.panel) - dc.Blit(0, 0, self.buffer.GetWidth(), self.buffer.GetHeight(), self.dc, 0, 0) - - if self.last_pos: - dc.DrawLine(self.last_pos[0] - 10, self.last_pos[1], self.last_pos[0] + 10, self.last_pos[1]) - dc.DrawLine(self.last_pos[0], self.last_pos[1] - 10, self.last_pos[0], self.last_pos[1] + 10) - - def draw_one_frame(self): - for i in xrange(self.stitches_per_frame): - try: - ((x1, y1), (x2, y2)), color = self.segments[self.current_stitch] - - if self.mirror: - y1 = self.height - y1 - y2 = self.height - y2 - - x1 = x1 * self.scale + self.margin - y1 = y1 * self.scale + self.margin - x2 = x2 * self.scale + self.margin - y2 = y2 * self.scale + self.margin - - self.canvas.SetPen(color) - self.canvas.DrawLines(((x1, y1), (x2, y2))) - self.Refresh() - - self.current_stitch += 1 - self.last_pos = (x2, y2) - except IndexError: - self.timer.Stop() diff --git a/inkstitch/stitch_plan/__init__.py b/inkstitch/stitch_plan/__init__.py deleted file mode 100644 index 6c1f418a..00000000 --- a/inkstitch/stitch_plan/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from stitch_plan import patches_to_stitch_plan, StitchPlan, ColorBlock diff --git a/inkstitch/stitch_plan/stitch_plan.py b/inkstitch/stitch_plan/stitch_plan.py deleted file mode 100644 index fab87876..00000000 --- a/inkstitch/stitch_plan/stitch_plan.py +++ /dev/null @@ -1,227 +0,0 @@ -from .. import Stitch, PIXELS_PER_MM -from ..utils.geometry import Point -from .stop import process_stop -from .trim import process_trim -from .ties import add_ties -from ..threads import ThreadColor - - -def patches_to_stitch_plan(patches, collapse_len=3.0 * PIXELS_PER_MM): - """Convert a collection of inkstitch.element.Patch objects to a StitchPlan. - - * applies instructions embedded in the Patch such as trim_after and stop_after - * adds tie-ins and tie-offs - * adds jump-stitches between patches if necessary - """ - - stitch_plan = StitchPlan() - color_block = stitch_plan.new_color_block() - - need_trim = False - for patch in patches: - if not patch.stitches: - continue - - if need_trim: - process_trim(color_block, patch.stitches[0]) - need_trim = False - - 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: - # 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 - color_block.add_stitch(color_block.last_stitch.x, color_block.last_stitch.y, stop=True) - color_block = stitch_plan.new_color_block() - 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 - - if patch.stop_after: - process_stop(color_block) - - add_ties(stitch_plan) - - return stitch_plan - - -class StitchPlan(object): - """Holds a set of color blocks, each containing stitches.""" - - def __init__(self): - self.color_blocks = [] - - def new_color_block(self, *args, **kwargs): - color_block = ColorBlock(*args, **kwargs) - self.color_blocks.append(color_block) - return color_block - - def __iter__(self): - return iter(self.color_blocks) - - def __len__(self): - return len(self.color_blocks) - - def __repr__(self): - return "StitchPlan(%s)" % ", ".join(repr(cb) for cb in self.color_blocks) - - @property - def num_colors(self): - """Number of unique colors in the stitch plan.""" - return len({block.color for block in self}) - - @property - def num_stops(self): - return sum(block.num_stops for block in self) - - @property - def num_trims(self): - return sum(block.num_trims for block in self) - - @property - def num_stitches(self): - return sum(block.num_stitches for block in self) - - @property - def bounding_box(self): - color_block_bounding_boxes = [cb.bounding_box for cb in self] - minx = min(bb[0] for bb in color_block_bounding_boxes) - miny = min(bb[1] for bb in color_block_bounding_boxes) - maxx = max(bb[2] for bb in color_block_bounding_boxes) - maxy = max(bb[3] for bb in color_block_bounding_boxes) - - return minx, miny, maxx, maxy - - @property - def dimensions(self): - minx, miny, maxx, maxy = self.bounding_box - return (maxx - minx, maxy - miny) - - @property - def extents(self): - minx, miny, maxx, maxy = self.bounding_box - - return max(-minx, maxx), max(-miny, maxy) - - @property - def dimensions_mm(self): - dimensions = self.dimensions - return (dimensions[0] / PIXELS_PER_MM, dimensions[1] / PIXELS_PER_MM) - - -class ColorBlock(object): - """Holds a set of stitches, all with the same thread color.""" - - def __init__(self, color=None, stitches=None): - self.color = color - self.stitches = stitches or [] - - def __iter__(self): - return iter(self.stitches) - - def __repr__(self): - return "ColorBlock(%s, %s)" % (self.color, self.stitches) - - def has_color(self): - return self._color is not None - - @property - def color(self): - return self._color - - @color.setter - def color(self, value): - if isinstance(value, ThreadColor): - self._color = value - elif value is None: - self._color = None - else: - self._color = ThreadColor(value) - - @property - def last_stitch(self): - if self.stitches: - return self.stitches[-1] - else: - return None - - @property - def num_stitches(self): - """Number of stitches in this color block.""" - 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) - - def filter_duplicate_stitches(self): - if not self.stitches: - return - - 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 - pass - else: - l = (stitch - stitches[-1]).length() - if l <= 0.1: - # duplicate stitch, skip this one - continue - - stitches.append(stitch) - - self.stitches = stitches - - def add_stitch(self, *args, **kwargs): - 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: - self.stitches.append(Stitch(*args, **kwargs)) - - def add_stitches(self, stitches, *args, **kwargs): - for stitch in stitches: - if isinstance(stitch, (Stitch, Point)): - self.add_stitch(stitch, *args, **kwargs) - else: - self.add_stitch(*(list(stitch) + args), **kwargs) - - def replace_stitches(self, stitches): - self.stitches = stitches - - @property - def bounding_box(self): - minx = min(stitch.x for stitch in self) - miny = min(stitch.y for stitch in self) - maxx = max(stitch.x for stitch in self) - maxy = max(stitch.y for stitch in self) - - return minx, miny, maxx, maxy diff --git a/inkstitch/stitch_plan/stop.py b/inkstitch/stitch_plan/stop.py deleted file mode 100644 index c5e9f7e4..00000000 --- a/inkstitch/stitch_plan/stop.py +++ /dev/null @@ -1,27 +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. - - On such 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 introduce an artificial color change - shortly before the current stitch so that the user can set that - to C00. We'll go back 3 stitches and do that: - """ - - if len(color_block.stitches) >= 3: - color_block.stitches[-3].stop = True - - # and also add a color change on this stitch, completing the C00 - # block: - - color_block.stitches[-1].stop = True - - # reference for the above: https://github.com/lexelby/inkstitch/pull/29#issuecomment-359175447 diff --git a/inkstitch/stitch_plan/ties.py b/inkstitch/stitch_plan/ties.py deleted file mode 100644 index 1207ea51..00000000 --- a/inkstitch/stitch_plan/ties.py +++ /dev/null @@ -1,51 +0,0 @@ -from ..utils import cut_path -from ..stitches import running_stitch -from .. import Stitch -from copy import deepcopy - -def add_tie(stitches, tie_path): - if stitches[-1].no_ties: - # It's from a manual stitch block, so don't add tie stitches. The user - # will add them if they want them. - return - - tie_path = cut_path(tie_path, 0.6) - tie_stitches = running_stitch(tie_path, 0.3) - tie_stitches = [Stitch(stitch.x, stitch.y) for stitch in tie_stitches] - - stitches.extend(deepcopy(tie_stitches[1:])) - stitches.extend(deepcopy(list(reversed(tie_stitches))[1:])) - - -def add_tie_off(stitches): - add_tie(stitches, list(reversed(stitches))) - - -def add_tie_in(stitches, upcoming_stitches): - add_tie(stitches, upcoming_stitches) - - -def add_ties(stitch_plan): - """Add tie-off before and after trims, jumps, and color changes.""" - - for color_block in stitch_plan: - need_tie_in = True - new_stitches = [] - for i, stitch in enumerate(color_block.stitches): - is_special = stitch.trim or stitch.jump or stitch.stop - - if is_special and not need_tie_in: - add_tie_off(new_stitches) - new_stitches.append(stitch) - need_tie_in = True - elif need_tie_in and not is_special: - new_stitches.append(stitch) - add_tie_in(new_stitches, upcoming_stitches=color_block.stitches[i:]) - need_tie_in = False - else: - new_stitches.append(stitch) - - if not need_tie_in: - add_tie_off(new_stitches) - - color_block.replace_stitches(new_stitches) diff --git a/inkstitch/stitch_plan/trim.py b/inkstitch/stitch_plan/trim.py deleted file mode 100644 index f692a179..00000000 --- a/inkstitch/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/inkstitch/stitches/__init__.py b/inkstitch/stitches/__init__.py deleted file mode 100644 index d2ff0446..00000000 --- a/inkstitch/stitches/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from running_stitch import * -from auto_fill import auto_fill -from fill import legacy_fill diff --git a/inkstitch/stitches/auto_fill.py b/inkstitch/stitches/auto_fill.py deleted file mode 100644 index 7f265909..00000000 --- a/inkstitch/stitches/auto_fill.py +++ /dev/null @@ -1,447 +0,0 @@ -from fill import intersect_region_with_grating, row_num, stitch_row -from .. import _, PIXELS_PER_MM, Point as InkstitchPoint -import sys -import shapely -import networkx -import math -from itertools import groupby -from collections import deque - - -class MaxQueueLengthExceeded(Exception): - pass - - -def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_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)) - - stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers)) - - return stitches - - -def which_outline(shape, coords): - """return the index of the outline on which the point resides - - Index 0 is the outer boundary of the fill region. 1+ are the - outlines of the holes. - """ - - # I'd use an intersection check, but floating point errors make it - # fail sometimes. - - point = shapely.geometry.Point(*coords) - outlines = enumerate(list(shape.boundary)) - closest = min(outlines, key=lambda (index, outline): outline.distance(point)) - - return closest[0] - - -def project(shape, coords, outline_index): - """project the point onto the specified outline - - This returns the distance along the outline at which the point resides. - """ - - outline = list(shape.boundary)[outline_index] - return outline.project(shapely.geometry.Point(*coords)) - - -def build_graph(shape, segments, angle, row_spacing): - """build a graph representation of the grating segments - - This function builds a specialized graph (as in graph theory) that will - help us determine a stitching path. The idea comes from this paper: - - http://www.sciencedirect.com/science/article/pii/S0925772100000158 - - The goal is to build a graph that we know must have an Eulerian Path. - An Eulerian Path is a path from edge to edge in the graph that visits - every edge exactly once and ends at the node it started at. Algorithms - exist to build such a path, and we'll use Hierholzer's algorithm. - - A graph must have an Eulerian Path if every node in the graph has an - even number of edges touching it. Our goal here is to build a graph - that will have this property. - - Based on the paper linked above, we'll build the graph as follows: - - * nodes are the endpoints of the grating segments, where they meet - with the outer outline of the region the outlines of the interior - holes in the region. - * edges are: - * each section of the outer and inner outlines of the region, - between nodes - * double every other edge in the outer and inner hole outlines - - Doubling up on some of the edges seems as if it will just mean we have - to stitch those spots twice. This may be true, but it also ensures - that every node has 4 edges touching it, ensuring that a valid stitch - path must exist. - """ - - graph = networkx.MultiGraph() - - # First, add the grating segments as edges. We'll use the coordinates - # of the endpoints as nodes, which networkx will add automatically. - for segment in segments: - # networkx allows us to label nodes with arbitrary data. We'll - # mark this one as a grating segment. - graph.add_edge(*segment, key="segment") - - for node in graph.nodes(): - outline_index = which_outline(shape, node) - outline_projection = project(shape, node, outline_index) - - # Tag each node with its index and projection. - graph.add_node(node, index=outline_index, projection=outline_projection) - - nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] - nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection'])) - - for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']): - nodes = [ node for node, data in nodes ] - - # heuristic: change the order I visit the nodes in the outline if necessary. - # If the start and endpoints are in the same row, I can't tell which row - # I should treat it as being in. - for i in xrange(len(nodes)): - row0 = row_num(InkstitchPoint(*nodes[0]), angle, row_spacing) - row1 = row_num(InkstitchPoint(*nodes[1]), angle, row_spacing) - - if row0 == row1: - nodes = nodes[1:] + [nodes[0]] - else: - break - - # heuristic: it's useful to try to keep the duplicated edges in the same rows. - # this prevents the BFS from having to search a ton of edges. - min_row_num = min(row0, row1) - if min_row_num % 2 == 0: - edge_set = 0 - 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") - - # duplicate every other edge around this outline - if i % 2 == edge_set: - graph.add_edge(node1, node2, key="extra") - - - if not networkx.is_eulerian(graph): - raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected.")) - - return graph - - -def node_list_to_edge_list(node_list): - return zip(node_list[:-1], node_list[1:]) - - -def bfs_for_loop(graph, starting_node, max_queue_length=2000): - to_search = deque() - to_search.appendleft(([starting_node], set(), 0)) - - while to_search: - if len(to_search) > max_queue_length: - raise MaxQueueLengthExceeded() - - path, visited_edges, visited_segments = to_search.pop() - ending_node = path[-1] - - # get a list of neighbors paired with the key of the edge I can follow to get there - neighbors = [ - (node, key) - for node, adj in graph.adj[ending_node].iteritems() - for key in adj - ] - - # heuristic: try grating segments first - neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True) - - for next_node, key in neighbors: - # skip if I've already followed this edge - edge = (tuple(sorted((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 - - 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 - - new_visited_edges = visited_edges.copy() - new_visited_edges.add(edge) - - to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) - - -def find_loop(graph, starting_nodes): - """find a loop in the graph that is connected to the existing path - - Start at a candidate node and search through edges to find a path - back to that node. We'll use a breadth-first search (BFS) in order to - find the shortest available loop. - - In most cases, the BFS should not need to search far to find a loop. - The queue should stay relatively short. - - An added heuristic will be used: if the BFS queue's length becomes - too long, we'll abort and try a different starting point. Due to - the way we've set up the graph, there's bound to be a better choice - 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 - - 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 - # constructed from the starting_node (because the - # necessary edges have already been consumed). In that - # 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. - retry.append(starting_node) - - # Darn, couldn't find a loop. Try harder. - starting_nodes.extendleft(retry) - max_queue_length *= 2 - - starting_nodes.extendleft(retry) - return loop - - -def insert_loop(path, loop): - """insert a sub-loop into an existing path - - The path will be a series of edges describing a path through the graph - that ends where it starts. The loop will be similar, and its starting - point will be somewhere along the path. - - Insert the loop into the path, resulting in a longer path. - - Both the path and the loop will be a list of edges specified as a - 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)) - - path will be modified in place. - """ - - loop_start = loop[0][0] - - for i, (start, end) in enumerate(path): - if start == loop_start: - break - - path[i:i] = loop - - -def find_stitch_path(graph, segments): - """find a path that visits every grating segment exactly once - - Theoretically, we just need to find an Eulerian Path in the graph. - However, we don't actually care whether every single edge is visited. - 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. - - 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 - mowing a lawn. - - To do this, we'll use a simple heuristic: try to start from nodes in - the order of most-recently-visited first. - """ - - original_graph = graph - graph = graph.copy() - num_segments = len(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]))] - - graph.remove_edges_from(path) - - segments_visited += 1 - nodes_visited.extend(segments[0]) - - while segments_visited < num_segments: - result = find_loop(graph, nodes_visited) - - if not result: - 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] - 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() - - return path - - -def collapse_sequential_outline_edges(graph, path): - """collapse sequential edges that fall on the same outline - - When the path follows multiple edges along the outline of the region, - replace those edges with the starting and ending points. We'll use - these to stitch along the outline later on. - """ - - start_of_run = None - new_path = [] - - for edge in path: - if graph.has_edge(*edge, key="segment"): - if start_of_run: - # close off the last run - new_path.append((start_of_run, edge[0])) - start_of_run = None - - new_path.append(edge) - else: - if not start_of_run: - start_of_run = edge[0] - - if start_of_run: - # if we were still in a run, close it off - new_path.append((start_of_run, edge[1])) - - return new_path - - -def outline_distance(outline, p1, p2): - # how far around the outline (and in what direction) do I need to go - # to get from p1 to p2? - - p1_projection = outline.project(shapely.geometry.Point(p1)) - p2_projection = outline.project(shapely.geometry.Point(p2)) - - distance = p2_projection - p1_projection - - if abs(distance) > outline.length / 2.0: - # if we'd have to go more than halfway around, it's faster to go - # the other way - if distance < 0: - return distance + outline.length - elif distance > 0: - return distance - outline.length - else: - # this ought not happen, but just for completeness, return 0 if - # p1 and p0 are the same point - return 0 - else: - return distance - - -def connect_points(shape, start, end, running_stitch_length): - outline_index = which_outline(shape, start) - outline = shape.boundary[outline_index] - - pos = outline.project(shapely.geometry.Point(start)) - distance = outline_distance(outline, start, end) - num_stitches = abs(int(distance / 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): - pos = (pos + one_stitch) % outline.length - - stitches.append(InkstitchPoint(*outline.interpolate(pos).coords[0])) - - end = InkstitchPoint(*end) - if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: - stitches.append(end) - - #print >> dbg, "end connect_points" - #dbg.flush() - - return stitches - - -def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers): - path = collapse_sequential_outline_edges(graph, path) - - stitches = [] - - for edge in path: - if graph.has_edge(*edge, key="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)) - - return stitches diff --git a/inkstitch/stitches/fill.py b/inkstitch/stitches/fill.py deleted file mode 100644 index 1b7377b0..00000000 --- a/inkstitch/stitches/fill.py +++ /dev/null @@ -1,245 +0,0 @@ -from .. import PIXELS_PER_MM -from ..utils import cache, Point as InkstitchPoint -import shapely -import math -import sys - - -def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): - rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) - groups_of_segments = pull_runs(rows_of_segments, shape, row_spacing) - - return [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers) - for group in groups_of_segments] - - -@cache -def east(angle): - # "east" is the name of the direction that is to the right along a row - return InkstitchPoint(1, 0).rotate(-angle) - - -@cache -def north(angle): - return east(angle).rotate(math.pi / 2) - - -def row_num(point, angle, row_spacing): - return round((point * north(angle)) / row_spacing) - - -def adjust_stagger(stitch, angle, row_spacing, max_stitch_length, staggers): - this_row_num = row_num(stitch, angle, row_spacing) - row_stagger = this_row_num % staggers - stagger_offset = (float(row_stagger) / staggers) * max_stitch_length - offset = ((stitch * east(angle)) - stagger_offset) % max_stitch_length - - return stitch - offset * east(angle) - -def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers): - # We want our stitches to look like this: - # - # ---*-----------*----------- - # ------*-----------*-------- - # ---------*-----------*----- - # ------------*-----------*-- - # ---*-----------*----------- - # - # Each successive row of stitches will be staggered, with - # num_staggers rows before the pattern repeats. A value of - # 4 gives a nice fill while hiding the needle holes. The - # first row is offset 0%, the second 25%, the third 50%, and - # the fourth 75%. - # - # Actually, instead of just starting at an offset of 0, we - # can calculate a row's offset relative to the origin. This - # way if we have two abutting fill regions, they'll perfectly - # tile with each other. That's important because we often get - # abutting fill regions from pull_runs(). - - beg = InkstitchPoint(*beg) - end = InkstitchPoint(*end) - - row_direction = (end - beg).unit() - segment_length = (end - beg).length() - - # only stitch the first point if it's a reasonable distance away from the - # last stitch - if not stitches or (beg - stitches[-1]).length() > 0.5 * PIXELS_PER_MM: - stitches.append(beg) - - first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) - - # we might have chosen our first stitch just outside this row, so move back in - if (first_stitch - beg) * row_direction < 0: - first_stitch += row_direction * max_stitch_length - - offset = (first_stitch - beg).length() - - while offset < segment_length: - stitches.append(beg + offset * row_direction) - offset += max_stitch_length - - if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: - stitches.append(end) - - -def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=None, flip=False): - # the max line length I'll need to intersect the whole shape is the diagonal - (minx, miny, maxx, maxy) = shape.bounds - upper_left = InkstitchPoint(minx, miny) - lower_right = InkstitchPoint(maxx, maxy) - length = (upper_left - lower_right).length() - half_length = length / 2.0 - - # Now get a unit vector rotated to the requested angle. I use -angle - # because shapely rotates clockwise, but my geometry textbooks taught - # me to consider angles as counter-clockwise from the X axis. - direction = InkstitchPoint(1, 0).rotate(-angle) - - # and get a normal vector - normal = direction.rotate(math.pi / 2) - - # I'll start from the center, move in the normal direction some amount, - # and then walk left and right half_length in each direction to create - # a line segment in the grating. - center = InkstitchPoint((minx + maxx) / 2.0, (miny + maxy) / 2.0) - - # I need to figure out how far I need to go along the normal to get to - # the edge of the shape. To do that, I'll rotate the bounding box - # angle degrees clockwise and ask for the new bounding box. The max - # and min y tell me how far to go. - - _, start, _, end = shapely.affinity.rotate(shape, angle, origin='center', use_radians=True).bounds - - # convert start and end to be relative to center (simplifies things later) - start -= center.y - end -= center.y - - height = abs(end - start) - - #print >> dbg, "grating:", start, end, height, row_spacing, end_row_spacing - - # offset start slightly so that rows are always an even multiple of - # row_spacing_px from the origin. This makes it so that abutting - # fill regions at the same angle and spacing always line up nicely. - start -= (start + normal * center) % row_spacing - - rows = [] - - current_row_y = start - - while current_row_y < end: - p0 = center + normal * current_row_y + direction * half_length - p1 = center + normal * current_row_y - direction * half_length - endpoints = [p0.as_tuple(), p1.as_tuple()] - grating_line = shapely.geometry.LineString(endpoints) - - res = grating_line.intersection(shape) - - if (isinstance(res, shapely.geometry.MultiLineString)): - runs = map(lambda line_string: line_string.coords, res.geoms) - else: - if res.is_empty or len(res.coords) == 1: - # ignore if we intersected at a single point or no points - runs = [] - else: - runs = [res.coords] - - if runs: - runs.sort(key=lambda seg: (InkstitchPoint(*seg[0]) - upper_left).length()) - - if flip: - runs.reverse() - runs = map(lambda run: tuple(reversed(run)), runs) - - rows.append(runs) - - if end_row_spacing: - current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) - else: - current_row_y += row_spacing - - return rows - -def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers): - stitches = [] - first_segment = True - swap = False - last_end = None - - for segment in group_of_segments: - (beg, end) = segment - - if (swap): - (beg, end) = (end, beg) - - stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers) - - swap = not swap - - return stitches - - -def make_quadrilateral(segment1, segment2): - return shapely.geometry.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) - - -def is_same_run(segment1, segment2, shape, row_spacing): - line1 = shapely.geometry.LineString(segment1) - line2 = shapely.geometry.LineString(segment2) - - if line1.distance(line2) > row_spacing * 1.1: - return False - - quad = make_quadrilateral(segment1, segment2) - quad_area = quad.area - intersection_area = shape.intersection(quad).area - - return (intersection_area / quad_area) >= 0.9 - - -def pull_runs(rows, shape, row_spacing): - # Given a list of rows, each containing a set of line segments, - # break the area up into contiguous patches of line segments. - # - # This is done by repeatedly pulling off the first line segment in - # each row and calling that a shape. We have to be careful to make - # sure that the line segments are part of the same shape. Consider - # the letter "H", with an embroidery angle of 45 degrees. When - # we get to the bottom of the lower left leg, the next row will jump - # over to midway up the lower right leg. We want to stop there and - # start a new patch. - - # for row in rows: - # print >> sys.stderr, len(row) - - # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) - - runs = [] - count = 0 - while (len(rows) > 0): - run = [] - prev = None - - for row_num in xrange(len(rows)): - row = rows[row_num] - first, rest = row[0], row[1:] - - # TODO: only accept actually adjacent rows here - if prev is not None and not is_same_run(prev, first, shape, row_spacing): - break - - run.append(first) - prev = first - - rows[row_num] = rest - - # print >> sys.stderr, len(run) - runs.append(run) - rows = [row for row in rows if len(row) > 0] - - count += 1 - - return runs - diff --git a/inkstitch/stitches/running_stitch.py b/inkstitch/stitches/running_stitch.py deleted file mode 100644 index 81124339..00000000 --- a/inkstitch/stitches/running_stitch.py +++ /dev/null @@ -1,66 +0,0 @@ -""" 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() - - if segment_length == 0: - continue - - 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/svg.py b/inkstitch/svg.py deleted file mode 100644 index 0728309b..00000000 --- a/inkstitch/svg.py +++ /dev/null @@ -1,76 +0,0 @@ -import simpletransform, simplestyle, inkex -from . import _, get_viewbox_transform, cache, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG - -def color_block_to_point_lists(color_block): - point_lists = [[]] - - for stitch in color_block: - if stitch.trim: - if point_lists[-1]: - point_lists.append([]) - continue - - if not stitch.jump and not stitch.stop: - point_lists[-1].append(stitch.as_tuple()) - - return point_lists - - -@cache -def get_correction_transform(svg): - transform = get_viewbox_transform(svg) - - # we need to correct for the viewbox - transform = simpletransform.invertTransform(transform) - transform = simpletransform.formatTransform(transform) - - return transform - - -def color_block_to_paths(color_block, svg): - paths = [] - # We could emit just a single path with one subpath per point list, but - # emitting multiple paths makes it easier for the user to manipulate them. - for point_list in color_block_to_point_lists(color_block): - color = color_block.color.visible_on_white.to_hex_str() - paths.append(inkex.etree.Element( - SVG_PATH_TAG, - {'style': simplestyle.formatStyle( - {'stroke': color, - 'stroke-width': "0.4", - 'fill': 'none'}), - 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), - 'transform': get_correction_transform(svg), - 'embroider_manual_stitch': 'true', - 'embroider_trim_after': 'true', - })) - - # no need to trim at the end of a thread color - if paths: - paths[-1].attrib.pop('embroider_trim_after') - - return paths - - -def render_stitch_plan(svg, stitch_plan): - layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - if layer is None: - layer = inkex.etree.Element(SVG_GROUP_TAG, - {'id': '__inkstitch_stitch_plan__', - INKSCAPE_LABEL: _('Stitch Plan'), - INKSCAPE_GROUPMODE: 'layer'}) - else: - # delete old stitch plan - del layer[:] - - # make sure the layer is visible - layer.set('style', 'display:inline') - - for i, color_block in enumerate(stitch_plan): - group = inkex.etree.SubElement(layer, - SVG_GROUP_TAG, - {'id': '__color_block_%d__' % i, - INKSCAPE_LABEL: "color block %d" % (i + 1)}) - group.extend(color_block_to_paths(color_block, svg)) - - svg.append(layer) diff --git a/inkstitch/threads/__init__.py b/inkstitch/threads/__init__.py deleted file mode 100644 index 03cd777b..00000000 --- a/inkstitch/threads/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from color import ThreadColor -from palette import ThreadPalette -from catalog import ThreadCatalog diff --git a/inkstitch/threads/catalog.py b/inkstitch/threads/catalog.py deleted file mode 100644 index cebae4ff..00000000 --- a/inkstitch/threads/catalog.py +++ /dev/null @@ -1,95 +0,0 @@ -import os -from os.path import dirname, realpath -import sys -from glob import glob -from collections import Sequence -from .palette import ThreadPalette - -class _ThreadCatalog(Sequence): - """Holds a set of ThreadPalettes.""" - - def __init__(self): - self.palettes = [] - self.load_palettes(self.get_palettes_path()) - - def get_palettes_path(self): - if getattr(sys, 'frozen', None) is not None: - path = os.path.join(sys._MEIPASS, "..") - else: - path = dirname(dirname(dirname(realpath(__file__)))) - - return os.path.join(path, 'palettes') - - def load_palettes(self, path): - for palette_file in glob(os.path.join(path, '*.gpl')): - self.palettes.append(ThreadPalette(palette_file)) - - def palette_names(self): - return list(sorted(palette.name for palette in self)) - - def __getitem__(self, item): - return self.palettes[item] - - def __len__(self): - return len(self.palettes) - - def _num_exact_color_matches(self, palette, threads): - """Number of colors in stitch plan with an exact match in this palette.""" - - return sum(1 for thread in threads if thread in palette) - - def match_and_apply_palette(self, stitch_plan, palette=None): - if palette is None: - palette = self.match_palette(stitch_plan) - else: - palette = self.get_palette_by_name(palette) - - if palette is not None: - self.apply_palette(stitch_plan, palette) - - return palette - - def match_palette(self, stitch_plan): - """Figure out which color palette was used - - Scans the catalog of color palettes and chooses one that seems most - likely to be the one that the user used. A palette will only be - chosen if more tha 80% of the thread colors in the stitch plan are - exact matches for threads in the palette. - """ - - threads = [color_block.color for color_block in stitch_plan] - palettes_and_matches = [(palette, self._num_exact_color_matches(palette, threads)) - for palette in self] - palette, matches = max(palettes_and_matches, key=lambda item: item[1]) - - if matches < 0.8 * len(stitch_plan): - # if less than 80% of the colors are an exact match, - # don't use this palette - return None - else: - return palette - - def apply_palette(self, stitch_plan, palette): - for color_block in stitch_plan: - nearest = palette.nearest_color(color_block.color) - - color_block.color.name = nearest.name - color_block.color.number = nearest.number - color_block.color.manufacturer = nearest.manufacturer - - def get_palette_by_name(self, name): - for palette in self: - if palette.name == name: - return palette - -_catalog = None - -def ThreadCatalog(): - """Singleton _ThreadCatalog factory""" - - global _catalog - if _catalog is None: - _catalog = _ThreadCatalog() - - return _catalog diff --git a/inkstitch/threads/color.py b/inkstitch/threads/color.py deleted file mode 100644 index af474127..00000000 --- a/inkstitch/threads/color.py +++ /dev/null @@ -1,82 +0,0 @@ -import simplestyle -import re -import colorsys - - -class ThreadColor(object): - hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I) - - def __init__(self, color, name=None, number=None, manufacturer=None): - if color is None: - self.rgb = (0, 0, 0) - elif isinstance(color, (list, tuple)): - self.rgb = tuple(color) - elif self.hex_str_re.match(color): - self.rgb = simplestyle.parseColor(color) - else: - raise ValueError("Invalid color: " + repr(color)) - - self.name = name - self.number = number - self.manufacturer = manufacturer - - def __eq__(self, other): - if isinstance(other, ThreadColor): - return self.rgb == other.rgb - else: - return self == ThreadColor(other) - - def __hash__(self): - return hash(self.rgb) - - def __ne__(self, other): - return not(self == other) - - def __repr__(self): - return "ThreadColor" + repr(self.rgb) - - def to_hex_str(self): - return "#%s" % self.hex_digits - - @property - def hex_digits(self): - return "%02X%02X%02X" % self.rgb - - @property - def rgb_normalized(self): - return tuple(channel / 255.0 for channel in self.rgb) - - @property - def font_color(self): - """Pick a color that will allow text to show up on a swatch in the printout.""" - hls = colorsys.rgb_to_hls(*self.rgb_normalized) - - # We'll use white text unless the swatch color is too light. - if hls[1] > 0.7: - return (1, 1, 1) - else: - return (254, 254, 254) - - @property - def visible_on_white(self): - """A ThreadColor similar to this one but visible on white. - - If the thread color is white, we don't want to try to draw white in the - simulation view or print white in the print-out. Choose a color that's - as close as possible to the actual thread color but is still at least - somewhat visible on a white background. - """ - - hls = list(colorsys.rgb_to_hls(*self.rgb_normalized)) - - # Capping lightness should make the color visible without changing it - # too much. - if hls[1] > 0.85: - hls[1] = 0.85 - - 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/inkstitch/threads/palette.py b/inkstitch/threads/palette.py deleted file mode 100644 index e1f47c7f..00000000 --- a/inkstitch/threads/palette.py +++ /dev/null @@ -1,72 +0,0 @@ -from collections import Set -from .color import ThreadColor -from colormath.color_objects import sRGBColor, LabColor -from colormath.color_conversions import convert_color -from colormath.color_diff import delta_e_cie1994 - - -def compare_thread_colors(color1, color2): - # K_L=2 indicates textiles - return delta_e_cie1994(color1, color2, K_L=2) - - -class ThreadPalette(Set): - """Holds a set of ThreadColors all from the same manufacturer.""" - - def __init__(self, palette_file): - self.threads = dict() - self.parse_palette_file(palette_file) - - def parse_palette_file(self, palette_file): - """Read a GIMP palette file and load thread colors. - - Example file: - - GIMP Palette - Name: Ink/Stitch: Metro - Columns: 4 - # RGB Value Color Name Number - 240 186 212 Sugar Pink 1624 - 237 171 194 Carnatio 1636 - - """ - - with open(palette_file) as palette: - line = palette.readline().strip() - if line.lower() != "gimp palette": - raise ValueError("Invalid gimp palette header") - - self.name = palette.readline().strip() - if self.name.lower().startswith('name: ink/stitch: '): - self.name = self.name[18:] - - columns_line = palette.readline() - headers_line = palette.readline() - - for line in palette: - fields = line.split("\t", 3) - thread_color = [int(field) for field in fields[:3]] - thread_name, thread_number = fields[3].strip().rsplit(" ", 1) - thread_name = thread_name.strip() - - thread = ThreadColor(thread_color, thread_name, thread_number, manufacturer=self.name) - self.threads[thread] = convert_color(sRGBColor(*thread_color, is_upscaled=True), LabColor) - - def __contains__(self, thread): - return thread in self.threads - - def __iter__(self): - return iter(self.threads) - - def __len__(self): - return len(self.threads) - - def nearest_color(self, color): - """Find the thread in this palette that looks the most like the specified color.""" - - if isinstance(color, ThreadColor): - color = color.rgb - - color = convert_color(sRGBColor(*color, is_upscaled=True), LabColor) - - return min(self, key=lambda thread: compare_thread_colors(self.threads[thread], color)) diff --git a/inkstitch/utils/__init__.py b/inkstitch/utils/__init__.py deleted file mode 100644 index ff06d4a9..00000000 --- a/inkstitch/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from geometry import * -from cache import cache -from io import * -from inkscape import * diff --git a/inkstitch/utils/cache.py b/inkstitch/utils/cache.py deleted file mode 100644 index 38fe8f2c..00000000 --- a/inkstitch/utils/cache.py +++ /dev/null @@ -1,8 +0,0 @@ -try: - from functools import lru_cache -except ImportError: - from backports.functools_lru_cache import lru_cache - -# simplify use of lru_cache decorator -def cache(*args, **kwargs): - return lru_cache(maxsize=None)(*args, **kwargs) diff --git a/inkstitch/utils/geometry.py b/inkstitch/utils/geometry.py deleted file mode 100644 index 61b98bcb..00000000 --- a/inkstitch/utils/geometry.py +++ /dev/null @@ -1,102 +0,0 @@ -from shapely.geometry import LineString, Point as ShapelyPoint -import math - - -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), None] - coords = list(line.coords) - for i, p in enumerate(coords): - # TODO: I think this doesn't work if the path doubles back on itself - 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 [Point(*point) for point in subpath.coords] - - -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 diff --git a/inkstitch/utils/inkscape.py b/inkstitch/utils/inkscape.py deleted file mode 100644 index 2d0298bc..00000000 --- a/inkstitch/utils/inkscape.py +++ /dev/null @@ -1,15 +0,0 @@ -from os.path import realpath, expanduser, join as path_join -import sys - -def guess_inkscape_config_path(): - if getattr(sys, 'frozen', None): - path = realpath(path_join(sys._MEIPASS, "..", "..", "..")) - if sys.platform == "win32": - import win32api - - # This expands ugly things like EXTENS~1 - path = win32api.GetLongPathName(path) - else: - path = expanduser("~/.config/inkscape") - - return path diff --git a/inkstitch/utils/io.py b/inkstitch/utils/io.py deleted file mode 100644 index e87b9881..00000000 --- a/inkstitch/utils/io.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -import sys -from cStringIO import StringIO - -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()) - 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 diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 00000000..2c0ee620 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1,298 @@ +#!/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 +from .utils import cache +from .utils.geometry import Point + +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 + + +# 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') +INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') +INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') + +EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) + +dbg = open(os.devnull, "w") + +translation = None +_ = lambda message: message + + +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') + + global translation, _ + + translation = gettext.translation("inkstitch", locale_dir, fallback=True) + _ = 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 + +@cache +def get_stroke_scale(node): + doc_width, doc_height = get_doc_size(node) + viewbox = node.get('viewBox').strip().replace(',', ' ').split() + return doc_width / float(viewbox[2]) + + +class Stitch(Point): + def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): + self.x = x + self.y = y + self.color = color + self.jump = jump + self.trim = trim + self.stop = stop + 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 " ") + + +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 + + 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, stitch_plan, svg): + origin = get_origin(svg) + + pattern = libembroidery.embPattern_create() + + for color_block in stitch_plan: + add_thread(pattern, make_thread(color_block.color)) + + for stitch in color_block: + if stitch.stop and stitch is not color_block.last_stitch: + # A STOP stitch that is not at the end of a color block + # occurs when the user specified "STOP after". "STOP" is the + # same thing as a color change, and the user will assign a + # special color at the machine that tells it to pause after. + # We need to add another copy of the same color here so that + # the stitches after the STOP are still the same color. + add_thread(pattern, make_thread(color_block.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/lib/elements/__init__.py b/lib/elements/__init__.py new file mode 100644 index 00000000..7e05e19c --- /dev/null +++ b/lib/elements/__init__.py @@ -0,0 +1,6 @@ +from auto_fill import AutoFill +from fill import Fill +from stroke import Stroke +from satin_column import SatinColumn +from element import EmbroideryElement +from polyline import Polyline diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py new file mode 100644 index 00000000..6eb1f10c --- /dev/null +++ b/lib/elements/auto_fill.py @@ -0,0 +1,108 @@ +import math +from .. import _ +from .element import param, Patch +from ..utils import cache +from .fill import Fill +from shapely import geometry as shgeo +from ..stitches import auto_fill + + +class AutoFill(Fill): + element_name = _("Auto-Fill") + + @property + @param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True) + def auto_fill(self): + return self.get_boolean_param('auto_fill', True) + + @property + @cache + def outline(self): + return self.shape.boundary[0] + + @property + @cache + def outline_length(self): + return self.outline.length + + @property + def flip(self): + return False + + @property + @param('running_stitch_length_mm', _('Running stitch length (traversal between sections)'), unit='mm', type='float', default=1.5) + def running_stitch_length(self): + return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) + + @property + @param('fill_underlay', _('Underlay'), type='toggle', group=_('AutoFill Underlay'), default=False) + def fill_underlay(self): + return self.get_boolean_param("fill_underlay", default=False) + + @property + @param('fill_underlay_angle', _('Fill angle (default: fill angle + 90 deg)'), unit='deg', group=_('AutoFill Underlay'), type='float') + @cache + def fill_underlay_angle(self): + underlay_angle = self.get_float_param("fill_underlay_angle") + + if underlay_angle: + return math.radians(underlay_angle) + else: + return self.angle + math.pi / 2.0 + + @property + @param('fill_underlay_row_spacing_mm', _('Row spacing (default: 3x fill row spacing)'), unit='mm', group=_('AutoFill Underlay'), type='float') + @cache + def fill_underlay_row_spacing(self): + return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3 + + @property + @param('fill_underlay_max_stitch_length_mm', _('Max stitch length'), unit='mm', group=_('AutoFill Underlay'), type='float') + @cache + def fill_underlay_max_stitch_length(self): + 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) + 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) + if not isinstance(shape, shgeo.MultiPolygon): + shape = shgeo.MultiPolygon([shape]) + return shape + else: + return self.shape + + def to_patches(self, last_patch): + stitches = [] + + if last_patch is None: + starting_point = None + else: + starting_point = last_patch.stitches[-1] + + if self.fill_underlay: + stitches.extend(auto_fill(self.underlay_shape, + self.fill_underlay_angle, + self.fill_underlay_row_spacing, + self.fill_underlay_row_spacing, + self.fill_underlay_max_stitch_length, + self.running_stitch_length, + self.staggers, + starting_point)) + starting_point = stitches[-1] + + stitches.extend(auto_fill(self.shape, + self.angle, + self.row_spacing, + self.end_row_spacing, + self.max_stitch_length, + self.running_stitch_length, + self.staggers, + starting_point)) + + return [Patch(stitches=stitches, color=self.color)] diff --git a/lib/elements/element.py b/lib/elements/element.py new file mode 100644 index 00000000..cfca3782 --- /dev/null +++ b/lib/elements/element.py @@ -0,0 +1,254 @@ +import sys +from copy import deepcopy + +from ..utils import cache +from shapely import geometry as shgeo +from .. import _, PIXELS_PER_MM, get_viewbox_transform, get_stroke_scale, convert_length + +# inkscape-provided utilities +import simpletransform +import simplestyle +import cubicsuperpath +from cspsubdiv import cspsubdiv + +class Patch: + """A raw collection of stitches with attached instructions.""" + + def __init__(self, color=None, stitches=None, trim_after=False, stop_after=False, stitch_as_is=False): + self.color = color + self.stitches = stitches or [] + self.trim_after = trim_after + self.stop_after = stop_after + self.stitch_as_is = stitch_as_is + + def __add__(self, other): + if isinstance(other, Patch): + return Patch(self.color, self.stitches + other.stitches) + else: + raise TypeError("Patch can only be added to another Patch") + + def add_stitch(self, stitch): + self.stitches.append(stitch) + + def reverse(self): + 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 + 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): + value = default + + if value is None: + return value + + 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 + @cache + def stroke_width(self): + width = self.get_style("stroke-width") + + if width is None: + return 1.0 + + width = convert_length(width) + + return width * get_stroke_scale(self.node.getroottree().getroot()) + + @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 strip_control_points(self, subpath): + return [point for control_before, point, control_after in subpath] + + def flatten(self, path): + """approximate a path containing beziers with a series of points""" + + path = deepcopy(path) + cspsubdiv(path, 0.1) + + 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) + + 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) diff --git a/lib/elements/fill.py b/lib/elements/fill.py new file mode 100644 index 00000000..a74a897d --- /dev/null +++ b/lib/elements/fill.py @@ -0,0 +1,97 @@ +from .. import _, PIXELS_PER_MM +from .element import param, EmbroideryElement, Patch +from ..utils import cache +from shapely import geometry as shgeo +import math +from ..stitches import running_stitch, auto_fill, legacy_fill + +class Fill(EmbroideryElement): + element_name = _("Fill") + + def __init__(self, *args, **kwargs): + super(Fill, self).__init__(*args, **kwargs) + + @property + @param('auto_fill', _('Manually routed fill stitching'), type='toggle', inverse=True, default=True) + def auto_fill(self): + return self.get_boolean_param('auto_fill', True) + + @property + @param('angle', _('Angle of lines of stitches'), unit='deg', type='float', default=0) + @cache + def angle(self): + return math.radians(self.get_float_param('angle', 0)) + + @property + def color(self): + return self.get_style("fill") + + @property + @param('flip', _('Flip fill (start right-to-left)'), type='boolean', default=False) + def flip(self): + return self.get_boolean_param("flip", False) + + @property + @param('row_spacing_mm', _('Spacing between rows'), unit='mm', type='float', default=0.25) + def row_spacing(self): + return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM) + + @property + def end_row_spacing(self): + return self.get_float_param("end_row_spacing_mm") + + @property + @param('max_stitch_length_mm', _('Maximum fill stitch length'), unit='mm', type='float', default=3.0) + def max_stitch_length(self): + return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM) + + @property + @param('staggers', _('Stagger rows this many times before repeating'), type='int', default=4) + def staggers(self): + return self.get_int_param("staggers", 4) + + @property + @cache + def paths(self): + return self.flatten(self.parse_path()) + + @property + @cache + def shape(self): + poly_ary = [] + for sub_path in self.paths: + point_ary = [] + last_pt = None + for pt in sub_path: + if (last_pt is not None): + vp = (pt[0] - last_pt[0], pt[1] - last_pt[1]) + dp = math.sqrt(math.pow(vp[0], 2.0) + math.pow(vp[1], 2.0)) + # dbg.write("dp %s\n" % dp) + if (dp > 0.01): + # I think too-close points confuse shapely. + point_ary.append(pt) + last_pt = pt + else: + last_pt = pt + if point_ary: + poly_ary.append(point_ary) + + # shapely's idea of "holes" are to subtract everything in the second set + # from the first. So let's at least make sure the "first" thing is the + # biggest path. + # TODO: actually figure out which things are holes and which are shells + poly_ary.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True) + + polygon = shgeo.MultiPolygon([(poly_ary[0], poly_ary[1:])]) + # print >> sys.stderr, "polygon valid:", polygon.is_valid + return polygon + + def to_patches(self, last_patch): + stitch_lists = legacy_fill(self.shape, + self.angle, + self.row_spacing, + self.end_row_spacing, + self.max_stitch_length, + self.flip, + self.staggers) + return [Patch(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists] diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py new file mode 100644 index 00000000..6ded9fd1 --- /dev/null +++ b/lib/elements/polyline.py @@ -0,0 +1,72 @@ +from .. import _, Point +from .element import param, EmbroideryElement, Patch +from ..utils import cache + + +class Polyline(EmbroideryElement): + # Handle a element, which is treated as a set of points to + # stitch exactly. + # + # elements are pretty rare in SVG, from what I can tell. + # Anything you can do with a can also be done with a

, and + # much more. + # + # Notably, EmbroiderModder2 uses elements when converting from + # common machine embroidery file formats to SVG. Handling those here lets + # users use File -> Import to pull in existing designs they may have + # obtained, for example purchased fonts. + + @property + def points(self): + # example: "1,2 0,0 1.5,3 4,2" + + points = self.node.get('points') + points = points.split(" ") + points = [[float(coord) for coord in point.split(",")] for point in points] + + return 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 + # svg transforms that is in our superclass, we'll convert the polyline + # to a degenerate cubic superpath in which the bezier handles are on + # the segment endpoints. + + path = [[[point[:], point[:], point[:]] for point in self.points]] + + return path + + @property + @cache + def csp(self): + csp = self.parse_path() + + return csp + + @property + def color(self): + # EmbroiderModder2 likes to use the `stroke` property directly instead + # of CSS. + return self.get_style("stroke") or self.node.get("stroke") + + @property + def stitches(self): + # For a , we'll stitch the points exactly as they exist in + # the SVG, with no stitch spacing interpolation, flattening, etc. + + # See the comments in the parent class's parse_path method for a + # description of the CSP data structure. + + stitches = [point for handle_before, point, handle_after in self.csp[0]] + + return stitches + + def to_patches(self, last_patch): + patch = Patch(color=self.color) + + for stitch in self.stitches: + patch.add_stitch(Point(*stitch)) + + return [patch] diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py new file mode 100644 index 00000000..d22f5145 --- /dev/null +++ b/lib/elements/satin_column.py @@ -0,0 +1,403 @@ +from itertools import chain, izip + +from .. import _, Point +from .element import param, EmbroideryElement, Patch +from ..utils import cache +from shapely import geometry as shgeo, ops as shops + + +class SatinColumn(EmbroideryElement): + element_name = _("Satin Column") + + def __init__(self, *args, **kwargs): + super(SatinColumn, self).__init__(*args, **kwargs) + + @property + @param('satin_column', _('Custom satin column'), type='toggle') + def satin_column(self): + return self.get_boolean_param("satin_column") + + @property + def color(self): + return self.get_style("stroke") + + @property + @param('zigzag_spacing_mm', _('Zig-zag spacing (peak-to-peak)'), unit='mm', type='float', default=0.4) + def zigzag_spacing(self): + # peak-to-peak distance between zigzags + return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01) + + @property + @param('pull_compensation_mm', _('Pull compensation'), unit='mm', type='float') + def pull_compensation(self): + # In satin stitch, the stitches have a tendency to pull together and + # narrow the entire column. We can compensate for this by stitching + # wider than we desire the column to end up. + return self.get_float_param("pull_compensation_mm", 0) + + @property + @param('contour_underlay', _('Contour underlay'), type='toggle', group=_('Contour Underlay')) + def contour_underlay(self): + # "Contour underlay" is stitching just inside the rectangular shape + # of the satin column; that is, up one side and down the other. + return self.get_boolean_param("contour_underlay") + + @property + @param('contour_underlay_stitch_length_mm', _('Stitch length'), unit='mm', group=_('Contour Underlay'), type='float', default=1.5) + def contour_underlay_stitch_length(self): + return max(self.get_float_param("contour_underlay_stitch_length_mm", 1.5), 0.01) + + @property + @param('contour_underlay_inset_mm', _('Contour underlay inset amount'), unit='mm', group=_('Contour Underlay'), type='float', default=0.4) + def contour_underlay_inset(self): + # how far inside the edge of the column to stitch the underlay + return self.get_float_param("contour_underlay_inset_mm", 0.4) + + @property + @param('center_walk_underlay', _('Center-walk underlay'), type='toggle', group=_('Center-Walk Underlay')) + def center_walk_underlay(self): + # "Center walk underlay" is stitching down and back in the centerline + # between the two sides of the satin column. + return self.get_boolean_param("center_walk_underlay") + + @property + @param('center_walk_underlay_stitch_length_mm', _('Stitch length'), unit='mm', group=_('Center-Walk Underlay'), type='float', default=1.5) + def center_walk_underlay_stitch_length(self): + return max(self.get_float_param("center_walk_underlay_stitch_length_mm", 1.5), 0.01) + + @property + @param('zigzag_underlay', _('Zig-zag underlay'), type='toggle', group=_('Zig-zag Underlay')) + def zigzag_underlay(self): + return self.get_boolean_param("zigzag_underlay") + + @property + @param('zigzag_underlay_spacing_mm', _('Zig-Zag spacing (peak-to-peak)'), unit='mm', group=_('Zig-zag Underlay'), type='float', default=3) + def zigzag_underlay_spacing(self): + return max(self.get_float_param("zigzag_underlay_spacing_mm", 3), 0.01) + + @property + @param('zigzag_underlay_inset_mm', _('Inset amount (default: half of contour underlay inset)'), unit='mm', group=_('Zig-zag Underlay'), type='float') + def zigzag_underlay_inset(self): + # how far in from the edge of the satin the points in the zigzags + # should be + + # Default to half of the contour underlay inset. That is, if we're + # doing both contour underlay and zigzag underlay, make sure the + # points of the zigzag fall outside the contour underlay but inside + # the edges of the satin column. + return self.get_float_param("zigzag_underlay_inset_mm") or self.contour_underlay_inset / 2.0 + + @property + @cache + def csp(self): + return self.parse_path() + + @property + @cache + def flattened_beziers(self): + if len(self.csp) == 2: + return self.simple_flatten_beziers() + else: + return self.flatten_beziers_with_rungs() + + + def flatten_beziers_with_rungs(self): + input_paths = [self.flatten([path]) for path in self.csp] + input_paths = [shgeo.LineString(path[0]) for path in input_paths] + + paths = input_paths[:] + paths.sort(key=lambda path: path.length, reverse=True) + + # Imagine a satin column as a curvy ladder. + # The two long paths are the "rails" of the ladder. The remainder are + # the "rungs". + rails = paths[:2] + rungs = shgeo.MultiLineString(paths[2:]) + + # The rails should stay in the order they were in the original CSP. + # (this lets the user control where the satin starts and ends) + rails.sort(key=lambda rail: input_paths.index(rail)) + + result = [] + + for rail in rails: + if not rail.is_simple: + self.fatal(_("One or more rails crosses itself, and this is not allowed. Please split into multiple satin columns.")) + + # handle null intersections here? + linestrings = shops.split(rail, rungs) + + #print >> dbg, "rails and rungs", [str(rail) for rail in rails], [str(rung) for rung in rungs] + if len(linestrings.geoms) < len(rungs.geoms) + 1: + self.fatal(_("satin column: One or more of the rungs doesn't intersect both rails.") + " " + _("Each rail should intersect both rungs once.")) + elif len(linestrings.geoms) > len(rungs.geoms) + 1: + self.fatal(_("satin column: One or more of the rungs intersects the rails more than once.") + " " + _("Each rail should intersect both rungs once.")) + + paths = [[Point(*coord) for coord in ls.coords] for ls in linestrings.geoms] + result.append(paths) + + return zip(*result) + + + def simple_flatten_beziers(self): + # Given a pair of paths made up of bezier segments, flatten + # each individual bezier segment into line segments that approximate + # the curves. Retain the divisions between beziers -- we'll use those + # later. + + paths = [] + + for path in self.csp: + # See the documentation in the parent class for parse_path() for a + # description of the format of the CSP. Each bezier is constructed + # using two neighboring 3-tuples in the list. + + flattened_path = [] + + # iterate over pairs of 3-tuples + for prev, current in zip(path[:-1], path[1:]): + flattened_segment = self.flatten([[prev, current]]) + flattened_segment = [Point(x, y) for x, y in flattened_segment[0]] + flattened_path.append(flattened_segment) + + paths.append(flattened_path) + + return zip(*paths) + + def validate_satin_column(self): + # The node should have exactly two paths with no fill. Each + # path should have the same number of points, meaning that they + # will both be made up of the same number of bezier curves. + + node_id = self.node.get("id") + + if self.get_style("fill") is not None: + self.fatal(_("satin column: object %s has a fill (but should not)") % node_id) + + if len(self.csp) == 2: + if len(self.csp[0]) != len(self.csp[1]): + self.fatal(_("satin column: object %(id)s has two paths with an unequal number of points (%(length1)d and %(length2)d)") % \ + dict(id=node_id, length1=len(self.csp[0]), length2=len(self.csp[1]))) + + def offset_points(self, pos1, pos2, offset_px): + # Expand or contract two points about their midpoint. This is + # useful for pull compensation and insetting underlay. + + distance = (pos1 - pos2).length() + + if distance < 0.0001: + # if they're the same point, we don't know which direction + # to offset in, so we have to just return the points + return pos1, pos2 + + # don't contract beyond the midpoint, or we'll start expanding + if offset_px < -distance / 2.0: + offset_px = -distance / 2.0 + + pos1 = pos1 + (pos1 - pos2).unit() * offset_px + pos2 = pos2 + (pos2 - pos1).unit() * offset_px + + return pos1, pos2 + + def walk(self, path, start_pos, start_index, distance): + # Move pixels along , which is a sequence of line + # segments defined by points. + + # is the index of the line segment in that + # we're currently on. is where along that line + # segment we are. Return a new position and index. + + # print >> dbg, "walk", start_pos, start_index, distance + + pos = start_pos + index = start_index + last_index = len(path) - 1 + distance_remaining = distance + + while True: + if index >= last_index: + return pos, index + + segment_end = path[index + 1] + segment = segment_end - pos + segment_length = segment.length() + + if segment_length > distance_remaining: + # our walk ends partway along this segment + return pos + segment.unit() * distance_remaining, index + else: + # our walk goes past the end of this segment, so advance + # one point + index += 1 + distance_remaining -= segment_length + pos = segment_end + + def walk_paths(self, spacing, offset): + # Take a bezier segment from each path in turn, and plot out an + # equal number of points on each bezier. Return the points plotted. + # The points will be contracted or expanded by offset using + # offset_points(). + + points = [[], []] + + def add_pair(pos1, pos2): + pos1, pos2 = self.offset_points(pos1, pos2, offset) + points[0].append(pos1) + points[1].append(pos2) + + # We may not be able to fit an even number of zigzags in each pair of + # beziers. We'll store the remaining bit of the beziers after handling + # each section. + remainder_path1 = [] + remainder_path2 = [] + + for segment1, segment2 in self.flattened_beziers: + subpath1 = remainder_path1 + segment1 + subpath2 = remainder_path2 + segment2 + + len1 = shgeo.LineString(subpath1).length + len2 = shgeo.LineString(subpath2).length + + # Base the number of stitches in each section on the _longest_ of + # the two beziers. Otherwise, things could get too sparse when one + # side is significantly longer (e.g. when going around a corner). + # The risk here is that we poke a hole in the fabric if we try to + # cram too many stitches on the short bezier. The user will need + # to avoid this through careful construction of paths. + # + # TODO: some commercial machine embroidery software compensates by + # pulling in some of the "inner" stitches toward the center a bit. + + # note, this rounds down using integer-division + num_points = max(len1, len2) / spacing + + spacing1 = len1 / num_points + spacing2 = len2 / num_points + + pos1 = subpath1[0] + index1 = 0 + + pos2 = subpath2[0] + index2 = 0 + + for i in xrange(int(num_points)): + add_pair(pos1, pos2) + + pos1, index1 = self.walk(subpath1, pos1, index1, spacing1) + pos2, index2 = self.walk(subpath2, pos2, index2, spacing2) + + if index1 < len(subpath1) - 1: + remainder_path1 = [pos1] + subpath1[index1 + 1:] + else: + remainder_path1 = [] + + if index2 < len(subpath2) - 1: + remainder_path2 = [pos2] + subpath2[index2 + 1:] + else: + remainder_path2 = [] + + # We're off by one in the algorithm above, so we need one more + # pair of points. We also want to add points at the very end to + # make sure we match the vectors on screen as best as possible. + # Try to avoid doing both if they're going to stack up too + # closely. + + end1 = remainder_path1[-1] + end2 = remainder_path2[-1] + + if (end1 - pos1).length() > 0.3 * spacing: + add_pair(pos1, pos2) + + add_pair(end1, end2) + + return points + + def do_contour_underlay(self): + # "contour walk" underlay: do stitches up one side and down the + # other. + forward, back = self.walk_paths(self.contour_underlay_stitch_length, + -self.contour_underlay_inset) + return Patch(color=self.color, stitches=(forward + list(reversed(back)))) + + def do_center_walk(self): + # Center walk underlay is just a running stitch down and back on the + # center line between the bezier curves. + + # Do it like contour underlay, but inset all the way to the center. + forward, back = self.walk_paths(self.center_walk_underlay_stitch_length, + -100000) + return Patch(color=self.color, stitches=(forward + list(reversed(back)))) + + def do_zigzag_underlay(self): + # zigzag underlay, usually done at a much lower density than the + # satin itself. It looks like this: + # + # \/\/\/\/\/\/\/\/\/\/| + # /\/\/\/\/\/\/\/\/\/\| + # + # In combination with the "contour walk" underlay, this is the + # "German underlay" described here: + # http://www.mrxstitch.com/underlay-what-lies-beneath-machine-embroidery/ + + patch = Patch(color=self.color) + + sides = self.walk_paths(self.zigzag_underlay_spacing / 2.0, + -self.zigzag_underlay_inset) + + # This organizes the points in each side in the order that they'll be + # visited. + sides = [sides[0][::2] + list(reversed(sides[0][1::2])), + sides[1][1::2] + list(reversed(sides[1][::2]))] + + # This fancy bit of iterable magic just repeatedly takes a point + # from each side in turn. + for point in chain.from_iterable(izip(*sides)): + patch.add_stitch(point) + + return patch + + def do_satin(self): + # satin: do a zigzag pattern, alternating between the paths. The + # zigzag looks like this to make the satin stitches look perpendicular + # to the column: + # + # /|/|/|/|/|/|/|/| + + # print >> dbg, "satin", self.zigzag_spacing, self.pull_compensation + + patch = Patch(color=self.color) + + sides = self.walk_paths(self.zigzag_spacing, self.pull_compensation) + + # Like in zigzag_underlay(): take a point from each side in turn. + for point in chain.from_iterable(izip(*sides)): + patch.add_stitch(point) + + return patch + + def to_patches(self, last_patch): + # Stitch a variable-width satin column, zig-zagging between two paths. + + # The algorithm will draw zigzags between each consecutive pair of + # beziers. The boundary points between beziers serve as "checkpoints", + # allowing the user to control how the zigzags flow around corners. + + # First, verify that we have valid paths. + self.validate_satin_column() + + patches = [] + + if self.center_walk_underlay: + patches.append(self.do_center_walk()) + + if self.contour_underlay: + patches.append(self.do_contour_underlay()) + + if self.zigzag_underlay: + # zigzag underlay comes after contour walk underlay, so that the + # zigzags sit on the contour walk underlay like rail ties on rails. + patches.append(self.do_zigzag_underlay()) + + patches.append(self.do_satin()) + + return patches diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py new file mode 100644 index 00000000..360e3744 --- /dev/null +++ b/lib/elements/stroke.py @@ -0,0 +1,160 @@ +import sys +from .. import _, Point +from .element import param, EmbroideryElement, Patch +from ..utils import cache + + +warned_about_legacy_running_stitch = False + + +class Stroke(EmbroideryElement): + element_name = "Stroke" + + @property + @param('satin_column', _('Satin stitch along paths'), type='toggle', inverse=True) + def satin_column(self): + return self.get_boolean_param("satin_column") + + @property + def color(self): + return self.get_style("stroke") + + @property + def dashed(self): + return self.get_style("stroke-dasharray") is not None + + @property + @param('running_stitch_length_mm', _('Running stitch length'), unit='mm', type='float', default=1.5) + def running_stitch_length(self): + return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01) + + @property + @param('zigzag_spacing_mm', _('Zig-zag spacing (peak-to-peak)'), unit='mm', type='float', default=0.4) + @cache + def zigzag_spacing(self): + return max(self.get_float_param("zigzag_spacing_mm", 0.4), 0.01) + + @property + @param('repeats', _('Repeats'), type='int', default="1") + def repeats(self): + return self.get_int_param("repeats", 1) + + @property + def paths(self): + path = self.parse_path() + + if self.manual_stitch_mode: + return [self.strip_control_points(subpath) for subpath in path] + else: + return self.flatten(path) + + @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') + + 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 + + if self.dashed: + return True + elif stroke_width <= 0.5 and self.get_float_param('running_stitch_length_mm', None) is not None: + # if they use a stroke width less than 0.5 AND they specifically set a running stitch + # length, then assume they intend to use the deprecated <= 0.5 method to set running + # stitch. + # + # Note that we use self.get_style("stroke_width") _not_ self.stroke_width above. We + # explicitly want the stroke width in "user units" ("document units") -- that is, what + # the user sees in inkscape's stroke settings. + # + # Also note that we don't use self.running_stitch_length_mm above. This is because we + # want to see if they set a running stitch length at all, and the property will apply + # a default value. + # + # Thsi is so tricky, and and intricate that's a major reason that we deprecated the + # 0.5 units rule. + + # Warn them the first time. + global warned_about_legacy_running_stitch + if not warned_about_legacy_running_stitch: + warned_about_legacy_running_stitch = True + print >> sys.stderr, _("Legacy running stitch setting detected!\n\nIt looks like you're using a stroke " + \ + "smaller than 0.5 units to indicate a running stitch, which is deprecated. Instead, please set " + \ + "your stroke to be dashed to indicate running stitch. Any kind of dash will work.") + + # still allow the deprecated setting to work in order to support old files + return True + else: + return False + + def stroke_points(self, emb_point_list, zigzag_spacing, stroke_width): + # TODO: use inkstitch.stitches.running_stitch + + patch = Patch(color=self.color) + p0 = emb_point_list[0] + rho = 0.0 + side = 1 + last_segment_direction = None + + 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) + + for segi in order: + p1 = emb_point_list[segi] + + # how far we have to go along segment + seg_len = (p1 - p0).length() + if (seg_len == 0): + continue + + # vector pointing along segment + along = (p1 - p0).unit() + + # vector pointing to edge of stroke width + perp = along.rotate_left() * (stroke_width * 0.5) + + 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) + + # 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 + + p0 = p1 + last_segment_direction = along + rho -= seg_len + + if (p0 - patch.stitches[-1]).length() > 0.1: + patch.add_stitch(p0) + + return patch + + def to_patches(self, last_patch): + patches = [] + + for path in self.paths: + path = [Point(x, y) for x, y in path] + 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) + else: + patch = self.stroke_points(path, self.zigzag_spacing / 2.0, stroke_width=self.stroke_width) + + patches.append(patch) + + return patches diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py new file mode 100644 index 00000000..ebdd2fc9 --- /dev/null +++ b/lib/extensions/__init__.py @@ -0,0 +1,6 @@ +from embroider import Embroider +from palettes import Palettes +from params import Params +from print_pdf import Print +from simulate import Simulate +from input import Input diff --git a/lib/extensions/base.py b/lib/extensions/base.py new file mode 100644 index 00000000..91e050eb --- /dev/null +++ b/lib/extensions/base.py @@ -0,0 +1,222 @@ +import inkex +import re +import json +from copy import deepcopy +from collections import MutableMapping +from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement +from .. import SVG_POLYLINE_TAG, SVG_GROUP_TAG, SVG_DEFS_TAG, INKSCAPE_GROUPMODE, EMBROIDERABLE_TAGS, PIXELS_PER_MM +from ..utils import cache + + +SVG_METADATA_TAG = inkex.addNS("metadata", "svg") + + +def strip_namespace(tag): + """Remove xml namespace from a tag name. + + >>> {http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}namedview + <<< namedview + """ + + match = re.match('^\{[^}]+\}(.+)$', tag) + + if match: + return match.group(1) + else: + return tag + + +class InkStitchMetadata(MutableMapping): + """Helper class to get and set inkstitch-specific metadata attributes. + + Operates on a document and acts like a dict. Setting an item adds or + updates a metadata element in the document. Getting an item retrieves + a metadata element's text contents or None if an element by that name + doesn't exist. + """ + + def __init__(self, document): + self.document = document + self.metadata = self._get_or_create_metadata() + + def _get_or_create_metadata(self): + metadata = self.document.find(SVG_METADATA_TAG) + + if metadata is None: + metadata = inkex.etree.SubElement(self.document.getroot(), SVG_METADATA_TAG) + + # move it so that it goes right after the first element, sodipodi:namedview + self.document.getroot().remove(metadata) + self.document.getroot().insert(1, metadata) + + return metadata + + # Because this class inherints from MutableMapping, all we have to do is + # implement these five methods and we get a full dict-like interface. + + def __setitem__(self, name, value): + item = self._find_item(name) + + if value: + item.text = json.dumps(value) + else: + item.getparent().remove(item) + + def _find_item(self, name): + tag = inkex.addNS(name, "inkstitch") + item = self.metadata.find(tag) + if item is None: + item = inkex.etree.SubElement(self.metadata, tag) + + return item + + def __getitem__(self, name): + item = self._find_item(name) + + try: + return json.loads(item.text) + except (ValueError, TypeError): + return None + + def __delitem__(self, name): + item = self._find_item(name) + + if item: + self.metadata.remove(item) + + def __iter__(self): + for child in self.metadata: + if child.prefix == "inkstitch": + yield strip_namespace(child.tag) + + def __len__(self): + i = 0 + for i, item in enumerate(self): + pass + + return i + 1 + + +class InkstitchExtension(inkex.Effect): + """Base class for Inkstitch extensions. Not intended for direct use.""" + + def hide_all_layers(self): + for g in self.document.getroot().findall(SVG_GROUP_TAG): + if g.get(INKSCAPE_GROUPMODE) == "layer": + g.set("style", "display:none") + + def no_elements_error(self): + if self.selected: + 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.")) + + def descendants(self, 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(self.descendants(child)) + + if node.tag in EMBROIDERABLE_TAGS: + nodes.append(node) + + return nodes + + def get_nodes(self): + """Get all XML nodes, or just those selected + + effect is an instance of a subclass of inkex.Effect. + """ + + if self.selected: + nodes = [] + for node in self.document.getroot().iter(): + if node.get("id") in self.selected: + nodes.extend(self.descendants(node)) + else: + nodes = self.descendants(self.document.getroot()) + + return nodes + + def detect_classes(self, node): + if node.tag == SVG_POLYLINE_TAG: + return [Polyline] + else: + element = EmbroideryElement(node) + + if element.get_boolean_param("satin_column"): + return [SatinColumn] + else: + classes = [] + + if element.get_style("fill"): + if element.get_boolean_param("auto_fill", True): + classes.append(AutoFill) + else: + classes.append(Fill) + + if element.get_style("stroke"): + classes.append(Stroke) + + if element.get_boolean_param("stroke_first", False): + classes.reverse() + + return classes + + + def get_elements(self): + self.elements = [] + for node in self.get_nodes(): + classes = self.detect_classes(node) + self.elements.extend(cls(node) for cls in classes) + + if self.elements: + return True + else: + self.no_elements_error() + return False + + def elements_to_patches(self, elements): + patches = [] + for element in elements: + if patches: + last_patch = patches[-1] + else: + last_patch = None + + patches.extend(element.embroider(last_patch)) + + return patches + + def get_inkstitch_metadata(self): + return InkStitchMetadata(self.document) + + def parse(self): + """Override inkex.Effect to add Ink/Stitch xml namespace""" + + # SVG parsers don't actually look for anything at this URL. They just + # care that it's unique. That defines a "namespace" of element and + # attribute names to disambiguate conflicts with element and + # attribute names other XML namespaces. + # + # Updating inkex.NSS here allows us to pass 'inkstitch' into + # inkex.addNS(). + inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' + + # call the superclass's method first + inkex.Effect.parse(self) + + # This is the only way I could find to add a namespace to an existing + # element tree at the top without getting ugly prefixes like "ns0". + inkex.etree.cleanup_namespaces(self.document, + top_nsmap=inkex.NSS, + keep_ns_prefixes=inkex.NSS.keys()) + self.original_document = deepcopy(self.document) diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py new file mode 100644 index 00000000..564e96ca --- /dev/null +++ b/lib/extensions/embroider.py @@ -0,0 +1,86 @@ +import sys +import traceback +import os + +import inkex +from .. import _, PIXELS_PER_MM, write_embroidery_file +from .base import InkstitchExtension +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan + + +class Embroider(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("--hide_layers", + action="store", type="choice", + choices=["true", "false"], + dest="hide_layers", default="true", + help="Hide all other layers when the embroidery layer is generated") + self.OptionParser.add_option("-O", "--output_format", + action="store", type="string", + dest="output_format", default="csv", + help="Output file extenstion (default: csv)") + self.OptionParser.add_option("-P", "--path", + action="store", type="string", + dest="path", default=".", + help="Directory in which to store output file") + self.OptionParser.add_option("-F", "--output-file", + action="store", type="string", + dest="output_file", + help="Output filename.") + self.OptionParser.add_option("-b", "--max-backups", + action="store", type="int", + dest="max_backups", default=5, + help="Max number of backups of output files to keep.") + self.OptionParser.usage += _("\n\nSeeing a 'no such option' message? Please restart Inkscape to fix.") + + def get_output_path(self): + 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) + output_path = os.path.join(self.options.path, csv_filename) + + def add_suffix(path, suffix): + if suffix > 0: + path = "%s.%s" % (path, suffix) + + return path + + def move_if_exists(path, suffix=0): + source = add_suffix(path, suffix) + + if suffix >= self.options.max_backups: + return + + dest = add_suffix(path, suffix + 1) + + if os.path.exists(source): + move_if_exists(path, suffix + 1) + + if os.path.exists(dest): + os.remove(dest) + + os.rename(source, dest) + + move_if_exists(output_path) + + return output_path + + def effect(self): + if not self.get_elements(): + return + + if self.options.hide_layers: + self.hide_all_layers() + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) + write_embroidery_file(self.get_output_path(), stitch_plan, self.document.getroot()) + render_stitch_plan(self.document.getroot(), stitch_plan) diff --git a/lib/extensions/input.py b/lib/extensions/input.py new file mode 100644 index 00000000..bd3db0ed --- /dev/null +++ b/lib/extensions/input.py @@ -0,0 +1,66 @@ +import os +from os.path import realpath, dirname, join as path_join +import sys + +# 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 * +from inkex import etree +import inkex +from .. import PIXELS_PER_MM, INKSCAPE_LABEL, _ +from ..stitch_plan import StitchPlan +from ..svg import render_stitch_plan + + +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) + + 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, + stop=stitch.flags & STOP, + trim=stitch.flags & TRIM) + + extents = stitch_plan.extents + svg = etree.Element("svg", nsmap=inkex.NSS, attrib= + { + "width": str(extents[0] * 2), + "height": str(extents[1] * 2), + "viewBox": "0 0 %s %s" % (extents[0] * 2, extents[1] * 2), + }) + render_stitch_plan(svg, stitch_plan) + + # rename the Stitch Plan layer so that it doesn't get overwritten by Embroider + layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + layer.set(INKSCAPE_LABEL, os.path.basename(embroidery_file)) + layer.attrib.pop('id') + + # Shift the design so that its origin is at the center of the canvas + # Note: this is NOT the same as centering the design in the canvas! + layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1])) + + print etree.tostring(svg) diff --git a/lib/extensions/palettes.py b/lib/extensions/palettes.py new file mode 100644 index 00000000..269dc6dc --- /dev/null +++ b/lib/extensions/palettes.py @@ -0,0 +1,111 @@ +import sys +import traceback +import os +from os.path import realpath, dirname +from glob import glob +from threading import Thread +import socket +import errno +import time +import logging +import wx +import inkex +from ..utils import guess_inkscape_config_path + + +class InstallPalettesFrame(wx.Frame): + def __init__(self, *args, **kwargs): + wx.Frame.__init__(self, *args, **kwargs) + + default_path = os.path.join(guess_inkscape_config_path(), "palettes") + + 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) + + 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) + + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + install_button = wx.Button(panel, wx.ID_ANY, _("Install")) + install_button.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK)) + 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) + + outer_sizer = wx.BoxSizer(wx.HORIZONTAL) + outer_sizer.Add(sizer, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) + + panel.SetSizer(outer_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) + + def cancel_button_clicked(self, event): + self.Destroy() + + def chooser_button_clicked(self, event): + dialog = wx.DirDialog(self, _("Choose Inkscape palettes directory")) + if dialog.ShowModal() != wx.ID_CANCEL: + self.path_input.SetValue(dialog.GetPath()) + + def install_button_clicked(self, event): + try: + self.install_palettes() + except Exception, e: + wx.MessageDialog(self, + _('Thread palette 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.'), + _('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') + + if (sys.platform == "win32"): + # If we try to just use shutil.copy it says the operation requires elevation. + def copy_files(self, files, dest): + import winutils + + winutils.copy(files, dest) + else: + def copy_files(self, files, dest): + import shutil + + if not os.path.exists(dest): + os.makedirs(dest) + + for palette_file in files: + shutil.copy(palette_file, dest) + +class Palettes(inkex.Effect): + def effect(self): + app = wx.App() + installer_frame = InstallPalettesFrame(None, title=_("Ink/Stitch Thread Palette Installer"), size=(450, 200)) + installer_frame.Show() + app.MainLoop() diff --git a/lib/extensions/params.py b/lib/extensions/params.py new file mode 100644 index 00000000..881dab49 --- /dev/null +++ b/lib/extensions/params.py @@ -0,0 +1,756 @@ +# -*- coding: UTF-8 -*- + +import os +import sys +import json +import traceback +import time +from threading import Thread, Event +from copy import copy +import wx +from wx.lib.scrolledpanel import ScrolledPanel +from collections import defaultdict +from functools import partial +from itertools import groupby + +from .. import _ +from .base import InkstitchExtension +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 + + +def presets_path(): + try: + import appdirs + config_path = appdirs.user_config_dir('inkstitch') + except ImportError: + config_path = os.path.expanduser('~/.inkstitch') + + if not os.path.exists(config_path): + os.makedirs(config_path) + return os.path.join(config_path, 'presets.json') + + +def load_presets(): + try: + with open(presets_path(), 'r') as presets: + presets = json.load(presets) + return presets + except: + return {} + + +def save_presets(presets): + with open(presets_path(), 'w') as presets_file: + json.dump(presets, presets_file) + + +def load_preset(name): + return load_presets().get(name) + + +def save_preset(name, data): + presets = load_presets() + presets[name] = data + save_presets(presets) + + +def delete_preset(name): + presets = load_presets() + presets.pop(name, None) + save_presets(presets) + + +def confirm_dialog(parent, question, caption = 'ink/stitch'): + dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION) + result = dlg.ShowModal() == wx.ID_YES + dlg.Destroy() + return result + + +def info_dialog(parent, message, caption = 'ink/stitch'): + dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) + dlg.ShowModal() + dlg.Destroy() + + +class ParamsTab(ScrolledPanel): + def __init__(self, *args, **kwargs): + self.params = kwargs.pop('params', []) + self.name = kwargs.pop('name', None) + self.nodes = kwargs.pop('nodes') + kwargs["style"] = wx.TAB_TRAVERSAL + ScrolledPanel.__init__(self, *args, **kwargs) + self.SetupScrolling() + + self.changed_inputs = set() + self.dependent_tabs = [] + self.parent_tab = None + self.param_inputs = {} + self.paired_tab = None + self.disable_notify_pair = False + + toggles = [param for param in self.params if param.type == 'toggle'] + + if toggles: + self.toggle = toggles[0] + self.params.remove(self.toggle) + self.toggle_checkbox = wx.CheckBox(self, label=self.toggle.description) + + value = any(self.toggle.values) + if self.toggle.inverse: + value = not value + self.toggle_checkbox.SetValue(value) + + self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.update_toggle_state) + self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.changed) + + self.param_inputs[self.toggle.name] = self.toggle_checkbox + else: + self.toggle = None + + self.settings_grid = wx.FlexGridSizer(rows=0, cols=3, hgap=10, vgap=10) + self.settings_grid.AddGrowableCol(0, 1) + self.settings_grid.SetFlexibleDirection(wx.HORIZONTAL) + + self.__set_properties() + self.__do_layout() + + if self.toggle: + self.update_toggle_state() + # end wxGlade + + def pair(self, tab): + # print self.name, "paired with", tab.name + self.paired_tab = tab + self.update_description() + + def add_dependent_tab(self, tab): + self.dependent_tabs.append(tab) + self.update_description() + + def set_parent_tab(self, tab): + self.parent_tab = tab + + def is_dependent_tab(self): + return self.parent_tab is not None + + def enabled(self): + if self.toggle_checkbox: + return self.toggle_checkbox.IsChecked() + else: + return True + + def update_toggle_state(self, event=None, notify_pair=True): + enable = self.enabled() + # print self.name, "update_toggle_state", enable + for child in self.settings_grid.GetChildren(): + widget = child.GetWindow() + if widget: + child.GetWindow().Enable(enable) + + if notify_pair and self.paired_tab: + self.paired_tab.pair_changed(enable) + + for tab in self.dependent_tabs: + tab.dependent_enable(enable) + + if event: + event.Skip() + + def pair_changed(self, value): + # print self.name, "pair_changed", value + new_value = not value + + if self.enabled() != new_value: + self.set_toggle_state(not value) + self.update_toggle_state(notify_pair=False) + + if self.on_change_hook: + self.on_change_hook(self) + + def dependent_enable(self, enable): + if enable: + self.toggle_checkbox.Enable() + else: + self.set_toggle_state(False) + self.toggle_checkbox.Disable() + self.update_toggle_state() + + if self.on_change_hook: + self.on_change_hook(self) + + def set_toggle_state(self, value): + if self.toggle_checkbox: + self.toggle_checkbox.SetValue(value) + self.changed_inputs.add(self.toggle_checkbox) + + def get_values(self): + values = {} + + if self.toggle: + checked = self.enabled() + if self.toggle_checkbox in self.changed_inputs and not self.toggle.inverse: + values[self.toggle.name] = checked + + if not checked: + # Ignore params on this tab if the toggle is unchecked, + # because they're grayed out anyway. + return values + + for name, input in self.param_inputs.iteritems(): + if input in self.changed_inputs and input != self.toggle_checkbox: + values[name] = input.GetValue() + + return values + + def apply(self): + values = self.get_values() + for node in self.nodes: + # print >> sys.stderr, "apply: ", self.name, node.id, values + for name, value in values.iteritems(): + node.set_param(name, value) + + def on_change(self, callable): + self.on_change_hook = callable + + def changed(self, event): + self.changed_inputs.add(event.GetEventObject()) + event.Skip() + + if self.on_change_hook: + self.on_change_hook(self) + + def load_preset(self, preset): + preset_data = preset.get(self.name, {}) + + for name, value in preset_data.iteritems(): + if name in self.param_inputs: + self.param_inputs[name].SetValue(value) + self.changed_inputs.add(self.param_inputs[name]) + + self.update_toggle_state() + + def save_preset(self, storage): + preset = storage[self.name] = {} + for name, input in self.param_inputs.iteritems(): + preset[name] = input.GetValue() + + def update_description(self): + if len(self.nodes) == 1: + description = _("These settings will be applied to 1 object.") + else: + description = _("These settings will be applied to %d objects.") % len(self.nodes) + + if any(len(param.values) > 1 for param in self.params): + description += "\n • " + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.") + + if self.dependent_tabs: + if len(self.dependent_tabs) == 1: + description += "\n • " + _("Disabling this tab will disable the following %d tabs.") % len(self.dependent_tabs) + else: + description += "\n • " + _("Disabling this tab will disable the following tab.") + + if self.paired_tab: + description += "\n • " + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name + + self.description_text = description + + def resized(self, event): + if not hasattr(self, 'rewrap_timer'): + self.rewrap_timer = wx.Timer() + self.rewrap_timer.Bind(wx.EVT_TIMER, self.rewrap) + + # If we try to rewrap every time we get EVT_SIZE then a resize is + # extremely slow. + self.rewrap_timer.Start(50, oneShot=True) + event.Skip() + + def rewrap(self, event=None): + self.description.SetLabel(self.description_text) + self.description.Wrap(self.GetSize().x - 20) + self.description_container.Layout() + if event: + event.Skip() + + def __set_properties(self): + # begin wxGlade: SatinPane.__set_properties + # end wxGlade + pass + + def __do_layout(self): + # just to add space around the settings + box = wx.BoxSizer(wx.VERTICAL) + + summary_box = wx.StaticBox(self, wx.ID_ANY, label=_("Inkscape objects")) + sizer = wx.StaticBoxSizer(summary_box, wx.HORIZONTAL) +# sizer = wx.BoxSizer(wx.HORIZONTAL) + self.description = wx.StaticText(self) + self.update_description() + self.description.SetLabel(self.description_text) + self.description_container = box + self.Bind(wx.EVT_SIZE, self.resized) + sizer.Add(self.description, proportion=0, flag=wx.EXPAND|wx.ALL, border=5) + box.Add(sizer, proportion=0, flag=wx.ALL, border=5) + + if self.toggle: + box.Add(self.toggle_checkbox, proportion=0, flag=wx.BOTTOM, border=10) + + for param in self.params: + description = wx.StaticText(self, label=param.description) + description.SetToolTip(param.tooltip) + + self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND|wx.RIGHT, border=40) + + if param.type == 'boolean': + + if len(param.values) > 1: + input = wx.CheckBox(self, style=wx.CHK_3STATE) + input.Set3StateValue(wx.CHK_UNDETERMINED) + else: + input = wx.CheckBox(self) + if param.values: + input.SetValue(param.values[0]) + + input.Bind(wx.EVT_CHECKBOX, self.changed) + elif len(param.values) > 1: + input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(param.values), style=wx.CB_DROPDOWN) + input.Bind(wx.EVT_COMBOBOX, self.changed) + input.Bind(wx.EVT_TEXT, self.changed) + else: + value = param.values[0] if param.values else "" + input = wx.TextCtrl(self, wx.ID_ANY, value=str(value)) + input.Bind(wx.EVT_TEXT, self.changed) + + self.param_inputs[param.name] = input + + self.settings_grid.Add(input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) + self.settings_grid.Add(wx.StaticText(self, label=param.unit or ""), proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) + + box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10) + self.SetSizer(box) + + self.Layout() + +# end of class SatinPane + +class SettingsFrame(wx.Frame): + def __init__(self, *args, **kwargs): + # begin wxGlade: MyFrame.__init__ + self.tabs_factory = kwargs.pop('tabs_factory', []) + self.cancel_hook = kwargs.pop('on_cancel', None) + wx.Frame.__init__(self, None, wx.ID_ANY, + _("Embroidery Params") + ) + self.notebook = wx.Notebook(self, wx.ID_ANY) + self.tabs = self.tabs_factory(self.notebook) + + for tab in self.tabs: + tab.on_change(self.update_simulator) + + self.simulate_window = None + self.simulate_thread = None + self.simulate_refresh_needed = Event() + + wx.CallLater(1000, self.update_simulator) + + self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) + + self.preset_chooser = wx.ComboBox(self, wx.ID_ANY) + self.update_preset_list() + + self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load")) + self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_preset) + + self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add")) + self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset) + + self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite")) + self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset) + + self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete")) + self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset) + + self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) + self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) + self.Bind(wx.EVT_CLOSE, self.cancel) + + self.use_last_button = wx.Button(self, wx.ID_ANY, _("Use Last Settings")) + self.use_last_button.Bind(wx.EVT_BUTTON, self.use_last) + + self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + + self.__set_properties() + self.__do_layout() + # end wxGlade + + def update_simulator(self, tab=None): + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.clear() + + 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 + self.simulate_thread.start() + + self.simulate_refresh_needed.set() + + def simulate_worker(self): + while True: + self.simulate_refresh_needed.wait() + self.simulate_refresh_needed.clear() + self.update_patches() + + def update_patches(self): + patches = self.generate_patches() + + if patches and not self.simulate_refresh_needed.is_set(): + wx.CallAfter(self.refresh_simulator, patches) + + def refresh_simulator(self, patches): + stitch_plan = patches_to_stitch_plan(patches) + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.load(stitch_plan=stitch_plan) + else: + my_rect = self.GetRect() + simulator_pos = my_rect.GetTopRight() + simulator_pos.x += 5 + + screen_rect = wx.Display(0).ClientArea + max_width = screen_rect.GetWidth() - my_rect.GetWidth() + max_height = screen_rect.GetHeight() + + try: + self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"), + simulator_pos, + size=(300, 300), + stitch_plan=stitch_plan, + on_close=self.simulate_window_closed, + target_duration=5, + max_width=max_width, + max_height=max_height) + except: + error = traceback.format_exc() + + try: + # a window may have been created, so we need to destroy it + # or the app will never exit + wx.Window.FindWindowByName("Preview").Destroy() + except: + pass + + info_dialog(self, error, _("Internal Error")) + + self.simulate_window.Show() + wx.CallLater(10, self.Raise) + + wx.CallAfter(self.simulate_window.go) + + def simulate_window_closed(self): + self.simulate_window = None + + def generate_patches(self): + patches = [] + nodes = [] + + for tab in self.tabs: + tab.apply() + + if tab.enabled() and not tab.is_dependent_tab(): + nodes.extend(tab.nodes) + + # sort nodes into the proper stacking order + nodes.sort(key=lambda node: node.order) + + try: + for node in nodes: + if self.simulate_refresh_needed.is_set(): + # cancel; params were updated and we need to start over + return [] + + # Making a copy of the embroidery element is an easy + # way to drop the cache in the @cache decorators used + # for many params in embroider.py. + + patches.extend(copy(node).embroider(None)) + except SystemExit: + raise + except: + # Ignore errors. This can be things like incorrect paths for + # satins or division by zero caused by incorrect param values. + pass + + return patches + + def update_preset_list(self): + preset_names = load_presets().keys() + preset_names = [preset for preset in preset_names if preset != "__LAST__"] + self.preset_chooser.SetItems(sorted(preset_names)) + + def get_preset_name(self): + preset_name = self.preset_chooser.GetValue().strip() + if preset_name: + return preset_name + else: + info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset')) + return + + def check_and_load_preset(self, preset_name): + preset = load_preset(preset_name) + if not preset: + info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset')) + + return preset + + def get_preset_data(self): + preset = {} + + current_tab = self.tabs[self.notebook.GetSelection()] + while current_tab.parent_tab: + current_tab = current_tab.parent_tab + + tabs = [current_tab] + if current_tab.paired_tab: + tabs.append(current_tab.paired_tab) + tabs.extend(current_tab.paired_tab.dependent_tabs) + tabs.extend(current_tab.dependent_tabs) + + for tab in tabs: + tab.save_preset(preset) + + return preset + + def add_preset(self, event, overwrite=False): + preset_name = self.get_preset_name() + if not preset_name: + return + + if not overwrite and load_preset(preset_name): + info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset')) + + save_preset(preset_name, self.get_preset_data()) + self.update_preset_list() + + event.Skip() + + def overwrite_preset(self, event): + self.add_preset(event, overwrite=True) + + + def _load_preset(self, preset_name): + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + for tab in self.tabs: + tab.load_preset(preset) + + + def load_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + self._load_preset(preset_name) + + event.Skip() + + + def delete_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + delete_preset(preset_name) + self.update_preset_list() + self.preset_chooser.SetValue("") + + event.Skip() + + def _apply(self): + for tab in self.tabs: + tab.apply() + + def apply(self, event): + self._apply() + save_preset("__LAST__", self.get_preset_data()) + self.close() + + def use_last(self, event): + self._load_preset("__LAST__") + self.apply(event) + + def close(self): + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.Close() + + self.Destroy() + + def cancel(self, event): + if self.cancel_hook: + self.cancel_hook() + + self.close() + + def __set_properties(self): + # begin wxGlade: MyFrame.__set_properties + self.notebook.SetMinSize((800, 600)) + self.preset_chooser.SetSelection(-1) + # end wxGlade + + def __do_layout(self): + # begin wxGlade: MyFrame.__do_layout + sizer_1 = wx.BoxSizer(wx.VERTICAL) + #self.sizer_3_staticbox.Lower() + sizer_2 = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL) + sizer_3 = wx.BoxSizer(wx.HORIZONTAL) + for tab in self.tabs: + self.notebook.AddPage(tab, tab.name) + sizer_1.Add(self.notebook, 1, wx.EXPAND|wx.LEFT|wx.TOP|wx.RIGHT, 10) + sizer_2.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_1.Add(sizer_2, 0, flag=wx.EXPAND|wx.ALL, border=10) + sizer_3.Add(self.cancel_button, 0, wx.ALIGN_RIGHT|wx.RIGHT, 5) + sizer_3.Add(self.use_last_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) + sizer_3.Add(self.apply_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) + sizer_1.Add(sizer_3, 0, wx.ALIGN_RIGHT, 0) + self.SetSizer(sizer_1) + sizer_1.Fit(self) + self.Layout() + # end wxGlade + +class Params(InkstitchExtension): + def __init__(self, *args, **kwargs): + self.cancelled = False + InkstitchExtension.__init__(self, *args, **kwargs) + + def embroidery_classes(self, node): + element = EmbroideryElement(node) + classes = [] + + if element.get_style("fill"): + classes.append(AutoFill) + classes.append(Fill) + + if element.get_style("stroke"): + classes.append(Stroke) + + if element.get_style("stroke-dasharray") is None: + classes.append(SatinColumn) + + return classes + + def get_nodes_by_class(self): + nodes = self.get_nodes() + nodes_by_class = defaultdict(list) + + for z, node in enumerate(nodes): + for cls in self.embroidery_classes(node): + element = cls(node) + element.order = z + nodes_by_class[cls].append(element) + + return sorted(nodes_by_class.items(), key=lambda (cls, nodes): cls.__name__) + + def get_values(self, param, nodes): + getter = 'get_param' + + if param.type in ('toggle', 'boolean'): + getter = 'get_boolean_param' + else: + getter = 'get_param' + + values = filter(lambda item: item is not None, + (getattr(node, getter)(param.name, str(param.default)) for node in nodes)) + + return values + + def group_params(self, params): + def by_group_and_sort_index(param): + return param.group, param.sort_index + + def by_group(param): + return param.group + + return groupby(sorted(params, key=by_group_and_sort_index), by_group) + + def create_tabs(self, parent): + tabs = [] + for cls, nodes in self.get_nodes_by_class(): + params = cls.get_params() + + for param in params: + param.values = list(set(self.get_values(param, nodes))) + + parent_tab = None + new_tabs = [] + for group, params in self.group_params(params): + tab = ParamsTab(parent, id=wx.ID_ANY, name=group or cls.element_name, params=list(params), nodes=nodes) + new_tabs.append(tab) + + if group is None: + parent_tab = tab + + for tab in new_tabs: + if tab != parent_tab: + parent_tab.add_dependent_tab(tab) + tab.set_parent_tab(parent_tab) + + tabs.extend(new_tabs) + + for tab in tabs: + if tab.toggle and tab.toggle.inverse: + for other_tab in tabs: + if other_tab != tab and other_tab.toggle.name == tab.toggle.name: + tab.pair(other_tab) + other_tab.pair(tab) + + def tab_sort_key(tab): + parent = tab.parent_tab or tab + + sort_key = ( + # For Stroke and SatinColumn, place the one that's + # enabled first. Place dependent tabs first too. + parent.toggle and parent.toggle_checkbox.IsChecked(), + + # If multiple tabs are enabled, make sure dependent + # tabs are grouped with the parent. + parent, + + # Within parent/dependents, put the parent first. + tab == parent + ) + + return sort_key + + tabs.sort(key=tab_sort_key, reverse=True) + + return tabs + + + def cancel(self): + 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) diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py new file mode 100644 index 00000000..5d462c0f --- /dev/null +++ b/lib/extensions/print_pdf.py @@ -0,0 +1,391 @@ +import sys +import traceback +import os +from threading import Thread +import socket +import errno +import time +import logging +from copy import deepcopy +import wx +import appdirs +import json + +import inkex +from .. import _, PIXELS_PER_MM, SVG_GROUP_TAG, translation as inkstitch_translation +from .base import InkstitchExtension +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan +from ..threads import ThreadCatalog + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from datetime import date +import base64 + +from flask import Flask, request, Response, send_from_directory, jsonify +import webbrowser +import requests + + +def datetimeformat(value, format='%Y/%m/%d'): + return value.strftime(format) + + +def defaults_path(): + defaults_dir = appdirs.user_config_dir('inkstitch') + + if not os.path.exists(defaults_dir): + os.makedirs(defaults_dir) + + return os.path.join(defaults_dir, 'print_settings.json') + + +def load_defaults(): + try: + with open(defaults_path(), 'r') as defaults_file: + defaults = json.load(defaults_file) + return defaults + except: + return {} + + +def save_defaults(defaults): + with open(defaults_path(), 'w') as defaults_file: + json.dump(defaults, defaults_file) + + +def open_url(url): + # Avoid spurious output from xdg-open. Any output on stdout will crash + # inkscape. + null = open(os.devnull, 'w') + old_stdout = os.dup(sys.stdout.fileno()) + os.dup2(null.fileno(), sys.stdout.fileno()) + + if getattr(sys, 'frozen', False): + + # PyInstaller sets LD_LIBRARY_PATH. We need to temporarily clear it + # to avoid confusing xdg-open, which webbrowser will run. + + # The following code is adapted from PyInstaller's documentation + # http://pyinstaller.readthedocs.io/en/stable/runtime-information.html + + old_environ = dict(os.environ) # make a copy of the environment + lp_key = 'LD_LIBRARY_PATH' # for Linux and *BSD. + lp_orig = os.environ.get(lp_key + '_ORIG') # pyinstaller >= 20160820 has this + if lp_orig is not None: + os.environ[lp_key] = lp_orig # restore the original, unmodified value + else: + os.environ.pop(lp_key, None) # last resort: remove the env var + + webbrowser.open(url) + + # restore the old environ + os.environ.clear() + os.environ.update(old_environ) + else: + webbrowser.open(url) + + # restore file descriptors + os.dup2(old_stdout, sys.stdout.fileno()) + os.close(old_stdout) + + +class PrintPreviewServer(Thread): + def __init__(self, *args, **kwargs): + self.html = kwargs.pop('html') + self.metadata = kwargs.pop('metadata') + self.stitch_plan = kwargs.pop('stitch_plan') + Thread.__init__(self, *args, **kwargs) + self.daemon = True + self.last_request_time = None + self.shutting_down = False + + self.__setup_app() + + def __set_resources_path(self): + if getattr(sys, 'frozen', False): + self.resources_path = os.path.join(sys._MEIPASS, 'print', 'resources') + else: + self.resources_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', 'print', 'resources')) + + def __setup_app(self): + self.__set_resources_path() + self.app = Flask(__name__) + + @self.app.before_request + def request_started(): + self.last_request_time = time.time() + + @self.app.before_first_request + def start_watcher(): + self.watcher_thread = Thread(target=self.watch) + self.watcher_thread.daemon = True + self.watcher_thread.start() + + @self.app.route('/') + def index(): + return self.html + + @self.app.route('/shutdown', methods=['POST']) + def shutdown(): + self.shutting_down = True + request.environ.get('werkzeug.server.shutdown')() + return _('Closing...') + '

' + _('It is safe to close this window now.') + + @self.app.route('/resources/', methods=['GET']) + def resources(resource): + return send_from_directory(self.resources_path, resource, cache_timeout=1) + + @self.app.route('/ping') + def ping(): + # Javascript is letting us know it's still there. This resets self.last_request_time. + return "pong" + + @self.app.route('/printing/start') + def printing_start(): + # temporarily turn off the watcher while the print dialog is up, + # because javascript will be frozen + self.last_request_time = None + return "OK" + + @self.app.route('/printing/end') + def printing_end(): + # nothing to do here -- request_started() will restart the watcher + return "OK" + + @self.app.route('/settings/', methods=['POST']) + def set_field(field_name): + self.metadata[field_name] = request.json['value'] + return "OK" + + @self.app.route('/settings/', methods=['GET']) + def get_field(field_name): + return jsonify(self.metadata[field_name]) + + @self.app.route('/settings', methods=['GET']) + def get_settings(): + settings = {} + settings.update(load_defaults()) + settings.update(self.metadata) + return jsonify(settings) + + @self.app.route('/defaults', methods=['POST']) + def set_defaults(): + save_defaults(request.json['value']) + return "OK" + + @self.app.route('/palette', methods=['POST']) + def set_palette(): + name = request.json['name'] + catalog = ThreadCatalog() + palette = catalog.get_palette_by_name(name) + catalog.apply_palette(self.stitch_plan, palette) + + # clear any saved color or thread names + for field in self.metadata: + if field.startswith('color-') or field.startswith('thread-'): + del self.metadata[field] + + self.metadata['thread-palette'] = name + + return "OK" + + @self.app.route('/threads', methods=['GET']) + def get_threads(): + threads = [] + for color_block in self.stitch_plan: + threads.append({ + 'hex': color_block.color.hex_digits, + 'name': color_block.color.name, + 'manufacturer': color_block.color.manufacturer, + 'number': color_block.color.number, + }) + + return jsonify(threads) + + def stop(self): + # for whatever reason, shutting down only seems possible in + # the context of a flask request, so we'll just make one + requests.post("http://%s:%s/shutdown" % (self.host, self.port)) + + def watch(self): + try: + while True: + time.sleep(1) + if self.shutting_down: + break + + if self.last_request_time is not None and \ + (time.time() - self.last_request_time) > 3: + self.stop() + break + except: + # seems like sometimes this thread blows up during shutdown + pass + + def disable_logging(self): + logging.getLogger('werkzeug').setLevel(logging.ERROR) + + def run(self): + self.disable_logging() + + self.host = "127.0.0.1" + self.port = 5000 + + while True: + try: + self.app.run(self.host, self.port, threaded=True) + except socket.error, e: + if e.errno == errno.EADDRINUSE: + self.port += 1 + continue + else: + raise + else: + break + + +class PrintInfoFrame(wx.Frame): + def __init__(self, *args, **kwargs): + self.print_server = kwargs.pop("print_server") + wx.Frame.__init__(self, *args, **kwargs) + + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + + text = wx.StaticText(panel, label=_("A print preview has been opened in your web browser. This window will stay open in order to communicate with the JavaScript code running in your browser.\n\nThis window will close after you close the print preview in your browser, or you can close it manually if necessary.")) + font = wx.Font(14, wx.DEFAULT, wx.NORMAL, wx.NORMAL) + text.SetFont(font) + sizer.Add(text, proportion=1, flag=wx.ALL|wx.EXPAND, border=20) + + stop_button = wx.Button(panel, id=wx.ID_CLOSE) + stop_button.Bind(wx.EVT_BUTTON, self.close_button_clicked) + sizer.Add(stop_button, proportion=0, flag=wx.ALIGN_CENTER|wx.ALL, border=10) + + panel.SetSizer(sizer) + panel.Layout() + + self.timer = wx.PyTimer(self.__watcher) + self.timer.Start(250) + + def close_button_clicked(self, event): + self.print_server.stop() + + def __watcher(self): + if not self.print_server.is_alive(): + self.timer.Stop() + self.timer = None + self.Destroy() + + +class Print(InkstitchExtension): + def build_environment(self): + if getattr( sys, 'frozen', False ) : + template_dir = os.path.join(sys._MEIPASS, "print", "templates") + else: + template_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..", "print", "templates")) + + env = Environment( + loader = FileSystemLoader(template_dir), + autoescape=select_autoescape(['html', 'xml']), + extensions=['jinja2.ext.i18n'] + ) + + env.filters['datetimeformat'] = datetimeformat + env.install_gettext_translations(inkstitch_translation) + + return env + + def strip_namespaces(self): + # namespace prefixes seem to trip up HTML, so get rid of them + for element in self.document.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 = {} + + 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() + + # 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']") + stitch_plan_layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + + # First, delete all of the other layers. We don't need them and they'll + # just bulk up the SVG. + for layer in layers: + if layer is not stitch_plan_layer: + svg.remove(layer) + + overview_svg = inkex.etree.tostring(self.document) + + color_block_groups = stitch_plan_layer.getchildren() + + for i, group in enumerate(color_block_groups): + # clear the stitch plan layer + del stitch_plan_layer[:] + + # add in just this group + stitch_plan_layer.append(group) + + # save an SVG preview + stitch_plan.color_blocks[i].svg_preview = inkex.etree.tostring(self.document) + + env = self.build_environment() + template = env.get_template('index.html') + + html = template.render( + view = {'client_overview': False, 'client_detailedview': False, 'operator_overview': True, 'operator_detailedview': True}, + logo = {'src' : '', 'title' : 'LOGO'}, + date = date.today(), + client = "", + job = { + 'title': '', + 'num_colors': stitch_plan.num_colors, + 'num_color_blocks': len(stitch_plan), + 'num_stops': stitch_plan.num_stops, + 'num_trims': stitch_plan.num_trims, + 'dimensions': stitch_plan.dimensions_mm, + 'num_stitches': stitch_plan.num_stitches, + 'estimated_time': '', # TODO + 'estimated_thread': '', # TODO + }, + svg_overview = overview_svg, + color_blocks = stitch_plan.color_blocks, + palettes = ThreadCatalog().palette_names(), + selected_palette = palette.name, + ) + + # We've totally mucked with the SVG. Restore it so that we can save + # metadata into it. + self.document = deepcopy(self.original_document) + + print_server = PrintPreviewServer(html=html, metadata=self.get_inkstitch_metadata(), stitch_plan=stitch_plan) + print_server.start() + + time.sleep(1) + open_url("http://%s:%s/" % (print_server.host, print_server.port)) + + app = wx.App() + info_frame = PrintInfoFrame(None, title=_("Ink/Stitch Print"), size=(450, 350), print_server=print_server) + info_frame.Show() + app.MainLoop() diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py new file mode 100644 index 00000000..75bc62c7 --- /dev/null +++ b/lib/extensions/simulate.py @@ -0,0 +1,27 @@ +import wx + +from .base import InkstitchExtension +from ..simulator import EmbroiderySimulator +from ..stitch_plan import patches_to_stitch_plan + + +class Simulate(InkstitchExtension): + def __init__(self): + InkstitchExtension.__init__(self) + self.OptionParser.add_option("-P", "--path", + action="store", type="string", + dest="path", default=".", + help="Directory in which to store output file") + + def effect(self): + if not self.get_elements(): + return + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches) + app = wx.App() + frame = EmbroiderySimulator(None, -1, _("Embroidery Simulation"), wx.DefaultPosition, size=(1000, 1000), stitch_plan=stitch_plan) + app.SetTopWindow(frame) + frame.Show() + wx.CallAfter(frame.go) + app.MainLoop() diff --git a/lib/simulator.py b/lib/simulator.py new file mode 100644 index 00000000..cc9442ea --- /dev/null +++ b/lib/simulator.py @@ -0,0 +1,252 @@ +import sys +import numpy +import wx +import colorsys +from itertools import izip + +from . import PIXELS_PER_MM +from .svg import color_block_to_point_lists + + +class EmbroiderySimulator(wx.Frame): + def __init__(self, *args, **kwargs): + stitch_plan = kwargs.pop('stitch_plan', None) + self.on_close_hook = kwargs.pop('on_close', None) + self.frame_period = kwargs.pop('frame_period', 80) + self.stitches_per_frame = kwargs.pop('stitches_per_frame', 1) + self.target_duration = kwargs.pop('target_duration', None) + + self.margin = 10 + + screen_rect = wx.Display(0).ClientArea + self.max_width = kwargs.pop('max_width', screen_rect.GetWidth()) + self.max_height = kwargs.pop('max_height', screen_rect.GetHeight()) + self.scale = 1 + + wx.Frame.__init__(self, *args, **kwargs) + + self.panel = wx.Panel(self, wx.ID_ANY) + self.panel.SetFocus() + + self.load(stitch_plan) + + if self.target_duration: + self.adjust_speed(self.target_duration) + + self.buffer = wx.Bitmap(self.width * self.scale + self.margin * 2, self.height * self.scale + self.margin * 2) + self.dc = wx.MemoryDC() + self.dc.SelectObject(self.buffer) + self.canvas = wx.GraphicsContext.Create(self.dc) + + self.clear() + + self.Bind(wx.EVT_SIZE, self.on_size) + self.panel.Bind(wx.EVT_PAINT, self.on_paint) + self.panel.Bind(wx.EVT_KEY_DOWN, self.on_key_down) + + self.timer = None + + self.last_pos = None + + self.Bind(wx.EVT_CLOSE, self.on_close) + + def load(self, stitch_plan=None): + if stitch_plan: + self.mirror = False + self.segments = self._stitch_plan_to_segments(stitch_plan) + else: + return + + self.trim_margins() + self.calculate_dimensions() + + def adjust_speed(self, duration): + self.frame_period = 1000 * float(duration) / len(self.segments) + self.stitches_per_frame = 1 + + while self.frame_period < 1.0: + self.frame_period *= 2 + self.stitches_per_frame *= 2 + + def on_key_down(self, event): + keycode = event.GetKeyCode() + + if keycode == ord("+") or keycode == ord("=") or keycode == wx.WXK_UP: + if self.frame_period == 1: + self.stitches_per_frame *= 2 + else: + self.frame_period = self.frame_period / 2 + elif keycode == ord("-") or keycode == ord("_") or keycode == wx.WXK_DOWN: + if self.stitches_per_frame == 1: + self.frame_period *= 2 + else: + self.stitches_per_frame /= 2 + elif keycode == ord("Q"): + self.Close() + elif keycode == ord('P'): + if self.timer.IsRunning(): + self.timer.Stop() + else: + self.timer.Start(self.frame_period) + elif keycode == ord("R"): + self.stop() + self.clear() + self.go() + + self.frame_period = max(1, self.frame_period) + self.stitches_per_frame = max(self.stitches_per_frame, 1) + + if self.timer.IsRunning(): + self.timer.Stop() + self.timer.Start(self.frame_period) + + def _strip_quotes(self, string): + if string.startswith('"') and string.endswith('"'): + string = string[1:-1] + + return string + + def color_to_pen(self, color): + return wx.Pen(color.visible_on_white.rgb) + + def _stitch_plan_to_segments(self, stitch_plan): + segments = [] + + for color_block in stitch_plan: + pen = self.color_to_pen(color_block.color) + + for point_list in color_block_to_point_lists(color_block): + # if there's only one point, there's nothing to do, so skip + if len(point_list) < 2: + continue + + for start, end in izip(point_list[:-1], point_list[1:]): + segments.append(((start, end), pen)) + + return segments + + def all_coordinates(self): + for segment in self.segments: + start, end = segment[0] + + yield start + yield end + + def trim_margins(self): + """remove any unnecessary whitespace around the design""" + + min_x = sys.maxint + min_y = sys.maxint + + for x, y in self.all_coordinates(): + min_x = min(min_x, x) + min_y = min(min_y, y) + + new_segments = [] + + for segment in self.segments: + (start, end), color = segment + + new_segment = ( + ( + (start[0] - min_x, start[1] - min_y), + (end[0] - min_x, end[1] - min_y), + ), + color + ) + + new_segments.append(new_segment) + + self.segments = new_segments + + def calculate_dimensions(self): + # 0.01 avoids a division by zero below for designs with no width or + # height (e.g. a straight vertical or horizontal line) + width = 0.01 + height = 0.01 + + for x, y in self.all_coordinates(): + width = max(width, x) + height = max(height, y) + + self.width = width + self.height = height + self.scale = min(float(self.max_width) / width, float(self.max_height) / height) + + # make room for decorations and the margin + self.scale *= 0.95 + + def go(self): + self.clear() + + self.current_stitch = 0 + + if not self.timer: + self.timer = wx.PyTimer(self.draw_one_frame) + + self.timer.Start(self.frame_period) + + def on_close(self, event): + self.stop() + + if self.on_close_hook: + self.on_close_hook() + + # If we keep a reference here, wx crashes when the process exits. + self.canvas = None + + self.Destroy() + + def stop(self): + if self.timer: + self.timer.Stop() + + def clear(self): + self.dc.SetBackground(wx.Brush('white')) + self.dc.Clear() + self.last_pos = None + self.Refresh() + + def on_size(self, e): + # ensure that the whole canvas is visible + window_width, window_height = self.GetSize() + client_width, client_height = self.GetClientSize() + + decorations_width = window_width - client_width + decorations_height = window_height - client_height + + self.SetSize((self.width * self.scale + decorations_width + self.margin * 2, + self.height * self.scale + decorations_height + self.margin * 2)) + + e.Skip() + + def on_paint(self, e): + dc = wx.PaintDC(self.panel) + dc.Blit(0, 0, self.buffer.GetWidth(), self.buffer.GetHeight(), self.dc, 0, 0) + + if self.last_pos: + dc.DrawLine(self.last_pos[0] - 10, self.last_pos[1], self.last_pos[0] + 10, self.last_pos[1]) + dc.DrawLine(self.last_pos[0], self.last_pos[1] - 10, self.last_pos[0], self.last_pos[1] + 10) + + def draw_one_frame(self): + for i in xrange(self.stitches_per_frame): + try: + ((x1, y1), (x2, y2)), color = self.segments[self.current_stitch] + + if self.mirror: + y1 = self.height - y1 + y2 = self.height - y2 + + x1 = x1 * self.scale + self.margin + y1 = y1 * self.scale + self.margin + x2 = x2 * self.scale + self.margin + y2 = y2 * self.scale + self.margin + + self.canvas.SetPen(color) + self.canvas.DrawLines(((x1, y1), (x2, y2))) + self.Refresh() + + self.current_stitch += 1 + self.last_pos = (x2, y2) + except IndexError: + self.timer.Stop() diff --git a/lib/stitch_plan/__init__.py b/lib/stitch_plan/__init__.py new file mode 100644 index 00000000..6c1f418a --- /dev/null +++ b/lib/stitch_plan/__init__.py @@ -0,0 +1 @@ +from stitch_plan import patches_to_stitch_plan, StitchPlan, ColorBlock diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py new file mode 100644 index 00000000..fab87876 --- /dev/null +++ b/lib/stitch_plan/stitch_plan.py @@ -0,0 +1,227 @@ +from .. import Stitch, PIXELS_PER_MM +from ..utils.geometry import Point +from .stop import process_stop +from .trim import process_trim +from .ties import add_ties +from ..threads import ThreadColor + + +def patches_to_stitch_plan(patches, collapse_len=3.0 * PIXELS_PER_MM): + """Convert a collection of inkstitch.element.Patch objects to a StitchPlan. + + * applies instructions embedded in the Patch such as trim_after and stop_after + * adds tie-ins and tie-offs + * adds jump-stitches between patches if necessary + """ + + stitch_plan = StitchPlan() + color_block = stitch_plan.new_color_block() + + need_trim = False + for patch in patches: + if not patch.stitches: + continue + + if need_trim: + process_trim(color_block, patch.stitches[0]) + need_trim = False + + 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: + # 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 + color_block.add_stitch(color_block.last_stitch.x, color_block.last_stitch.y, stop=True) + color_block = stitch_plan.new_color_block() + 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 + + if patch.stop_after: + process_stop(color_block) + + add_ties(stitch_plan) + + return stitch_plan + + +class StitchPlan(object): + """Holds a set of color blocks, each containing stitches.""" + + def __init__(self): + self.color_blocks = [] + + def new_color_block(self, *args, **kwargs): + color_block = ColorBlock(*args, **kwargs) + self.color_blocks.append(color_block) + return color_block + + def __iter__(self): + return iter(self.color_blocks) + + def __len__(self): + return len(self.color_blocks) + + def __repr__(self): + return "StitchPlan(%s)" % ", ".join(repr(cb) for cb in self.color_blocks) + + @property + def num_colors(self): + """Number of unique colors in the stitch plan.""" + return len({block.color for block in self}) + + @property + def num_stops(self): + return sum(block.num_stops for block in self) + + @property + def num_trims(self): + return sum(block.num_trims for block in self) + + @property + def num_stitches(self): + return sum(block.num_stitches for block in self) + + @property + def bounding_box(self): + color_block_bounding_boxes = [cb.bounding_box for cb in self] + minx = min(bb[0] for bb in color_block_bounding_boxes) + miny = min(bb[1] for bb in color_block_bounding_boxes) + maxx = max(bb[2] for bb in color_block_bounding_boxes) + maxy = max(bb[3] for bb in color_block_bounding_boxes) + + return minx, miny, maxx, maxy + + @property + def dimensions(self): + minx, miny, maxx, maxy = self.bounding_box + return (maxx - minx, maxy - miny) + + @property + def extents(self): + minx, miny, maxx, maxy = self.bounding_box + + return max(-minx, maxx), max(-miny, maxy) + + @property + def dimensions_mm(self): + dimensions = self.dimensions + return (dimensions[0] / PIXELS_PER_MM, dimensions[1] / PIXELS_PER_MM) + + +class ColorBlock(object): + """Holds a set of stitches, all with the same thread color.""" + + def __init__(self, color=None, stitches=None): + self.color = color + self.stitches = stitches or [] + + def __iter__(self): + return iter(self.stitches) + + def __repr__(self): + return "ColorBlock(%s, %s)" % (self.color, self.stitches) + + def has_color(self): + return self._color is not None + + @property + def color(self): + return self._color + + @color.setter + def color(self, value): + if isinstance(value, ThreadColor): + self._color = value + elif value is None: + self._color = None + else: + self._color = ThreadColor(value) + + @property + def last_stitch(self): + if self.stitches: + return self.stitches[-1] + else: + return None + + @property + def num_stitches(self): + """Number of stitches in this color block.""" + 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) + + def filter_duplicate_stitches(self): + if not self.stitches: + return + + 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 + pass + else: + l = (stitch - stitches[-1]).length() + if l <= 0.1: + # duplicate stitch, skip this one + continue + + stitches.append(stitch) + + self.stitches = stitches + + def add_stitch(self, *args, **kwargs): + 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: + self.stitches.append(Stitch(*args, **kwargs)) + + def add_stitches(self, stitches, *args, **kwargs): + for stitch in stitches: + if isinstance(stitch, (Stitch, Point)): + self.add_stitch(stitch, *args, **kwargs) + else: + self.add_stitch(*(list(stitch) + args), **kwargs) + + def replace_stitches(self, stitches): + self.stitches = stitches + + @property + def bounding_box(self): + minx = min(stitch.x for stitch in self) + miny = min(stitch.y for stitch in self) + maxx = max(stitch.x for stitch in self) + maxy = max(stitch.y for stitch in self) + + return minx, miny, maxx, maxy diff --git a/lib/stitch_plan/stop.py b/lib/stitch_plan/stop.py new file mode 100644 index 00000000..c5e9f7e4 --- /dev/null +++ b/lib/stitch_plan/stop.py @@ -0,0 +1,27 @@ +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. + + On such 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 introduce an artificial color change + shortly before the current stitch so that the user can set that + to C00. We'll go back 3 stitches and do that: + """ + + if len(color_block.stitches) >= 3: + color_block.stitches[-3].stop = True + + # and also add a color change on this stitch, completing the C00 + # block: + + color_block.stitches[-1].stop = True + + # 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 new file mode 100644 index 00000000..1207ea51 --- /dev/null +++ b/lib/stitch_plan/ties.py @@ -0,0 +1,51 @@ +from ..utils import cut_path +from ..stitches import running_stitch +from .. import Stitch +from copy import deepcopy + +def add_tie(stitches, tie_path): + if stitches[-1].no_ties: + # It's from a manual stitch block, so don't add tie stitches. The user + # will add them if they want them. + return + + tie_path = cut_path(tie_path, 0.6) + tie_stitches = running_stitch(tie_path, 0.3) + tie_stitches = [Stitch(stitch.x, stitch.y) for stitch in tie_stitches] + + stitches.extend(deepcopy(tie_stitches[1:])) + stitches.extend(deepcopy(list(reversed(tie_stitches))[1:])) + + +def add_tie_off(stitches): + add_tie(stitches, list(reversed(stitches))) + + +def add_tie_in(stitches, upcoming_stitches): + add_tie(stitches, upcoming_stitches) + + +def add_ties(stitch_plan): + """Add tie-off before and after trims, jumps, and color changes.""" + + for color_block in stitch_plan: + need_tie_in = True + new_stitches = [] + for i, stitch in enumerate(color_block.stitches): + is_special = stitch.trim or stitch.jump or stitch.stop + + if is_special and not need_tie_in: + add_tie_off(new_stitches) + new_stitches.append(stitch) + need_tie_in = True + elif need_tie_in and not is_special: + new_stitches.append(stitch) + add_tie_in(new_stitches, upcoming_stitches=color_block.stitches[i:]) + need_tie_in = False + else: + new_stitches.append(stitch) + + if not need_tie_in: + add_tie_off(new_stitches) + + color_block.replace_stitches(new_stitches) diff --git a/lib/stitch_plan/trim.py b/lib/stitch_plan/trim.py new file mode 100644 index 00000000..f692a179 --- /dev/null +++ b/lib/stitch_plan/trim.py @@ -0,0 +1,23 @@ +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/__init__.py b/lib/stitches/__init__.py new file mode 100644 index 00000000..d2ff0446 --- /dev/null +++ b/lib/stitches/__init__.py @@ -0,0 +1,3 @@ +from running_stitch import * +from auto_fill import auto_fill +from fill import legacy_fill diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py new file mode 100644 index 00000000..7f265909 --- /dev/null +++ b/lib/stitches/auto_fill.py @@ -0,0 +1,447 @@ +from fill import intersect_region_with_grating, row_num, stitch_row +from .. import _, PIXELS_PER_MM, Point as InkstitchPoint +import sys +import shapely +import networkx +import math +from itertools import groupby +from collections import deque + + +class MaxQueueLengthExceeded(Exception): + pass + + +def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_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)) + + stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers)) + + return stitches + + +def which_outline(shape, coords): + """return the index of the outline on which the point resides + + Index 0 is the outer boundary of the fill region. 1+ are the + outlines of the holes. + """ + + # I'd use an intersection check, but floating point errors make it + # fail sometimes. + + point = shapely.geometry.Point(*coords) + outlines = enumerate(list(shape.boundary)) + closest = min(outlines, key=lambda (index, outline): outline.distance(point)) + + return closest[0] + + +def project(shape, coords, outline_index): + """project the point onto the specified outline + + This returns the distance along the outline at which the point resides. + """ + + outline = list(shape.boundary)[outline_index] + return outline.project(shapely.geometry.Point(*coords)) + + +def build_graph(shape, segments, angle, row_spacing): + """build a graph representation of the grating segments + + This function builds a specialized graph (as in graph theory) that will + help us determine a stitching path. The idea comes from this paper: + + http://www.sciencedirect.com/science/article/pii/S0925772100000158 + + The goal is to build a graph that we know must have an Eulerian Path. + An Eulerian Path is a path from edge to edge in the graph that visits + every edge exactly once and ends at the node it started at. Algorithms + exist to build such a path, and we'll use Hierholzer's algorithm. + + A graph must have an Eulerian Path if every node in the graph has an + even number of edges touching it. Our goal here is to build a graph + that will have this property. + + Based on the paper linked above, we'll build the graph as follows: + + * nodes are the endpoints of the grating segments, where they meet + with the outer outline of the region the outlines of the interior + holes in the region. + * edges are: + * each section of the outer and inner outlines of the region, + between nodes + * double every other edge in the outer and inner hole outlines + + Doubling up on some of the edges seems as if it will just mean we have + to stitch those spots twice. This may be true, but it also ensures + that every node has 4 edges touching it, ensuring that a valid stitch + path must exist. + """ + + graph = networkx.MultiGraph() + + # First, add the grating segments as edges. We'll use the coordinates + # of the endpoints as nodes, which networkx will add automatically. + for segment in segments: + # networkx allows us to label nodes with arbitrary data. We'll + # mark this one as a grating segment. + graph.add_edge(*segment, key="segment") + + for node in graph.nodes(): + outline_index = which_outline(shape, node) + outline_projection = project(shape, node, outline_index) + + # Tag each node with its index and projection. + graph.add_node(node, index=outline_index, projection=outline_projection) + + nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] + nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection'])) + + for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']): + nodes = [ node for node, data in nodes ] + + # heuristic: change the order I visit the nodes in the outline if necessary. + # If the start and endpoints are in the same row, I can't tell which row + # I should treat it as being in. + for i in xrange(len(nodes)): + row0 = row_num(InkstitchPoint(*nodes[0]), angle, row_spacing) + row1 = row_num(InkstitchPoint(*nodes[1]), angle, row_spacing) + + if row0 == row1: + nodes = nodes[1:] + [nodes[0]] + else: + break + + # heuristic: it's useful to try to keep the duplicated edges in the same rows. + # this prevents the BFS from having to search a ton of edges. + min_row_num = min(row0, row1) + if min_row_num % 2 == 0: + edge_set = 0 + 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") + + # duplicate every other edge around this outline + if i % 2 == edge_set: + graph.add_edge(node1, node2, key="extra") + + + if not networkx.is_eulerian(graph): + raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected.")) + + return graph + + +def node_list_to_edge_list(node_list): + return zip(node_list[:-1], node_list[1:]) + + +def bfs_for_loop(graph, starting_node, max_queue_length=2000): + to_search = deque() + to_search.appendleft(([starting_node], set(), 0)) + + while to_search: + if len(to_search) > max_queue_length: + raise MaxQueueLengthExceeded() + + path, visited_edges, visited_segments = to_search.pop() + ending_node = path[-1] + + # get a list of neighbors paired with the key of the edge I can follow to get there + neighbors = [ + (node, key) + for node, adj in graph.adj[ending_node].iteritems() + for key in adj + ] + + # heuristic: try grating segments first + neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True) + + for next_node, key in neighbors: + # skip if I've already followed this edge + edge = (tuple(sorted((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 + + 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 + + new_visited_edges = visited_edges.copy() + new_visited_edges.add(edge) + + to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) + + +def find_loop(graph, starting_nodes): + """find a loop in the graph that is connected to the existing path + + Start at a candidate node and search through edges to find a path + back to that node. We'll use a breadth-first search (BFS) in order to + find the shortest available loop. + + In most cases, the BFS should not need to search far to find a loop. + The queue should stay relatively short. + + An added heuristic will be used: if the BFS queue's length becomes + too long, we'll abort and try a different starting point. Due to + the way we've set up the graph, there's bound to be a better choice + 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 + + 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 + # constructed from the starting_node (because the + # necessary edges have already been consumed). In that + # 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. + retry.append(starting_node) + + # Darn, couldn't find a loop. Try harder. + starting_nodes.extendleft(retry) + max_queue_length *= 2 + + starting_nodes.extendleft(retry) + return loop + + +def insert_loop(path, loop): + """insert a sub-loop into an existing path + + The path will be a series of edges describing a path through the graph + that ends where it starts. The loop will be similar, and its starting + point will be somewhere along the path. + + Insert the loop into the path, resulting in a longer path. + + Both the path and the loop will be a list of edges specified as a + 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)) + + path will be modified in place. + """ + + loop_start = loop[0][0] + + for i, (start, end) in enumerate(path): + if start == loop_start: + break + + path[i:i] = loop + + +def find_stitch_path(graph, segments): + """find a path that visits every grating segment exactly once + + Theoretically, we just need to find an Eulerian Path in the graph. + However, we don't actually care whether every single edge is visited. + 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. + + 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 + mowing a lawn. + + To do this, we'll use a simple heuristic: try to start from nodes in + the order of most-recently-visited first. + """ + + original_graph = graph + graph = graph.copy() + num_segments = len(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]))] + + graph.remove_edges_from(path) + + segments_visited += 1 + nodes_visited.extend(segments[0]) + + while segments_visited < num_segments: + result = find_loop(graph, nodes_visited) + + if not result: + 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] + 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() + + return path + + +def collapse_sequential_outline_edges(graph, path): + """collapse sequential edges that fall on the same outline + + When the path follows multiple edges along the outline of the region, + replace those edges with the starting and ending points. We'll use + these to stitch along the outline later on. + """ + + start_of_run = None + new_path = [] + + for edge in path: + if graph.has_edge(*edge, key="segment"): + if start_of_run: + # close off the last run + new_path.append((start_of_run, edge[0])) + start_of_run = None + + new_path.append(edge) + else: + if not start_of_run: + start_of_run = edge[0] + + if start_of_run: + # if we were still in a run, close it off + new_path.append((start_of_run, edge[1])) + + return new_path + + +def outline_distance(outline, p1, p2): + # how far around the outline (and in what direction) do I need to go + # to get from p1 to p2? + + p1_projection = outline.project(shapely.geometry.Point(p1)) + p2_projection = outline.project(shapely.geometry.Point(p2)) + + distance = p2_projection - p1_projection + + if abs(distance) > outline.length / 2.0: + # if we'd have to go more than halfway around, it's faster to go + # the other way + if distance < 0: + return distance + outline.length + elif distance > 0: + return distance - outline.length + else: + # this ought not happen, but just for completeness, return 0 if + # p1 and p0 are the same point + return 0 + else: + return distance + + +def connect_points(shape, start, end, running_stitch_length): + outline_index = which_outline(shape, start) + outline = shape.boundary[outline_index] + + pos = outline.project(shapely.geometry.Point(start)) + distance = outline_distance(outline, start, end) + num_stitches = abs(int(distance / 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): + pos = (pos + one_stitch) % outline.length + + stitches.append(InkstitchPoint(*outline.interpolate(pos).coords[0])) + + end = InkstitchPoint(*end) + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + #print >> dbg, "end connect_points" + #dbg.flush() + + return stitches + + +def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers): + path = collapse_sequential_outline_edges(graph, path) + + stitches = [] + + for edge in path: + if graph.has_edge(*edge, key="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)) + + return stitches diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py new file mode 100644 index 00000000..1b7377b0 --- /dev/null +++ b/lib/stitches/fill.py @@ -0,0 +1,245 @@ +from .. import PIXELS_PER_MM +from ..utils import cache, Point as InkstitchPoint +import shapely +import math +import sys + + +def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): + rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) + groups_of_segments = pull_runs(rows_of_segments, shape, row_spacing) + + return [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers) + for group in groups_of_segments] + + +@cache +def east(angle): + # "east" is the name of the direction that is to the right along a row + return InkstitchPoint(1, 0).rotate(-angle) + + +@cache +def north(angle): + return east(angle).rotate(math.pi / 2) + + +def row_num(point, angle, row_spacing): + return round((point * north(angle)) / row_spacing) + + +def adjust_stagger(stitch, angle, row_spacing, max_stitch_length, staggers): + this_row_num = row_num(stitch, angle, row_spacing) + row_stagger = this_row_num % staggers + stagger_offset = (float(row_stagger) / staggers) * max_stitch_length + offset = ((stitch * east(angle)) - stagger_offset) % max_stitch_length + + return stitch - offset * east(angle) + +def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers): + # We want our stitches to look like this: + # + # ---*-----------*----------- + # ------*-----------*-------- + # ---------*-----------*----- + # ------------*-----------*-- + # ---*-----------*----------- + # + # Each successive row of stitches will be staggered, with + # num_staggers rows before the pattern repeats. A value of + # 4 gives a nice fill while hiding the needle holes. The + # first row is offset 0%, the second 25%, the third 50%, and + # the fourth 75%. + # + # Actually, instead of just starting at an offset of 0, we + # can calculate a row's offset relative to the origin. This + # way if we have two abutting fill regions, they'll perfectly + # tile with each other. That's important because we often get + # abutting fill regions from pull_runs(). + + beg = InkstitchPoint(*beg) + end = InkstitchPoint(*end) + + row_direction = (end - beg).unit() + segment_length = (end - beg).length() + + # only stitch the first point if it's a reasonable distance away from the + # last stitch + if not stitches or (beg - stitches[-1]).length() > 0.5 * PIXELS_PER_MM: + stitches.append(beg) + + first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) + + # we might have chosen our first stitch just outside this row, so move back in + if (first_stitch - beg) * row_direction < 0: + first_stitch += row_direction * max_stitch_length + + offset = (first_stitch - beg).length() + + while offset < segment_length: + stitches.append(beg + offset * row_direction) + offset += max_stitch_length + + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + +def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=None, flip=False): + # the max line length I'll need to intersect the whole shape is the diagonal + (minx, miny, maxx, maxy) = shape.bounds + upper_left = InkstitchPoint(minx, miny) + lower_right = InkstitchPoint(maxx, maxy) + length = (upper_left - lower_right).length() + half_length = length / 2.0 + + # Now get a unit vector rotated to the requested angle. I use -angle + # because shapely rotates clockwise, but my geometry textbooks taught + # me to consider angles as counter-clockwise from the X axis. + direction = InkstitchPoint(1, 0).rotate(-angle) + + # and get a normal vector + normal = direction.rotate(math.pi / 2) + + # I'll start from the center, move in the normal direction some amount, + # and then walk left and right half_length in each direction to create + # a line segment in the grating. + center = InkstitchPoint((minx + maxx) / 2.0, (miny + maxy) / 2.0) + + # I need to figure out how far I need to go along the normal to get to + # the edge of the shape. To do that, I'll rotate the bounding box + # angle degrees clockwise and ask for the new bounding box. The max + # and min y tell me how far to go. + + _, start, _, end = shapely.affinity.rotate(shape, angle, origin='center', use_radians=True).bounds + + # convert start and end to be relative to center (simplifies things later) + start -= center.y + end -= center.y + + height = abs(end - start) + + #print >> dbg, "grating:", start, end, height, row_spacing, end_row_spacing + + # offset start slightly so that rows are always an even multiple of + # row_spacing_px from the origin. This makes it so that abutting + # fill regions at the same angle and spacing always line up nicely. + start -= (start + normal * center) % row_spacing + + rows = [] + + current_row_y = start + + while current_row_y < end: + p0 = center + normal * current_row_y + direction * half_length + p1 = center + normal * current_row_y - direction * half_length + endpoints = [p0.as_tuple(), p1.as_tuple()] + grating_line = shapely.geometry.LineString(endpoints) + + res = grating_line.intersection(shape) + + if (isinstance(res, shapely.geometry.MultiLineString)): + runs = map(lambda line_string: line_string.coords, res.geoms) + else: + if res.is_empty or len(res.coords) == 1: + # ignore if we intersected at a single point or no points + runs = [] + else: + runs = [res.coords] + + if runs: + runs.sort(key=lambda seg: (InkstitchPoint(*seg[0]) - upper_left).length()) + + if flip: + runs.reverse() + runs = map(lambda run: tuple(reversed(run)), runs) + + rows.append(runs) + + if end_row_spacing: + current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) + else: + current_row_y += row_spacing + + return rows + +def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers): + stitches = [] + first_segment = True + swap = False + last_end = None + + for segment in group_of_segments: + (beg, end) = segment + + if (swap): + (beg, end) = (end, beg) + + stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers) + + swap = not swap + + return stitches + + +def make_quadrilateral(segment1, segment2): + return shapely.geometry.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) + + +def is_same_run(segment1, segment2, shape, row_spacing): + line1 = shapely.geometry.LineString(segment1) + line2 = shapely.geometry.LineString(segment2) + + if line1.distance(line2) > row_spacing * 1.1: + return False + + quad = make_quadrilateral(segment1, segment2) + quad_area = quad.area + intersection_area = shape.intersection(quad).area + + return (intersection_area / quad_area) >= 0.9 + + +def pull_runs(rows, shape, row_spacing): + # Given a list of rows, each containing a set of line segments, + # break the area up into contiguous patches of line segments. + # + # This is done by repeatedly pulling off the first line segment in + # each row and calling that a shape. We have to be careful to make + # sure that the line segments are part of the same shape. Consider + # the letter "H", with an embroidery angle of 45 degrees. When + # we get to the bottom of the lower left leg, the next row will jump + # over to midway up the lower right leg. We want to stop there and + # start a new patch. + + # for row in rows: + # print >> sys.stderr, len(row) + + # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) + + runs = [] + count = 0 + while (len(rows) > 0): + run = [] + prev = None + + for row_num in xrange(len(rows)): + row = rows[row_num] + first, rest = row[0], row[1:] + + # TODO: only accept actually adjacent rows here + if prev is not None and not is_same_run(prev, first, shape, row_spacing): + break + + run.append(first) + prev = first + + rows[row_num] = rest + + # print >> sys.stderr, len(run) + runs.append(run) + rows = [row for row in rows if len(row) > 0] + + count += 1 + + return runs + diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py new file mode 100644 index 00000000..81124339 --- /dev/null +++ b/lib/stitches/running_stitch.py @@ -0,0 +1,66 @@ +""" 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() + + if segment_length == 0: + continue + + 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/lib/svg.py b/lib/svg.py new file mode 100644 index 00000000..0728309b --- /dev/null +++ b/lib/svg.py @@ -0,0 +1,76 @@ +import simpletransform, simplestyle, inkex +from . import _, get_viewbox_transform, cache, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG + +def color_block_to_point_lists(color_block): + point_lists = [[]] + + for stitch in color_block: + if stitch.trim: + if point_lists[-1]: + point_lists.append([]) + continue + + if not stitch.jump and not stitch.stop: + point_lists[-1].append(stitch.as_tuple()) + + return point_lists + + +@cache +def get_correction_transform(svg): + transform = get_viewbox_transform(svg) + + # we need to correct for the viewbox + transform = simpletransform.invertTransform(transform) + transform = simpletransform.formatTransform(transform) + + return transform + + +def color_block_to_paths(color_block, svg): + paths = [] + # We could emit just a single path with one subpath per point list, but + # emitting multiple paths makes it easier for the user to manipulate them. + for point_list in color_block_to_point_lists(color_block): + color = color_block.color.visible_on_white.to_hex_str() + paths.append(inkex.etree.Element( + SVG_PATH_TAG, + {'style': simplestyle.formatStyle( + {'stroke': color, + 'stroke-width': "0.4", + 'fill': 'none'}), + 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), + 'transform': get_correction_transform(svg), + 'embroider_manual_stitch': 'true', + 'embroider_trim_after': 'true', + })) + + # no need to trim at the end of a thread color + if paths: + paths[-1].attrib.pop('embroider_trim_after') + + return paths + + +def render_stitch_plan(svg, stitch_plan): + layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + if layer is None: + layer = inkex.etree.Element(SVG_GROUP_TAG, + {'id': '__inkstitch_stitch_plan__', + INKSCAPE_LABEL: _('Stitch Plan'), + INKSCAPE_GROUPMODE: 'layer'}) + else: + # delete old stitch plan + del layer[:] + + # make sure the layer is visible + layer.set('style', 'display:inline') + + for i, color_block in enumerate(stitch_plan): + group = inkex.etree.SubElement(layer, + SVG_GROUP_TAG, + {'id': '__color_block_%d__' % i, + INKSCAPE_LABEL: "color block %d" % (i + 1)}) + group.extend(color_block_to_paths(color_block, svg)) + + svg.append(layer) diff --git a/lib/threads/__init__.py b/lib/threads/__init__.py new file mode 100644 index 00000000..03cd777b --- /dev/null +++ b/lib/threads/__init__.py @@ -0,0 +1,3 @@ +from color import ThreadColor +from palette import ThreadPalette +from catalog import ThreadCatalog diff --git a/lib/threads/catalog.py b/lib/threads/catalog.py new file mode 100644 index 00000000..cebae4ff --- /dev/null +++ b/lib/threads/catalog.py @@ -0,0 +1,95 @@ +import os +from os.path import dirname, realpath +import sys +from glob import glob +from collections import Sequence +from .palette import ThreadPalette + +class _ThreadCatalog(Sequence): + """Holds a set of ThreadPalettes.""" + + def __init__(self): + self.palettes = [] + self.load_palettes(self.get_palettes_path()) + + def get_palettes_path(self): + if getattr(sys, 'frozen', None) is not None: + path = os.path.join(sys._MEIPASS, "..") + else: + path = dirname(dirname(dirname(realpath(__file__)))) + + return os.path.join(path, 'palettes') + + def load_palettes(self, path): + for palette_file in glob(os.path.join(path, '*.gpl')): + self.palettes.append(ThreadPalette(palette_file)) + + def palette_names(self): + return list(sorted(palette.name for palette in self)) + + def __getitem__(self, item): + return self.palettes[item] + + def __len__(self): + return len(self.palettes) + + def _num_exact_color_matches(self, palette, threads): + """Number of colors in stitch plan with an exact match in this palette.""" + + return sum(1 for thread in threads if thread in palette) + + def match_and_apply_palette(self, stitch_plan, palette=None): + if palette is None: + palette = self.match_palette(stitch_plan) + else: + palette = self.get_palette_by_name(palette) + + if palette is not None: + self.apply_palette(stitch_plan, palette) + + return palette + + def match_palette(self, stitch_plan): + """Figure out which color palette was used + + Scans the catalog of color palettes and chooses one that seems most + likely to be the one that the user used. A palette will only be + chosen if more tha 80% of the thread colors in the stitch plan are + exact matches for threads in the palette. + """ + + threads = [color_block.color for color_block in stitch_plan] + palettes_and_matches = [(palette, self._num_exact_color_matches(palette, threads)) + for palette in self] + palette, matches = max(palettes_and_matches, key=lambda item: item[1]) + + if matches < 0.8 * len(stitch_plan): + # if less than 80% of the colors are an exact match, + # don't use this palette + return None + else: + return palette + + def apply_palette(self, stitch_plan, palette): + for color_block in stitch_plan: + nearest = palette.nearest_color(color_block.color) + + color_block.color.name = nearest.name + color_block.color.number = nearest.number + color_block.color.manufacturer = nearest.manufacturer + + def get_palette_by_name(self, name): + for palette in self: + if palette.name == name: + return palette + +_catalog = None + +def ThreadCatalog(): + """Singleton _ThreadCatalog factory""" + + global _catalog + if _catalog is None: + _catalog = _ThreadCatalog() + + return _catalog diff --git a/lib/threads/color.py b/lib/threads/color.py new file mode 100644 index 00000000..af474127 --- /dev/null +++ b/lib/threads/color.py @@ -0,0 +1,82 @@ +import simplestyle +import re +import colorsys + + +class ThreadColor(object): + hex_str_re = re.compile('#([0-9a-z]{3}|[0-9a-z]{6})', re.I) + + def __init__(self, color, name=None, number=None, manufacturer=None): + if color is None: + self.rgb = (0, 0, 0) + elif isinstance(color, (list, tuple)): + self.rgb = tuple(color) + elif self.hex_str_re.match(color): + self.rgb = simplestyle.parseColor(color) + else: + raise ValueError("Invalid color: " + repr(color)) + + self.name = name + self.number = number + self.manufacturer = manufacturer + + def __eq__(self, other): + if isinstance(other, ThreadColor): + return self.rgb == other.rgb + else: + return self == ThreadColor(other) + + def __hash__(self): + return hash(self.rgb) + + def __ne__(self, other): + return not(self == other) + + def __repr__(self): + return "ThreadColor" + repr(self.rgb) + + def to_hex_str(self): + return "#%s" % self.hex_digits + + @property + def hex_digits(self): + return "%02X%02X%02X" % self.rgb + + @property + def rgb_normalized(self): + return tuple(channel / 255.0 for channel in self.rgb) + + @property + def font_color(self): + """Pick a color that will allow text to show up on a swatch in the printout.""" + hls = colorsys.rgb_to_hls(*self.rgb_normalized) + + # We'll use white text unless the swatch color is too light. + if hls[1] > 0.7: + return (1, 1, 1) + else: + return (254, 254, 254) + + @property + def visible_on_white(self): + """A ThreadColor similar to this one but visible on white. + + If the thread color is white, we don't want to try to draw white in the + simulation view or print white in the print-out. Choose a color that's + as close as possible to the actual thread color but is still at least + somewhat visible on a white background. + """ + + hls = list(colorsys.rgb_to_hls(*self.rgb_normalized)) + + # Capping lightness should make the color visible without changing it + # too much. + if hls[1] > 0.85: + hls[1] = 0.85 + + 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/threads/palette.py b/lib/threads/palette.py new file mode 100644 index 00000000..e1f47c7f --- /dev/null +++ b/lib/threads/palette.py @@ -0,0 +1,72 @@ +from collections import Set +from .color import ThreadColor +from colormath.color_objects import sRGBColor, LabColor +from colormath.color_conversions import convert_color +from colormath.color_diff import delta_e_cie1994 + + +def compare_thread_colors(color1, color2): + # K_L=2 indicates textiles + return delta_e_cie1994(color1, color2, K_L=2) + + +class ThreadPalette(Set): + """Holds a set of ThreadColors all from the same manufacturer.""" + + def __init__(self, palette_file): + self.threads = dict() + self.parse_palette_file(palette_file) + + def parse_palette_file(self, palette_file): + """Read a GIMP palette file and load thread colors. + + Example file: + + GIMP Palette + Name: Ink/Stitch: Metro + Columns: 4 + # RGB Value Color Name Number + 240 186 212 Sugar Pink 1624 + 237 171 194 Carnatio 1636 + + """ + + with open(palette_file) as palette: + line = palette.readline().strip() + if line.lower() != "gimp palette": + raise ValueError("Invalid gimp palette header") + + self.name = palette.readline().strip() + if self.name.lower().startswith('name: ink/stitch: '): + self.name = self.name[18:] + + columns_line = palette.readline() + headers_line = palette.readline() + + for line in palette: + fields = line.split("\t", 3) + thread_color = [int(field) for field in fields[:3]] + thread_name, thread_number = fields[3].strip().rsplit(" ", 1) + thread_name = thread_name.strip() + + thread = ThreadColor(thread_color, thread_name, thread_number, manufacturer=self.name) + self.threads[thread] = convert_color(sRGBColor(*thread_color, is_upscaled=True), LabColor) + + def __contains__(self, thread): + return thread in self.threads + + def __iter__(self): + return iter(self.threads) + + def __len__(self): + return len(self.threads) + + def nearest_color(self, color): + """Find the thread in this palette that looks the most like the specified color.""" + + if isinstance(color, ThreadColor): + color = color.rgb + + color = convert_color(sRGBColor(*color, is_upscaled=True), LabColor) + + return min(self, key=lambda thread: compare_thread_colors(self.threads[thread], color)) diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py new file mode 100644 index 00000000..ff06d4a9 --- /dev/null +++ b/lib/utils/__init__.py @@ -0,0 +1,4 @@ +from geometry import * +from cache import cache +from io import * +from inkscape import * diff --git a/lib/utils/cache.py b/lib/utils/cache.py new file mode 100644 index 00000000..38fe8f2c --- /dev/null +++ b/lib/utils/cache.py @@ -0,0 +1,8 @@ +try: + from functools import lru_cache +except ImportError: + from backports.functools_lru_cache import lru_cache + +# simplify use of lru_cache decorator +def cache(*args, **kwargs): + return lru_cache(maxsize=None)(*args, **kwargs) diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py new file mode 100644 index 00000000..61b98bcb --- /dev/null +++ b/lib/utils/geometry.py @@ -0,0 +1,102 @@ +from shapely.geometry import LineString, Point as ShapelyPoint +import math + + +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), None] + coords = list(line.coords) + for i, p in enumerate(coords): + # TODO: I think this doesn't work if the path doubles back on itself + 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 [Point(*point) for point in subpath.coords] + + +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 diff --git a/lib/utils/inkscape.py b/lib/utils/inkscape.py new file mode 100644 index 00000000..2d0298bc --- /dev/null +++ b/lib/utils/inkscape.py @@ -0,0 +1,15 @@ +from os.path import realpath, expanduser, join as path_join +import sys + +def guess_inkscape_config_path(): + if getattr(sys, 'frozen', None): + path = realpath(path_join(sys._MEIPASS, "..", "..", "..")) + if sys.platform == "win32": + import win32api + + # This expands ugly things like EXTENS~1 + path = win32api.GetLongPathName(path) + else: + path = expanduser("~/.config/inkscape") + + return path diff --git a/lib/utils/io.py b/lib/utils/io.py new file mode 100644 index 00000000..e87b9881 --- /dev/null +++ b/lib/utils/io.py @@ -0,0 +1,17 @@ +import os +import sys +from cStringIO import StringIO + +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()) + 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 -- cgit v1.2.3