diff options
Diffstat (limited to 'lib/extensions')
| -rw-r--r-- | lib/extensions/__init__.py | 2 | ||||
| -rw-r--r-- | lib/extensions/base.py | 6 | ||||
| -rw-r--r-- | lib/extensions/commands.py | 146 | ||||
| -rw-r--r-- | lib/extensions/convert_to_satin.py | 290 | ||||
| -rw-r--r-- | lib/extensions/input.py | 44 | ||||
| -rw-r--r-- | lib/extensions/install.py | 10 | ||||
| -rw-r--r-- | lib/extensions/output.py | 5 | ||||
| -rw-r--r-- | lib/extensions/params.py | 2 | ||||
| -rw-r--r-- | lib/extensions/zip.py | 24 |
9 files changed, 458 insertions, 71 deletions
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index b11ba1a4..30a08c9f 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -7,3 +7,5 @@ from input import Input from output import Output from zip import Zip from flip import Flip +from commands import Commands +from convert_to_satin import ConvertToSatin diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 78f75cf1..d230f1b0 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -58,11 +58,7 @@ class InkStitchMetadata(MutableMapping): def __setitem__(self, name, value): item = self._find_item(name) - - if value: - item.text = json.dumps(value) - else: - item.getparent().remove(item) + item.text = json.dumps(value) def _find_item(self, name, create=True): tag = inkex.addNS(name, "inkstitch") diff --git a/lib/extensions/commands.py b/lib/extensions/commands.py new file mode 100644 index 00000000..353c9874 --- /dev/null +++ b/lib/extensions/commands.py @@ -0,0 +1,146 @@ +import os +import sys +import inkex +import simpletransform +import cubicsuperpath +from copy import deepcopy +from random import random +from shapely import geometry as shgeo + +from .base import InkstitchExtension +from ..i18n import _ +from ..elements import SatinColumn +from ..utils import get_bundled_dir, cache +from ..svg.tags import SVG_DEFS_TAG, SVG_GROUP_TAG, SVG_USE_TAG, SVG_PATH_TAG, INKSCAPE_GROUPMODE, XLINK_HREF, CONNECTION_START, CONNECTION_END, CONNECTOR_TYPE +from ..svg import get_correction_transform + + +class Commands(InkstitchExtension): + COMMANDS = ["fill_start", "fill_end", "stop", "trim"] + + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self, *args, **kwargs) + for command in self.COMMANDS: + self.OptionParser.add_option("--%s" % command, type="inkbool") + + @property + def symbols_path(self): + return os.path.join(get_bundled_dir("symbols"), "inkstitch.svg") + + @property + @cache + def symbols_svg(self): + with open(self.symbols_path) as symbols_file: + return inkex.etree.parse(symbols_file) + + @property + @cache + def symbol_defs(self): + return self.symbols_svg.find(SVG_DEFS_TAG) + + @property + @cache + def defs(self): + return self.document.find(SVG_DEFS_TAG) + + def ensure_symbol(self, command): + path = "./*[@id='inkstitch_%s']" % command + if self.defs.find(path) is None: + self.defs.append(deepcopy(self.symbol_defs.find(path))) + + def add_connector(self, symbol, element): + # I'd like it if I could position the connector endpoint nicely but inkscape just + # moves it to the element's center immediately after the extension runs. + start_pos = (symbol.get('x'), symbol.get('y')) + end_pos = element.shape.centroid + + path = inkex.etree.Element(SVG_PATH_TAG, + { + "id": self.uniqueId("connector"), + "d": "M %s,%s %s,%s" % (start_pos[0], start_pos[1], end_pos.x, end_pos.y), + "style": "stroke:#000000;stroke-width:1px;stroke-opacity:0.5;fill:none;", + "transform": get_correction_transform(symbol), + CONNECTION_START: "#%s" % symbol.get('id'), + CONNECTION_END: "#%s" % element.node.get('id'), + CONNECTOR_TYPE: "polyline", + } + ) + + symbol.getparent().insert(symbol.getparent().index(symbol), path) + + def get_command_pos(self, element, index, total): + # Put command symbols 30 pixels out from the shape, spaced evenly around it. + + # get a line running 30 pixels out from the shape + outline = element.shape.buffer(30).exterior + + # pick this item's spot arond the outline and perturb it a bit to avoid + # stacking up commands if they run the extension multiple times + position = index / float(total) + position += random() * 0.1 + + return outline.interpolate(position, normalized=True) + + def remove_legacy_param(self, element, command): + if command == "trim" or command == "stop": + # If they had the old "TRIM after" or "STOP after" attributes set, + # automatically delete them. THe new commands will do the same + # thing. + # + # If we didn't delete these here, then things would get confusing. + # If the user were to delete a "trim" symbol added by this extension + # but the "embroider_trim_after" attribute is still set, then the + # trim would keep happening. + + attribute = "embroider_%s_after" % command + + if attribute in element.node.attrib: + del element.node.attrib[attribute] + + def add_command(self, element, commands): + for i, command in enumerate(commands): + self.remove_legacy_param(element, command) + + pos = self.get_command_pos(element, i, len(commands)) + + symbol = inkex.etree.SubElement(element.node.getparent(), SVG_USE_TAG, + { + "id": self.uniqueId("use"), + XLINK_HREF: "#inkstitch_%s" % command, + "height": "100%", + "width": "100%", + "x": str(pos.x), + "y": str(pos.y), + "transform": get_correction_transform(element.node) + } + ) + + self.add_connector(symbol, element) + + def effect(self): + if not self.get_elements(): + return + + if not self.selected: + inkex.errormsg(_("Please select one or more objects to which to attach commands.")) + return + + self.svg = self.document.getroot() + + commands = [command for command in self.COMMANDS if getattr(self.options, command)] + + if not commands: + inkex.errormsg(_("Please choose one or more commands to attach.")) + return + + for command in commands: + self.ensure_symbol(command) + + # Each object (node) in the SVG may correspond to multiple Elements of different + # types (e.g. stroke + fill). We only want to process each one once. + seen_nodes = set() + + for element in self.elements: + if element.node not in seen_nodes: + self.add_command(element, commands) + seen_nodes.add(element.node) diff --git a/lib/extensions/convert_to_satin.py b/lib/extensions/convert_to_satin.py new file mode 100644 index 00000000..1eae69b1 --- /dev/null +++ b/lib/extensions/convert_to_satin.py @@ -0,0 +1,290 @@ +import inkex +from shapely import geometry as shgeo +from itertools import chain, groupby +import numpy +from numpy import diff, sign, setdiff1d +from scipy.signal import argrelmin +import math +from copy import deepcopy + +from .base import InkstitchExtension +from ..svg.tags import SVG_PATH_TAG +from ..svg import get_correction_transform, PIXELS_PER_MM +from ..elements import Stroke +from ..utils import Point + + +class SelfIntersectionError(Exception): + pass + + +class ConvertToSatin(InkstitchExtension): + """Convert a line to a satin column of the same width.""" + + def effect(self): + if not self.get_elements(): + return + + if not self.selected: + inkex.errormsg(_("Please select at least one line to convert to a satin column.")) + return + + if not all(isinstance(item, Stroke) for item in self.elements): + # L10N: Convert To Satin extension, user selected one or more objects that were not lines. + inkex.errormsg(_("Only simple lines may be converted to satin columns.")) + return + + for element in self.elements: + parent = element.node.getparent() + index = parent.index(element.node) + correction_transform = get_correction_transform(element.node) + style_args = self.join_style_args(element) + + for path in element.paths: + path = self.remove_duplicate_points(path) + + if len(path) < 2: + # ignore paths with just one point -- they're not visible to the user anyway + continue + + self.fix_loop(path) + + try: + rails, rungs = self.path_to_satin(path, element.stroke_width, style_args) + except SelfIntersectionError: + inkex.errormsg(_("Cannot convert %s to a satin column because it intersects itself. Try breaking it up into multiple paths.") % element.node.get('id')) + + # revert any changes we've made + self.document = deepcopy(self.original_document) + + return + + parent.insert(index, self.satin_to_svg_node(rails, rungs, correction_transform)) + + parent.remove(element.node) + + def fix_loop(self, path): + if path[0] == path[-1]: + # Looping paths seem to confuse shapely's parallel_offset(). It loses track + # of where the start and endpoint is, even if the user explicitly breaks the + # path. I suspect this is because parallel_offset() uses buffer() under the + # hood. + # + # To work around this we'll introduce a tiny gap by nudging the starting point + # toward the next point slightly. + start = Point(*path[0]) + next = Point(*path[1]) + direction = (next - start).unit() + start += 0.01 * direction + path[0] = start.as_tuple() + + def remove_duplicate_points(self, path): + return [point for point, repeats in groupby(path)] + + def join_style_args(self, element): + """Convert svg line join style to shapely parallel offset arguments.""" + + args = { + 'join_style': shgeo.JOIN_STYLE.round + } + + element_join_style = element.get_style('stroke-linejoin') + + if element_join_style is not None: + if element_join_style == "miter": + args['join_style'] = shgeo.JOIN_STYLE.mitre + + # 4 is the default per SVG spec + miter_limit = float(element.get_style('stroke-miterlimit', 4)) + args['mitre_limit'] = miter_limit + elif element_join_style == "bevel": + args['join_style'] = shgeo.JOIN_STYLE.bevel + + return args + + def path_to_satin(self, path, stroke_width, style_args): + path = shgeo.LineString(path) + + left_rail = path.parallel_offset(stroke_width / 2.0, 'left', **style_args) + right_rail = path.parallel_offset(stroke_width / 2.0, 'right', **style_args) + + if not isinstance(left_rail, shgeo.LineString) or \ + not isinstance(right_rail, shgeo.LineString): + # If the parallel offsets come out as anything but a LineString, that means the + # path intersects itself, when taking its stroke width into consideration. See + # the last example for parallel_offset() in the Shapely documentation: + # https://shapely.readthedocs.io/en/latest/manual.html#object.parallel_offset + raise SelfIntersectionError() + + # for whatever reason, shapely returns a right-side offset's coordinates in reverse + left_rail = list(left_rail.coords) + right_rail = list(reversed(right_rail.coords)) + + rungs = self.generate_rungs(path, stroke_width) + + return (left_rail, right_rail), rungs + + def get_scores(self, path): + """Generate an array of "scores" of the sharpness of corners in a path + + A higher score means that there are sharper corners in that section of + the path. We'll divide the path into boxes, with the score in each + box indicating the sharpness of corners at around that percentage of + the way through the path. For example, if scores[40] is 100 and + scores[45] is 200, then the path has sharper corners at a spot 45% + along its length than at a spot 40% along its length. + """ + + # need 101 boxes in order to encompass percentages from 0% to 100% + scores = numpy.zeros(101, numpy.int32) + path_length = path.length + + prev_point = None + prev_direction = None + length_so_far = 0 + for point in path.coords: + point = Point(*point) + + if prev_point is None: + prev_point = point + continue + + direction = (point - prev_point).unit() + + if prev_direction is not None: + # The dot product of two vectors is |v1| * |v2| * cos(angle). + # These are unit vectors, so their magnitudes are 1. + cos_angle_between = prev_direction * direction + angle = abs(math.degrees(math.acos(cos_angle_between))) + + # Use the square of the angle, measured in degrees. + # + # Why the square? This penalizes bigger angles more than + # smaller ones. + # + # Why degrees? This is kind of arbitrary but allows us to + # use integer math effectively and avoid taking the square + # of a fraction between 0 and 1. + scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2 + + length_so_far += (point - prev_point).length() + prev_direction = direction + prev_point = point + + return scores + + def local_minima(self, array): + # from: https://stackoverflow.com/a/9667121/4249120 + # This finds spots where the curvature (second derivative) is > 0. + # + # This method has the convenient benefit of choosing points around + # 5% before and after a sharp corner such as in a square. + return (diff(sign(diff(array))) > 0).nonzero()[0] + 1 + + def generate_rungs(self, path, stroke_width): + """Create rungs for a satin column. + + Where should we put the rungs along a path? We want to ensure that the + resulting satin matches the original path as closely as possible. We + want to avoid having a ton of rungs that will annoy the user. We want + to ensure that the rungs we choose actually intersect both rails. + + We'll place a few rungs perpendicular to the tangent of the path. + Things get pretty tricky at sharp corners. If we naively place a rung + perpendicular to the path just on either side of a sharp corner, the + rung may not intersect both paths: + | | + _______________| | + ______|_ + ____________________| + + It'd be best to place rungs in the straight sections before and after + the sharp corner and allow the satin column to bend the stitches around + the corner automatically. + + How can we find those spots? + + The general algorithm below is: + + * assign a "score" to each section of the path based on how sharp its + corners are (higher means a sharper corner) + * pick spots with lower scores + """ + + scores = self.get_scores(path) + + # This is kind of like a 1-dimensional gaussian blur filter. We want to + # avoid the area near a sharp corner, so we spread out its effect for + # 5 buckets in either direction. + scores = numpy.convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same') + + # Now we'll find the spots that aren't near corners, whose scores are + # low -- the local minima. + rung_locations = self.local_minima(scores) + + # Remove the start and end, because we can't stick a rung there. + rung_locations = setdiff1d(rung_locations, [0, 100]) + + if len(rung_locations) == 0: + # Straight lines won't have local minima, so add a rung in the center. + rung_locations = [50] + + rungs = [] + last_rung_center = None + + for location in rung_locations: + # Convert percentage to a fraction so that we can use interpolate's + # normalized parameter. + location = location / 100.0 + + rung_center = path.interpolate(location, normalized=True) + rung_center = Point(rung_center.x, rung_center.y) + + # Avoid placing rungs too close together. This somewhat + # arbitrarily rejects the rung if there was one less than 2 + # millimeters before this one. + if last_rung_center is not None and \ + (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM: + continue + else: + last_rung_center = rung_center + + # We need to know the tangent of the path's curve at this point. + # Pick another point just after this one and subtract them to + # approximate a tangent vector. + tangent_end = path.interpolate(location + 0.001, normalized=True) + tangent_end = Point(tangent_end.x, tangent_end.y) + tangent = (tangent_end - rung_center).unit() + + # Rotate 90 degrees left to make a normal vector. + normal = tangent.rotate_left() + + # Travel 75% of the stroke width left and right to make the rung's + # endpoints. This means the rung's length is 150% of the stroke + # width. + offset = normal * stroke_width * 0.75 + rung_start = rung_center + offset + rung_end = rung_center - offset + + rungs.append((rung_start.as_tuple(), rung_end.as_tuple())) + + return rungs + + + def satin_to_svg_node(self, rails, rungs, correction_transform): + d = "" + for path in chain(rails, rungs): + d += "M" + for x, y in path: + d += "%s,%s " % (x, y) + d += " " + + return inkex.etree.Element(SVG_PATH_TAG, + { + "id": self.uniqueId("path"), + "style": "stroke:#000000;stroke-width:1px;fill:none", + "transform": correction_transform, + "d": d, + "embroider_satin_column": "true", + } + ) diff --git a/lib/extensions/input.py b/lib/extensions/input.py index 21248dd9..cb5ac452 100644 --- a/lib/extensions/input.py +++ b/lib/extensions/input.py @@ -3,53 +3,29 @@ from os.path import realpath, dirname, join as path_join import sys from inkex import etree import inkex - -# help python find libembroidery when running in a local repo clone -if getattr(sys, 'frozen', None) is None: - sys.path.append(realpath(path_join(dirname(__file__), '..', '..'))) - -from libembroidery import * +import pyembroidery from ..svg import PIXELS_PER_MM, render_stitch_plan from ..svg.tags import INKSCAPE_LABEL from ..i18n import _ -from ..stitch_plan import StitchPlan +from ..stitch_plan import StitchPlan, ColorBlock from ..utils.io import save_stdout class Input(object): - def pattern_stitches(self, pattern): - stitch_pointer = pattern.stitchList - while stitch_pointer: - yield stitch_pointer.stitch - stitch_pointer = stitch_pointer.next - - def affect(self, args): - # libembroidery likes to dump a bunch of debugging stuff to stdout - save_stdout() - embroidery_file = args[0] - pattern = embPattern_create() - embPattern_read(pattern, embroidery_file) - embPattern_flipVertical(pattern) + pattern = pyembroidery.read(embroidery_file) stitch_plan = StitchPlan() color_block = None - current_color = None - - for stitch in self.pattern_stitches(pattern): - if stitch.color != current_color: - thread = embThreadList_getAt(pattern.threadList, stitch.color) - color = thread.color - color_block = stitch_plan.new_color_block((color.r, color.g, color.b)) - current_color = stitch.color - if not stitch.flags & END: - color_block.add_stitch(stitch.xx * PIXELS_PER_MM, stitch.yy * PIXELS_PER_MM, - jump=stitch.flags & JUMP, - color_change=stitch.flags & STOP, - trim=stitch.flags & TRIM) + for raw_stitches, thread in pattern.get_as_colorblocks(): + color_block = stitch_plan.new_color_block(thread) + for x, y, command in raw_stitches: + color_block.add_stitch(x * PIXELS_PER_MM / 10.0, y * PIXELS_PER_MM / 10.0, + jump=(command == pyembroidery.JUMP), + trim=(command == pyembroidery.TRIM)) extents = stitch_plan.extents svg = etree.Element("svg", nsmap=inkex.NSS, attrib= @@ -69,4 +45,4 @@ class Input(object): # Note: this is NOT the same as centering the design in the canvas! layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1])) - print >> sys.real_stdout, etree.tostring(svg) + print etree.tostring(svg) diff --git a/lib/extensions/install.py b/lib/extensions/install.py index d55b96d0..42a92113 100644 --- a/lib/extensions/install.py +++ b/lib/extensions/install.py @@ -13,7 +13,7 @@ import logging import wx import inkex -from ..utils import guess_inkscape_config_path +from ..utils import guess_inkscape_config_path, get_bundled_dir class InstallerFrame(wx.Frame): @@ -78,15 +78,9 @@ class InstallerFrame(wx.Frame): def install_addons(self, type): path = os.path.join(self.path, type) - src_dir = self.get_bundled_dir(type) + src_dir = get_bundled_dir(type) self.copy_files(glob(os.path.join(src_dir, "*")), path) - def get_bundled_dir(self, name): - if getattr(sys, 'frozen', None) is not None: - return realpath(os.path.join(sys._MEIPASS, '..', name)) - else: - return realpath(os.path.join(dirname(realpath(__file__)), '..', '..', name)) - if (sys.platform == "win32"): # If we try to just use shutil.copy it says the operation requires elevation. def copy_files(self, files, dest): diff --git a/lib/extensions/output.py b/lib/extensions/output.py index f4b153e6..1dc8d19d 100644 --- a/lib/extensions/output.py +++ b/lib/extensions/output.py @@ -29,20 +29,17 @@ class Output(InkstitchExtension): patches = self.elements_to_patches(self.elements) stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) - # libembroidery wants to write to an actual file rather than stdout temp_file = tempfile.NamedTemporaryFile(suffix=".%s" % self.options.file_extension, delete=False) # in windows, failure to close here will keep the file locked temp_file.close() - # libembroidery likes to debug log things to stdout. No way to disable it. - save_stdout() write_embroidery_file(temp_file.name, stitch_plan, self.document.getroot()) # inkscape will read the file contents from stdout and copy # to the destination file that the user chose with open(temp_file.name) as output_file: - sys.real_stdout.write(output_file.read()) + sys.stdout.write(output_file.read()) # clean up the temp file os.remove(temp_file.name) diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 58fedd6b..1b8f2589 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -424,7 +424,7 @@ class SettingsFrame(wx.Frame): self.simulate_window.stop() self.simulate_window.load(stitch_plan=stitch_plan) else: - my_rect = self.GetRect() + my_rect = self.GetScreenRect() simulator_pos = my_rect.GetTopRight() simulator_pos.x += 5 diff --git a/lib/extensions/zip.py b/lib/extensions/zip.py index ca12efdd..02f29e8a 100644 --- a/lib/extensions/zip.py +++ b/lib/extensions/zip.py @@ -4,7 +4,7 @@ import os import inkex import tempfile from zipfile import ZipFile -from libembroidery import * +import pyembroidery from .base import InkstitchExtension from ..i18n import _ @@ -24,18 +24,11 @@ class Zip(InkstitchExtension): # it's kind of obnoxious that I have to do this... self.formats = [] - formatList = embFormatList_create() - curFormat = formatList - while(curFormat): - # extension includes the dot, so we'll remove it - extension = embFormat_extension(curFormat)[1:] - description = embFormat_description(curFormat) - writer_state = embFormat_writerState(curFormat) - - if writer_state.strip() and embFormat_type(curFormat) != EMBFORMAT_OBJECTONLY: + for format in pyembroidery.supported_formats(): + if 'writer' in format and format['category'] == 'embroidery': + extension = format['extension'] self.OptionParser.add_option('--format-%s' % extension, type="inkbool", dest=extension) self.formats.append(extension) - curFormat = curFormat.next def effect(self): if not self.get_elements(): @@ -49,19 +42,12 @@ class Zip(InkstitchExtension): files = [] - # libembroidery likes to debug log things to stdout. No way to disable it. - save_stdout() for format in self.formats: if getattr(self.options, format): output_file = os.path.join(path, "%s.%s" % (base_file_name, format)) write_embroidery_file(output_file, stitch_plan, self.document.getroot()) files.append(output_file) - # I'd love to do restore_stderr() here, but if I do, libembroidery's - # stuff still prints out and corrupts the zip! That's because it uses - # C's buffered stdout, so it hasn't actually written anything to the - # real standard output yet. - if not files: self.errormsg(_("No embroidery file formats selected.")) @@ -77,7 +63,7 @@ class Zip(InkstitchExtension): # inkscape will read the file contents from stdout and copy # to the destination file that the user chose with open(temp_file.name) as output_file: - sys.real_stdout.write(output_file.read()) + sys.stdout.write(output_file.read()) os.remove(temp_file.name) for file in files: |
