diff --git a/embroider.py b/embroider.py index aba438331..3f88ed3cc 100644 --- a/embroider.py +++ b/embroider.py @@ -50,12 +50,52 @@ SVG_DEFS_TAG = inkex.addNS('defs', 'svg') SVG_GROUP_TAG = inkex.addNS('g', 'svg') -class EmbroideryElement(object): +class Param(object): + def __init__(self, name, description, unit=None, values=[], type=None, group=None, inverse=False, default=None): + 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 - def __init__(self, node, options): + 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, options=None): self.node = node self.options = options + @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() @@ -99,6 +139,9 @@ class EmbroideryElement(object): return value + def set_param(self, name, value): + self.node.set("embroider_%s" % name, value) + @cache def get_style(self, style_name): style = simplestyle.parseStyle(self.node.get("style")) @@ -184,11 +227,16 @@ class EmbroideryElement(object): class Fill(EmbroideryElement): - 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') @cache def angle(self): return math.radians(self.get_float_param('angle', 0)) @@ -198,18 +246,22 @@ class Fill(EmbroideryElement): return self.get_style("fill") @property + @param('flip', 'Flip fill (start right-to-left)', type='boolean') def flip(self): return self.get_boolean_param("flip", False) @property + @param('row_spacing_mm', 'Spacing between rows', unit='mm', type='float') def row_spacing(self): return self.get_float_param("row_spacing_mm") @property + @param('max_stitch_length_mm', 'Maximum fill stitch length', unit='mm', type='float') def max_stitch_length(self): return self.get_float_param("max_stitch_length_mm") @property + @param('staggers', 'Stagger rows this many times before repeating', type='int') def staggers(self): return self.get_int_param("staggers", 4) @@ -478,6 +530,11 @@ class Fill(EmbroideryElement): class AutoFill(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): @@ -493,14 +550,17 @@ class AutoFill(Fill): return False @property + @param('running_stitch_length_mm', 'Running stitch length (traversal between sections)', unit='mm', type='float') def running_stitch_length(self): return self.get_float_param("running_stitch_length_mm") @property + @param('fill_underlay', 'Underlay', type='toggle', group='AutoFill Underlay') def fill_underlay(self): return self.get_boolean_param("fill_underlay") @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") @@ -511,11 +571,13 @@ class AutoFill(Fill): 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) @@ -607,7 +669,7 @@ class AutoFill(Fill): return self.section_to_patch(section, angle, row_spacing, max_stitch_length) - def auto_fill(self, angle, row_spacing, max_stitch_length, starting_point=None): + def do_auto_fill(self, angle, row_spacing, max_stitch_length, starting_point=None): rows_of_segments = self.intersect_region_with_grating(angle, row_spacing) sections = self.pull_runs(rows_of_segments) @@ -637,15 +699,19 @@ class AutoFill(Fill): last_stitch = last_patch.stitches[-1] if self.fill_underlay: - patches.extend(self.auto_fill(self.fill_underlay_angle, self.fill_underlay_row_spacing, self.fill_underlay_max_stitch_length, last_stitch)) + patches.extend(self.do_auto_fill(self.fill_underlay_angle, self.fill_underlay_row_spacing, self.fill_underlay_max_stitch_length, last_stitch)) last_stitch = patches[-1].stitches[-1] - patches.extend(self.auto_fill(self.angle, self.row_spacing, self.max_stitch_length, last_stitch)) + patches.extend(self.do_auto_fill(self.angle, self.row_spacing, self.max_stitch_length, last_stitch)) return patches class Stroke(EmbroideryElement): + @property + @param('satin_column', 'Satin along paths', type='toggle', inverse=True) + def satin_column(self): + return self.get_boolean_param("satin_column") @property def color(self): @@ -666,15 +732,18 @@ class Stroke(EmbroideryElement): return self.get_style("stroke-dasharray") is not None @property + @param('running_stitch_length_mm', 'Running stitch length', unit='mm', type='float') def running_stitch_length(self): return self.get_float_param("running_stitch_length_mm") @property + @param('zigzag_spacing_mm', 'Zig-zag spacing (peak-to-peak)', unit='mm', type='float') @cache def zigzag_spacing(self): return self.get_float_param("zigzag_spacing_mm") @property + @param('repeats', 'Repeats', type='int') def repeats(self): return self.get_int_param("repeats", 1) @@ -751,25 +820,26 @@ class Stroke(EmbroideryElement): class SatinColumn(EmbroideryElement): - def __init__(self, *args, **kwargs): super(SatinColumn, self).__init__(*args, **kwargs) - self.csp = self.parse_path() - self.flattened_beziers = self.get_flattened_paths() - - # print >> dbg, "flattened beziers", self.flattened_beziers + @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') def zigzag_spacing(self): # peak-to-peak distance between zigzags return self.get_float_param("zigzag_spacing_mm") @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 @@ -777,42 +847,50 @@ class SatinColumn(EmbroideryElement): 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') def contour_underlay_stitch_length(self): # use "contour_underlay_stitch_length", or, if not set, default to "stitch_length" return self.get_float_param("contour_underlay_stitch_length_mm") or self.get_float_param("running_stitch_length_mm") @property + @param('contour_underlay_inset_mm', 'Contour underlay inset amount', unit='mm', group='Contour Underlay', type='float') 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') def center_walk_underlay_stitch_length(self): # use "center_walk_underlay_stitch_length", or, if not set, default to "stitch_length" return self.get_float_param("center_walk_underlay_stitch_length_mm") or self.get_float_param("running_stitch_length_mm") @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') def zigzag_underlay_spacing(self): # peak-to-peak distance between zigzags in zigzag underlay return self.get_float_param("zigzag_underlay_spacing_mm", 1) @property + @param('zigzag_underlay_inset', '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 @@ -823,7 +901,14 @@ class SatinColumn(EmbroideryElement): # the edges of the satin column. return self.get_float_param("zigzag_underlay_inset_mm") or self.contour_underlay_inset / 2.0 - def get_flattened_paths(self): + @property + @cache + def csp(self): + return self.parse_path() + + @property + @cache + def flattened_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 @@ -1088,8 +1173,48 @@ class SatinColumn(EmbroideryElement): return patches -class Patch: +def detect_classes(node): + 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 descendants(node): + nodes = [] + element = EmbroideryElement(node) + + if element.has_style('display') and element.get_style('display') is None: + return [] + + if node.tag == SVG_DEFS_TAG: + return [] + + for child in node: + nodes.extend(descendants(child)) + + if node.tag == SVG_PATH_TAG: + nodes.append(node) + + return nodes + +class Patch: def __init__(self, color=None, stitches=None): self.color = color self.stitches = stitches or [] @@ -1227,41 +1352,11 @@ class Embroider(inkex.Effect): def handle_node(self, node): print >> dbg, "handling node", node.get('id'), node.get('tag') - - element = EmbroideryElement(node, self.options) - - if element.has_style('display') and element.get_style('display') is None: - return - - if node.tag == SVG_DEFS_TAG: - return - - for child in node: - self.handle_node(child) - - if node.tag != SVG_PATH_TAG: - return - - # dbg.write("Node: %s\n"%str((id, etree.tostring(node, pretty_print=True)))) - - if element.get_boolean_param("satin_column"): - self.elements.append(SatinColumn(node, self.options)) - else: - elements = [] - - if element.get_style("fill"): - if element.get_boolean_param("auto_fill", True): - elements.append(AutoFill(node, self.options)) - else: - elements.append(Fill(node, self.options)) - - if element.get_style("stroke"): - elements.append(Stroke(node, self.options)) - - if element.get_boolean_param("stroke_first", False): - elements.reverse() - - self.elements.extend(elements) + nodes = descendants(node) + for node in nodes: + classes = detect_classes(node) + print >> dbg, "classes:", classes + self.elements.extend(cls(node, self.options) for cls in classes) def get_output_path(self): svg_filename = self.document.getroot().get(inkex.addNS('docname', 'sodipodi')) diff --git a/embroider.py.save b/embroider.py.save deleted file mode 100644 index c580ac46a..000000000 --- a/embroider.py.save +++ /dev/null @@ -1,1147 +0,0 @@ -#!/usr/bin/python -# -# documentation: see included index.html -# LICENSE: -# Copyright 2010 by Jon Howell, -# Originally licensed under GPLv3. -# Copyright 2015 by Bas Wijnen . -# New parts are licensed under AGPL3 or later. -# (Note that this means this work is licensed under the common part of those two: AGPL version 3.) -# -# Important resources: -# lxml interface for walking SVG tree: -# http://codespeak.net/lxml/tutorial.html#elementpath -# Inkscape library for extracting paths from SVG: -# http://wiki.inkscape.org/wiki/index.php/Python_modules_for_extensions#simplepath.py -# Shapely computational geometry library: -# http://gispython.org/shapely/manual.html#multipolygons -# Embroidery file format documentation: -# http://www.achatina.de/sewing/main/TECHNICL.HTM - -import sys -sys.path.append("/usr/share/inkscape/extensions") -import os -import subprocess -from copy import deepcopy -import time -from itertools import chain, izip -import inkex -import simplepath -import simplestyle -import simpletransform -from bezmisc import bezierlength, beziertatlength, bezierpointatt -from cspsubdiv import cspsubdiv -import cubicsuperpath -import math -import lxml.etree as etree -import shapely.geometry as shgeo -import shapely.affinity as affinity -from pprint import pformat - -import PyEmb - -dbg = open("/tmp/embroider-debug.txt", "w") -PyEmb.dbg = dbg - -SVG_PATH_TAG = inkex.addNS('path', 'svg') -SVG_DEFS_TAG = inkex.addNS('defs', 'svg') -SVG_GROUP_TAG = inkex.addNS('g', 'svg') - -class EmbroideryElement(object): - def __init__(self, node, options): - self.node = node - self.options = options - - def get_param(self, param, default): - value = self.node.get("embroider_" + param) - - if value is None or not value.strip(): - if default is None: - try: - default = getattr(self.options, "%s_mm" % param) * self.options.pixels_per_mm - except AttributeError: - default = getattr(self.options, param, None) - - return default - - return value.strip() - - 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')) - - def get_float_param(self, param, default=None): - value = self.get_param(param, default) - - try: - return float(value) - except TypeError: - return default - - - def get_int_param(self, param, default=None): - value = self.get_param(param, default) - - try: - return int(value) - except ValueError: - return default - - 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 - - def has_style(self, style_name): - style = simplestyle.parseStyle(self.node.get("style")) - return style_name in style - - 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 = cubicsuperpath.parsePath(self.node.get("d")) - - # print >> sys.stderr, pformat(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) - - # apply the combined transform to this node's path - simpletransform.applyTransformToPath(transform, path) - - return path - - def flatten(self, path): - """approximate a path containing beziers with a series of points""" - - path = deepcopy(path) - - cspsubdiv(path, self.options.flat) - - flattened = [] - - for comp in path: - vertices = [] - for ctl in comp: - vertices.append((ctl[1][0], ctl[1][1])) - flattened.append(vertices) - - return flattened - - def to_patches(self): - raise NotImplementedError("%s must implement to_path()" % self.__class__.__name__) - - def fatal(self, message): - print >> sys.stderr, "error:", message - sys.exit(1) - - -class Fill(EmbroideryElement): - def __init__(self, *args, **kwargs): - super(Fill, self).__init__(*args, **kwargs) - - self.shape = self.get_shape() - - @property - def angle(self): - return math.radians(self.get_float_param('angle', 0)) - - @property - def color(self): - return self.get_style("fill") - - @property - def flip(self): - return self.get_boolean_param("flip", False) - - @property - def row_spacing(self): - return self.get_float_param("row_spacing") - - @property - def max_stitch_length(self): - return self.get_float_param("max_stitch_length") - - @property - def staggers(self): - return self.get_int_param("staggers", 4) - - @property - def paths(self): - return self.flatten(self.parse_path()) - - def get_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 - poly_ary.append(point_ary) - - print >> dbg, poly_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 intersect_region_with_grating(self): - # the max line length I'll need to intersect the whole shape is the diagonal - (minx, miny, maxx, maxy) = self.shape.bounds - upper_left = PyEmb.Point(minx, miny) - lower_right = PyEmb.Point(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 = PyEmb.Point(1, 0).rotate(-self.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 = PyEmb.Point((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 = affinity.rotate(self.shape, self.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 - - # 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) % self.row_spacing - - rows = [] - - while start < end: - p0 = center + normal.mul(start) + direction.mul(half_length) - p1 = center + normal.mul(start) - direction.mul(half_length) - endpoints = [p0.as_tuple(), p1.as_tuple()] - grating_line = shgeo.LineString(endpoints) - - res = grating_line.intersection(self.shape) - - if (isinstance(res, shgeo.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 - start += self.row_spacing - continue - runs = [res.coords] - - runs.sort(key=lambda seg: (PyEmb.Point(*seg[0]) - upper_left).length()) - - if self.flip: - runs.reverse() - runs = map(lambda run: tuple(reversed(run)), runs) - - rows.append(runs) - - start += self.row_spacing - - return rows - - def pull_runs(self, rows): - # 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. - - # Segments more than this far apart are considered not to be part of - # the same run. - row_distance_cutoff = self.row_spacing * 1.1 - - def make_quadrilateral(segment1, segment2): - return shgeo.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) - - def is_same_run(segment1, segment2): - if shgeo.LineString(segment1).distance(shgeo.LineString(segment1)) > row_distance_cutoff: - return False - - quad = make_quadrilateral(segment1, segment2) - quad_area = quad.area - intersection_area = self.shape.intersection(quad).area - - return (intersection_area / quad_area) >= 0.9 - - # 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): - 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 - - def to_patches(self): - rows_of_segments = self.intersect_region_with_grating() - groups_of_segments = self.pull_runs(rows_of_segments) - - # "east" is the name of the direction that is to the right along a row - east = PyEmb.Point(1, 0).rotate(-self.angle) - - # print >> sys.stderr, len(groups_of_segments) - - patches = [] - for group_of_segments in groups_of_segments: - patch = Patch(color=self.color) - first_segment = True - swap = False - last_end = None - - for segment in group_of_segments: - # 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, end) = segment - - if (swap): - (beg, end) = (end, beg) - - beg = PyEmb.Point(*beg) - end = PyEmb.Point(*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 last_end is None or (beg - last_end).length() > 0.5 * self.options.pixels_per_mm: - patch.add_stitch(beg) - - # Now, imagine the coordinate axes rotated by 'angle' degrees, such that - # the rows are parallel to the X axis. We can find the coordinates in these - # axes of the beginning point in this way: - relative_beg = beg.rotate(self.angle) - - absolute_row_num = round(relative_beg.y / self.row_spacing) - row_stagger = absolute_row_num % self.staggers - row_stagger_offset = (float(row_stagger) / self.staggers) * self.max_stitch_length - - first_stitch_offset = (relative_beg.x - row_stagger_offset) % self.max_stitch_length - - first_stitch = beg - east * first_stitch_offset - - # 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 * self.max_stitch_length - - offset = (first_stitch - beg).length() - - while offset < segment_length: - patch.add_stitch(beg + offset * row_direction) - offset += self.max_stitch_length - - if (end - patch.stitches[-1]).length() > 0.1 * self.options.pixels_per_mm: - patch.add_stitch(end) - - last_end = end - swap = not swap - - patches.append(patch) - return patches - - -class Stroke(EmbroideryElement): - @property - def color(self): - return self.get_style("stroke") - - @property - def width(self): - stroke_width = self.get_style("stroke-width") - - if stroke_width.endswith("px"): - stroke_width = stroke_width[:-2] - - return float(stroke_width) - - @property - def dashed(self): - return self.get_style("stroke-dasharray") is not None - - @property - def running_stitch_length(self): - return self.get_float_param("running_stitch_length") - - @property - def zigzag_spacing(self): - return self.get_float_param("zigzag_spacing") - - @property - def repeats(self): - return self.get_int_param("repeats", 1) - - @property - def paths(self): - return self.flatten(self.parse_path()) - - def is_running_stitch(self): - # stroke width <= 0.5 pixels is deprecated in favor of dashed lines - return self.dashed or self.width <= 0.5 - - def stroke_points(self, emb_point_list, zigzag_spacing, stroke_width): - patch = Patch(color=self.color) - p0 = emb_point_list[0] - rho = 0.0 - side = 1 - 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().mul(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 = self.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 += self.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): - patches = [] - - for path in self.paths: - path = [PyEmb.Point(x, y) for x, y in path] - if 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.width) - - patches.append(patch) - - return patches - - -class SatinColumn(EmbroideryElement): - def __init__(self, *args, **kwargs): - super(SatinColumn, self).__init__(*args, **kwargs) - - self.csp = self.parse_path() - self.flattened_beziers = self.get_flattened_paths() - - @property - def color(self): - return self.get_style("stroke") - - @property - def zigzag_spacing(self): - # peak-to-peak distance between zigzags - return self.get_float_param("zigzag_spacing") - - @property - 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", 0) - - @property - 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 - def contour_underlay_stitch_length(self): - # use "contour_underlay_stitch_length", or, if not set, default to "stitch_length" - return self.get_float_param("contour_underlay_stitch_length", self.get_float_param("stitch_length")) - - @property - 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", 0.4) - - @property - 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 - def center_walk_underlay_stitch_length(self): - # use "center_walk_underlay_stitch_length", or, if not set, default to "stitch_length" - return self.get_float_param("center_walk_underlay_stitch_length", self.get_float_param("stitch_length")) - - @property - def zigzag_underlay(self): - return self.get_boolean_param("zigzag_underlay") - - @property - def zigzag_underlay_spacing(self): - # peak-to-peak distance between zigzags in zigzag underlay - return self.get_float_param("zigzag_underlay_spacing", 1) - - @property - 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", self.contour_underlay_inset / 2.0) - - def get_flattened_paths(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. - - path = [] - - # iterate over pairs of 3-tuples - for prev, current in zip(path[:-1], path[1:]): - flattened = self.flatten([prev, current]) - flattened = [PyEmb.point(x, y) for x, y in flattened] - path.append(flattened) - - paths.append(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 len(self.csp) != 2: - self.fatal("satin column: object %s invalid: expected exactly two sub-paths, but there are %s" % (node_id, len(csp))) - - 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[0]) != len(self.csp[1]): - self.fatal("satin column: object %s has two paths with an unequal number of points (%s and %s)" % (node_id, len(self.csp[0]), len(self.csp[1]))) - - def offset_points(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(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. - - pos = start_pos - index = start_index - last_index = len(path) - 1 - distance_remaining = distance - - while True: - if index >= last_index: - return pos, last_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, 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 = 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 = walk(subpath1, pos1, index1, spacing1) - pos2, index2 = 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. - - if remainder_path1: - 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 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 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_len_px, - -100000) - return Patch(color=self.color, stitches=(forward + list(reversed(back)))) - - def 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 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() - - 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): - # 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.center_walk_underlay) - - if self.contour_underlay: - patches.append(self.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.zigzag_underlay()) - - patches.append(self.satin()) - - return patches - - -class Patch: - def __init__(self, color=None, stitches=None): - self.color = color - self.stitches = stitches or [] - - 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]) - - -def patches_to_stitches(patch_list, collapse_len_px=0): - stitches = [] - - last_stitch = None - last_color = None - for patch in patch_list: - jump_stitch = True - for stitch in patch.stitches: - if last_stitch and last_color == patch.color: - l = (stitch - last_stitch).length() - if l <= 0.1: - # filter out duplicate successive stitches - jump_stitch = False - continue - - if jump_stitch: - # consider collapsing jump stitch, if it is pretty short - if l < collapse_len_px: - # dbg.write("... collapsed\n") - jump_stitch = False - - # dbg.write("stitch color %s\n" % patch.color) - - newStitch = PyEmb.Stitch(stitch.x, stitch.y, patch.color, jump_stitch) - stitches.append(newStitch) - - jump_stitch = False - last_stitch = stitch - last_color = patch.color - - return stitches - - -def stitches_to_paths(stitches): - paths = [] - last_color = None - last_stitch = None - for stitch in stitches: - if stitch.jump_stitch: - if last_color == stitch.color: - paths.append([None, []]) - if last_stitch is not None: - paths[-1][1].append(['M', last_stitch.as_tuple()]) - paths[-1][1].append(['L', stitch.as_tuple()]) - last_color = None - if stitch.color != last_color: - paths.append([stitch.color, []]) - paths[-1][1].append(['L' if len(paths[-1][1]) > 0 else 'M', stitch.as_tuple()]) - last_color = stitch.color - last_stitch = stitch - return paths - - -def emit_inkscape(parent, stitches): - for color, path in stitches_to_paths(stitches): - # dbg.write('path: %s %s\n' % (color, repr(path))) - inkex.etree.SubElement(parent, - inkex.addNS('path', 'svg'), - {'style': simplestyle.formatStyle( - {'stroke': color if color is not None else '#000000', - 'stroke-width': "0.4", - 'fill': 'none'}), - 'd': simplepath.formatPath(path), - }) - - -class Embroider(inkex.Effect): - def __init__(self, *args, **kwargs): - inkex.Effect.__init__(self) - self.OptionParser.add_option("-r", "--row_spacing_mm", - action="store", type="float", - dest="row_spacing_mm", default=0.4, - help="row spacing (mm)") - self.OptionParser.add_option("-z", "--zigzag_spacing_mm", - action="store", type="float", - dest="zigzag_spacing_mm", default=1.0, - help="zigzag spacing (mm)") - self.OptionParser.add_option("-l", "--max_stitch_len_mm", - action="store", type="float", - dest="max_stitch_length_mm", default=3.0, - help="max stitch length (mm)") - self.OptionParser.add_option("--running_stitch_len_mm", - action="store", type="float", - dest="running_stitch_length_mm", default=3.0, - help="running stitch length (mm)") - self.OptionParser.add_option("-c", "--collapse_len_mm", - action="store", type="float", - dest="collapse_length_mm", default=0.0, - help="max collapse length (mm)") - self.OptionParser.add_option("-f", "--flatness", - action="store", type="float", - dest="flat", default=0.1, - help="Minimum flatness of the subdivided curves") - 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="choice", - choices=["melco", "csv", "gcode"], - dest="output_format", default="melco", - help="File output format") - 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("-b", "--max-backups", - action="store", type="int", - dest="max_backups", default=5, - help="Max number of backups of output files to keep.") - self.OptionParser.add_option("-p", "--pixels_per_mm", - action="store", type="int", - dest="pixels_per_mm", default=10, - help="Number of on-screen pixels per millimeter.") - self.patches = [] - - def handle_node(self, node): - print >> dbg, "handling node", node.get('id'), node.get('tag') - - element = EmbroideryElement(node, self.options) - - if element.has_style('display') and element.get_style('display') is None: - return - - if node.tag == SVG_DEFS_TAG: - return - - for child in node: - self.handle_node(child) - - if node.tag != SVG_PATH_TAG: - return - - # dbg.write("Node: %s\n"%str((id, etree.tostring(node, pretty_print=True)))) - - if element.get_boolean_param("satin_column"): - self.elements.append(SatinColumn(node, self.options)) - else: - elements = [] - - if element.get_style("fill"): - elements.append(Fill(node, self.options)) - - if element.get_style("stroke"): - elements.append(Stroke(node, self.options)) - - if element.get_boolean_param("stroke_first", False): - elements.reverse() - - self.elements.extend(elements) - - def get_output_path(self): - svg_filename = self.document.getroot().get(inkex.addNS('docname', 'sodipodi')) - csv_filename = svg_filename.replace('.svg', '.csv') - 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) - os.rename(source, dest) - - move_if_exists(output_path) - - return output_path - - def hide_layers(self): - for g in self.document.getroot().findall(SVG_GROUP_TAG): - if g.get(inkex.addNS("groupmode", "inkscape")) == "layer": - g.set("style", "display:none") - - def effect(self): - # Printing anything other than a valid SVG on stdout blows inkscape up. - old_stdout = sys.stdout - sys.stdout = sys.stderr - - self.patch_list = [] - - print >> dbg, "starting nodes: %s\n" % time.time() - dbg.flush() - - self.elements = [] - - if self.selected: - # be sure to visit selected nodes in the order they're stacked in - # the document - for node in self.document.getroot().iter(): - if node.get("id") in self.selected: - self.handle_node(node) - else: - self.handle_node(self.document.getroot()) - - print >> dbg, "finished nodes: %s" % time.time() - dbg.flush() - - if not self.elements: - 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.") - return - - if self.options.hide_layers: - self.hide_layers() - - patches = chain.from_iterable(element.to_patches() for element in self.elements) - stitches = patches_to_stitches(patches, self.options.collapse_length_mm * self.options.pixels_per_mm) - emb = PyEmb.Embroidery(stitches, self.options.pixels_per_mm) - emb.export(self.get_output_path(), self.options.output_format) - - new_layer = inkex.etree.SubElement(self.document.getroot(), SVG_GROUP_TAG, {}) - new_layer.set('id', self.uniqueId("embroidery")) - new_layer.set(inkex.addNS('label', 'inkscape'), 'Embroidery') - new_layer.set(inkex.addNS('groupmode', 'inkscape'), 'layer') - - emit_inkscape(new_layer, stitches) - - sys.stdout = old_stdout - -if __name__ == '__main__': - sys.setrecursionlimit(100000) - e = Embroider() - e.affect() - dbg.flush() - -dbg.close() diff --git a/embroider_params.inx b/embroider_params.inx index 0f26749e3..f3987502e 100644 --- a/embroider_params.inx +++ b/embroider_params.inx @@ -4,28 +4,6 @@ jonh.embroider.params embroider_params.py inkex.py - - - - - - - - - - - - - - - - - - - - - - all diff --git a/embroider_params.py b/embroider_params.py index 43def8358..4c31ee5f7 100644 --- a/embroider_params.py +++ b/embroider_params.py @@ -1,59 +1,573 @@ -#!/usr/bin/python -# -# Set embroidery parameter attributes on all selected objects. If an option -# value is blank, the parameter is created as blank on all objects that don't -# already have it. If an option value is given, any existing node parameter -# values are overwritten on all selected objects. +#!/usr/bin/env python +# -*- coding: UTF-8 -*- -import sys -sys.path.append("/usr/share/inkscape/extensions") import os +import sys +import json +from cStringIO import StringIO +import wx +from wx.lib.scrolledpanel import ScrolledPanel +from collections import defaultdict import inkex +from embroider import Param, EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn, descendants +from functools import partial +from itertools import groupby -class EmbroiderParams(inkex.Effect): +def presets_path(): + try: + import appdirs + config_path = appdirs.user_config_dir('inkscape-embroidery') + except ImportError: + config_path = os.path.expanduser('~/.inkscape-embroidery') + 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 = 'inkscape-embroidery'): + 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 = 'inkscape-embroidery'): + dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) + dlg.ShowModal() + dlg.Destroy() + + +class ParamsTab(ScrolledPanel): def __init__(self, *args, **kwargs): - inkex.Effect.__init__(self) + 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.params = ["zigzag_spacing_mm", - "running_stitch_length_mm", - "row_spacing_mm", - "max_stitch_length_mm", - "repeats", - "angle", - "flip", - "satin_column", - "stroke_first", - "pull_compensation_mm", - "contour_underlay", - "contour_underlay_inset_mm", - "contour_underlay_stitch_length_mm", - "center_walk_underlay", - "center_walk_underlay_stitch_length_mm", - "zigzag_underlay", - "zigzag_underlay_inset_mm", - "fill_underlay", - "fill_underlay_angle", - "fill_underlay_row_spacing_mm", - "fill_underlay_max_stitch_length_mm", - ] + 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 update_toggle_state(self, event=None, notify_pair=True): + enable = self.toggle_checkbox.IsChecked() + # 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(self.toggle_checkbox.IsChecked()) + + 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.toggle_checkbox.IsChecked() != new_value: + self.set_toggle_state(not value) + self.toggle_checkbox.changed = True + self.update_toggle_state(notify_pair=False) + + def dependent_enable(self, enable): + if enable: + self.toggle_checkbox.Enable() + else: + self.set_toggle_state(False) + self.toggle_checkbox.Disable() + self.toggle_checkbox.changed = True + self.update_toggle_state() + + def set_toggle_state(self, value): + self.toggle_checkbox.SetValue(value) + + def get_values(self): + values = {} + + if self.toggle: + checked = self.toggle_checkbox.IsChecked() + if self.toggle_checkbox in self.changed_inputs: + 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: + values[name] = input.GetValue() + + return values + + def apply(self): + values = self.get_values() + for node in self.nodes: + print >> sys.stderr, node.id, values + for name, value in values.iteritems(): + node.set_param(name, value) + + def changed(self, event): + self.changed_inputs.add(event.GetEventObject()) + event.Skip() + + 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): + description = "These settings will be applied to %d object%s." % \ + (len(self.nodes), "s" if len(self.nodes) != 1 else "") + + 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: + description += "\n • Disabling this tab will disable the following %d tabs." % len(self.dependent_tabs) + + 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, style=wx.TE_WORDWRAP) + 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: - self.OptionParser.add_option("--%s" % param, default="") + self.settings_grid.Add(wx.StaticText(self, label=param.description), proportion=1, flag=wx.EXPAND|wx.RIGHT, border=40) + + if param.type == 'boolean': + + values = list(set(param.values)) + if len(values) > 1: + input = wx.CheckBox(self, style=wx.CHK_3STATE) + input.Set3StateValue(wx.CHK_UNDETERMINED) + elif values: + input = wx.CheckBox(self) + input.SetValue(values[0]) + + input.Bind(wx.EVT_CHECKBOX, self.changed) + elif len(param.values) > 1: + input = wx.ComboBox(self, wx.ID_ANY, choices=param.values, style=wx.CB_DROPDOWN | wx.CB_SORT) + 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=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', []) + 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) + + self.presets_box = wx.StaticBox(self, wx.ID_ANY, label="Presets") + + self.preset_chooser = wx.ComboBox(self, wx.ID_ANY, style=wx.CB_SORT) + 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.close) + + 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_preset_list(self): + self.preset_chooser.SetItems(load_presets().keys()) + + 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.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, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + for tab in self.tabs: + tab.load_preset(preset) + + 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, event): + for tab in self.tabs: + tab.apply() + + self.Close() + + def close(self, event): + self.Close() + + def __set_properties(self): + # begin wxGlade: MyFrame.__set_properties + self.SetTitle("frame_1") + self.notebook.SetMinSize((800, 400)) + 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, 0, 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.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 EmbroiderParams(inkex.Effect): + def get_nodes(self): + if self.selected: + nodes = [] + for node in self.document.getroot().iter(): + if node.get("id") in self.selected: + nodes.extend(descendants(node)) + else: + nodes = descendants(self.document.getroot()) + + return nodes + + def embroidery_classes(self, node): + element = EmbroideryElement(node) + classes = [] + + if element.get_style("fill"): + classes.append(AutoFill) + classes.append(Fill) + elif 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 node in self.get_nodes(): + for cls in self.embroidery_classes(node): + nodes_by_class[cls].append(node) + + 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' + elif param.type: + getter = 'get_%s_param' % param.type + + values = filter(lambda item: item is not None, + (getattr(node, getter)(param.name, param.default) for node in nodes)) + + if param.type in ('int', 'float'): + values = [str(value) for value in values] + + return values + + def group_params(self, params): + def by_group(param): + return param.group + + return groupby(sorted(params, key=by_group), by_group) + + def create_tabs(self, parent): + tabs = [] + for cls, nodes in self.get_nodes_by_class(): + nodes = [cls(node) for node in nodes] + params = cls.get_params() + + for param in params: + param.values = 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.__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) + + return tabs + def effect(self): - for node in self.selected.itervalues(): - for param in self.params: - value = getattr(self.options, param).strip() - param = "embroider_" + param + app = wx.App() + frame = SettingsFrame(tabs_factory=self.create_tabs) + frame.Show() + app.MainLoop() - if node.get(param) is not None and not value: - # only overwrite existing params if they gave a value - continue - else: - node.set(param, value) +# end of class MyFrame +if __name__ == "__main__": + # GTK likes to spam stderr, which inkscape will show in a dialog. + null = open('/dev/null', 'w') + stderr_dup = os.dup(sys.stderr.fileno()) + os.dup2(null.fileno(), 2) + stderr_backup = sys.stderr + sys.stderr = StringIO() -if __name__ == '__main__': e = EmbroiderParams() e.affect() + + os.dup2(stderr_dup, 2) + stderr_backup.write(sys.stderr.getvalue()) + sys.stderr = stderr_backup