diff options
Diffstat (limited to 'lib/elements')
| -rw-r--r-- | lib/elements/__init__.py | 6 | ||||
| -rw-r--r-- | lib/elements/auto_fill.py | 108 | ||||
| -rw-r--r-- | lib/elements/element.py | 254 | ||||
| -rw-r--r-- | lib/elements/fill.py | 97 | ||||
| -rw-r--r-- | lib/elements/polyline.py | 72 | ||||
| -rw-r--r-- | lib/elements/satin_column.py | 403 | ||||
| -rw-r--r-- | lib/elements/stroke.py | 160 |
7 files changed, 1100 insertions, 0 deletions
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 <polyline> element, which is treated as a set of points to + # stitch exactly. + # + # <polyline> elements are pretty rare in SVG, from what I can tell. + # Anything you can do with a <polyline> can also be done with a <p>, and + # much more. + # + # Notably, EmbroiderModder2 uses <polyline> 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 <polyline>, 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 <distance> pixels along <path>, which is a sequence of line + # segments defined by points. + + # <start_index> is the index of the line segment in <path> that + # we're currently on. <start_pos> 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 |
