From 2ddc013c760a8b4be32606e8aa8334f4e17b5c68 Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Thu, 1 Mar 2018 19:39:44 -0500 Subject: [PATCH] Fill underlay inset (#108) * interim commit * implement underlay inset * more refactoring --- embroider.py | 738 ++------------------------------ inkstitch/stitches/__init__.py | 2 + inkstitch/stitches/auto_fill.py | 447 +++++++++++++++++++ inkstitch/stitches/fill.py | 244 +++++++++++ messages.po | 11 +- 5 files changed, 740 insertions(+), 702 deletions(-) create mode 100644 inkstitch/stitches/auto_fill.py create mode 100644 inkstitch/stitches/fill.py diff --git a/embroider.py b/embroider.py index eeab58540..804782a94 100644 --- a/embroider.py +++ b/embroider.py @@ -36,7 +36,7 @@ from pprint import pformat import inkstitch from inkstitch import _, cache, dbg, param, EmbroideryElement, get_nodes, SVG_POLYLINE_TAG, SVG_GROUP_TAG, PIXELS_PER_MM, get_viewbox_transform -from inkstitch.stitches import running_stitch +from inkstitch.stitches import running_stitch, auto_fill, legacy_fill from inkstitch.utils import cut_path class Fill(EmbroideryElement): @@ -120,257 +120,21 @@ class Fill(EmbroideryElement): # print >> sys.stderr, "polygon valid:", polygon.is_valid return polygon - @cache - def east(self, angle): - # "east" is the name of the direction that is to the right along a row - return inkstitch.Point(1, 0).rotate(-angle) - - @cache - def north(self, angle): - return self.east(angle).rotate(math.pi / 2) - - def row_num(self, point, angle, row_spacing): - return round((point * self.north(angle)) / row_spacing) - - def adjust_stagger(self, stitch, angle, row_spacing, max_stitch_length): - row_num = self.row_num(stitch, angle, row_spacing) - row_stagger = row_num % self.staggers - stagger_offset = (float(row_stagger) / self.staggers) * max_stitch_length - offset = ((stitch * self.east(angle)) - stagger_offset) % max_stitch_length - - return stitch - offset * self.east(angle) - - def intersect_region_with_grating(self, angle=None, row_spacing=None, end_row_spacing=None): - if angle is None: - angle = self.angle - - if row_spacing is None: - row_spacing = self.row_spacing - - if end_row_spacing is None: - end_row_spacing = self.end_row_spacing - - # the max line length I'll need to intersect the whole shape is the diagonal - (minx, miny, maxx, maxy) = self.shape.bounds - upper_left = inkstitch.Point(minx, miny) - lower_right = inkstitch.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 = inkstitch.Point(1, 0).rotate(-angle) - - # and get a normal vector - normal = direction.rotate(math.pi / 2) - - # I'll start from the center, move in the normal direction some amount, - # and then walk left and right half_length in each direction to create - # a line segment in the grating. - center = inkstitch.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, angle, origin='center', use_radians=True).bounds - - # convert start and end to be relative to center (simplifies things later) - start -= center.y - end -= center.y - - height = abs(end - start) - - print >> dbg, "grating:", start, end, height, row_spacing, end_row_spacing - - # offset start slightly so that rows are always an even multiple of - # row_spacing_px from the origin. This makes it so that abutting - # fill regions at the same angle and spacing always line up nicely. - start -= (start + normal * center) % row_spacing - - rows = [] - - current_row_y = start - - while current_row_y < end: - p0 = center + normal * current_row_y + direction * half_length - p1 = center + normal * current_row_y - direction * half_length - endpoints = [p0.as_tuple(), p1.as_tuple()] - grating_line = 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 - runs = [] - else: - runs = [res.coords] - - if runs: - runs.sort(key=lambda seg: (inkstitch.Point(*seg[0]) - upper_left).length()) - - if self.flip: - runs.reverse() - runs = map(lambda run: tuple(reversed(run)), runs) - - rows.append(runs) - - if end_row_spacing: - current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) - else: - current_row_y += row_spacing - - return rows - - def make_quadrilateral(self, segment1, segment2): - return shgeo.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) - - def is_same_run(self, segment1, segment2): - if shgeo.LineString(segment1).distance(shgeo.LineString(segment2)) > self.row_spacing * 1.1: - return False - - quad = self.make_quadrilateral(segment1, segment2) - quad_area = quad.area - intersection_area = self.shape.intersection(quad).area - - return (intersection_area / quad_area) >= 0.9 - - 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. - - # 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 self.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 stitch_row(self, patch, beg, end, angle, row_spacing, max_stitch_length): - # 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 = inkstitch.Point(*beg) - end = inkstitch.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 not patch.stitches or (beg - patch.stitches[-1]).length() > 0.5 * PIXELS_PER_MM: - patch.add_stitch(beg) - - first_stitch = self.adjust_stagger(beg, angle, row_spacing, max_stitch_length) - - # we might have chosen our first stitch just outside this row, so move back in - if (first_stitch - beg) * row_direction < 0: - first_stitch += row_direction * max_stitch_length - - offset = (first_stitch - beg).length() - - while offset < segment_length: - patch.add_stitch(beg + offset * row_direction) - offset += max_stitch_length - - if (end - patch.stitches[-1]).length() > 0.1 * PIXELS_PER_MM: - patch.add_stitch(end) - - - def section_to_patch(self, group_of_segments, angle=None, row_spacing=None, max_stitch_length=None): - if max_stitch_length is None: - max_stitch_length = self.max_stitch_length - - if row_spacing is None: - row_spacing = self.row_spacing - - if angle is None: - angle = self.angle - - # print >> sys.stderr, len(groups_of_segments) - - patch = Patch(color=self.color) - first_segment = True - swap = False - last_end = None - - for segment in group_of_segments: - (beg, end) = segment - - if (swap): - (beg, end) = (end, beg) - - self.stitch_row(patch, beg, end, angle, row_spacing, max_stitch_length) - - swap = not swap - - return patch - def to_patches(self, last_patch): - rows_of_segments = self.intersect_region_with_grating() - groups_of_segments = self.pull_runs(rows_of_segments) + 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] - return [self.section_to_patch(group) for group in groups_of_segments] + rows_of_segments = fill.intersect_region_with_grating(self.shape, self.angle, self.row_spacing, self.end_row_spacing, self.flip) + groups_of_segments = fill.pull_runs(rows_of_segments) + return [fill.section_to_patch(group) for group in groups_of_segments] -class MaxQueueLengthExceeded(Exception): - pass class AutoFill(Fill): element_name = _("Auto-Fill") @@ -427,462 +191,50 @@ class AutoFill(Fill): def fill_underlay_max_stitch_length(self): return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length - def which_outline(self, coords): - """return the index of the outline on which the point resides - - Index 0 is the outer boundary of the fill region. 1+ are the - outlines of the holes. - """ - - # I'd use an intersection check, but floating point errors make it - # fail sometimes. - - point = shgeo.Point(*coords) - outlines = list(enumerate(self.shape.boundary)) - closest = min(outlines, key=lambda (index, outline): outline.distance(point)) - - return closest[0] - - def project(self, coords, outline_index): - """project the point onto the specified outline - - This returns the distance along the outline at which the point resides. - """ - - return self.shape.boundary.project(shgeo.Point(*coords)) - - def build_graph(self, segments, angle, row_spacing): - """build a graph representation of the grating segments - - This function builds a specialized graph (as in graph theory) that will - help us determine a stitching path. The idea comes from this paper: - - http://www.sciencedirect.com/science/article/pii/S0925772100000158 - - The goal is to build a graph that we know must have an Eulerian Path. - An Eulerian Path is a path from edge to edge in the graph that visits - every edge exactly once and ends at the node it started at. Algorithms - exist to build such a path, and we'll use Hierholzer's algorithm. - - A graph must have an Eulerian Path if every node in the graph has an - even number of edges touching it. Our goal here is to build a graph - that will have this property. - - Based on the paper linked above, we'll build the graph as follows: - - * nodes are the endpoints of the grating segments, where they meet - with the outer outline of the region the outlines of the interior - holes in the region. - * edges are: - * each section of the outer and inner outlines of the region, - between nodes - * double every other edge in the outer and inner hole outlines - - Doubling up on some of the edges seems as if it will just mean we have - to stitch those spots twice. This may be true, but it also ensures - that every node has 4 edges touching it, ensuring that a valid stitch - path must exist. - """ - - graph = networkx.MultiGraph() - - # First, add the grating segments as edges. We'll use the coordinates - # of the endpoints as nodes, which networkx will add automatically. - for segment in segments: - # networkx allows us to label nodes with arbitrary data. We'll - # mark this one as a grating segment. - graph.add_edge(*segment, key="segment") - - for node in graph.nodes(): - outline_index = self.which_outline(node) - outline_projection = self.project(node, outline_index) - - # Tag each node with its index and projection. - graph.add_node(node, index=outline_index, projection=outline_projection) - - nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] - nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection'])) - - for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']): - nodes = [ node for node, data in nodes ] - - # heuristic: change the order I visit the nodes in the outline if necessary. - # If the start and endpoints are in the same row, I can't tell which row - # I should treat it as being in. - for i in xrange(len(nodes)): - row0 = self.row_num(inkstitch.Point(*nodes[0]), angle, row_spacing) - row1 = self.row_num(inkstitch.Point(*nodes[1]), angle, row_spacing) - - if row0 == row1: - nodes = nodes[1:] + [nodes[0]] - else: - break - - # heuristic: it's useful to try to keep the duplicated edges in the same rows. - # this prevents the BFS from having to search a ton of edges. - row_num = min(row0, row1) - if row_num % 2 == 0: - edge_set = 0 - else: - edge_set = 1 - - #print >> sys.stderr, outline_index, "es", edge_set, "rn", row_num, inkstitch.Point(*nodes[0]) * self.north(angle), inkstitch.Point(*nodes[1]) * self.north(angle) - - # add an edge between each successive node - for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])): - graph.add_edge(node1, node2, key="outline") - - # duplicate edges contained in every other row (exactly half - # will be duplicated) - row_num = min(self.row_num(inkstitch.Point(*node1), angle, row_spacing), - self.row_num(inkstitch.Point(*node2), angle, row_spacing)) - - # duplicate every other edge around this outline - if i % 2 == edge_set: - graph.add_edge(node1, node2, key="extra") - - - if not networkx.is_eulerian(graph): - raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected.")) - - return graph - - def node_list_to_edge_list(self, node_list): - return zip(node_list[:-1], node_list[1:]) - - def bfs_for_loop(self, graph, starting_node, max_queue_length=2000): - to_search = deque() - to_search.appendleft(([starting_node], set(), 0)) - - while to_search: - if len(to_search) > max_queue_length: - raise MaxQueueLengthExceeded() - - path, visited_edges, visited_segments = to_search.pop() - ending_node = path[-1] - - # get a list of neighbors paired with the key of the edge I can follow to get there - neighbors = [ - (node, key) - for node, adj in graph.adj[ending_node].iteritems() - for key in adj - ] - - # heuristic: try grating segments first - neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True) - - for next_node, key in neighbors: - # skip if I've already followed this edge - edge = (tuple(sorted((ending_node, next_node))), key) - if edge in visited_edges: - continue - - new_path = path + [next_node] - - if key == "segment": - new_visited_segments = visited_segments + 1 - else: - new_visited_segments = visited_segments - - if next_node == starting_node: - # ignore trivial loops (down and back a doubled edge) - if len(new_path) > 3: - return self.node_list_to_edge_list(new_path), new_visited_segments - - new_visited_edges = visited_edges.copy() - new_visited_edges.add(edge) - - to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) - - def find_loop(self, graph, starting_nodes): - """find a loop in the graph that is connected to the existing path - - Start at a candidate node and search through edges to find a path - back to that node. We'll use a breadth-first search (BFS) in order to - find the shortest available loop. - - In most cases, the BFS should not need to search far to find a loop. - The queue should stay relatively short. - - An added heuristic will be used: if the BFS queue's length becomes - too long, we'll abort and try a different starting point. Due to - the way we've set up the graph, there's bound to be a better choice - somewhere else. - """ - - #loop = self.simple_loop(graph, starting_nodes[-2]) - - #if loop: - # print >> sys.stderr, "simple_loop success" - # starting_nodes.pop() - # starting_nodes.pop() - # return loop - - loop = None - retry = [] - max_queue_length = 2000 - - while not loop: - while not loop and starting_nodes: - starting_node = starting_nodes.pop() - #print >> sys.stderr, "find loop from", starting_node - - try: - # Note: if bfs_for_loop() returns None, no loop can be - # constructed from the starting_node (because the - # necessary edges have already been consumed). In that - # case we discard that node and try the next. - loop = self.bfs_for_loop(graph, starting_node, max_queue_length) - - if not loop: - print >> dbg, "failed on", starting_node - dbg.flush() - except MaxQueueLengthExceeded: - print >> dbg, "gave up on", starting_node - dbg.flush() - # We're giving up on this node for now. We could try - # this node again later, so add it to the bottm of the - # stack. - retry.append(starting_node) - - # Darn, couldn't find a loop. Try harder. - starting_nodes.extendleft(retry) - max_queue_length *= 2 - - starting_nodes.extendleft(retry) - return loop - - def insert_loop(self, path, loop): - """insert a sub-loop into an existing path - - The path will be a series of edges describing a path through the graph - that ends where it starts. The loop will be similar, and its starting - point will be somewhere along the path. - - Insert the loop into the path, resulting in a longer path. - - Both the path and the loop will be a list of edges specified as a - start and end point. The points will be specified in order, such - that they will look like this: - - ((p1, p2), (p2, p3), (p3, p4) ... (pn, p1)) - - path will be modified in place. - """ - - loop_start = loop[0][0] - - for i, (start, end) in enumerate(path): - if start == loop_start: - break - - path[i:i] = loop - - def find_stitch_path(self, graph, segments): - """find a path that visits every grating segment exactly once - - Theoretically, we just need to find an Eulerian Path in the graph. - However, we don't actually care whether every single edge is visited. - The edges on the outline of the region are only there to help us get - from one grating segment to the next. - - We'll build a "cycle" (a path that ends where it starts) using - Hierholzer's algorithm. We'll stop once we've visited every grating - segment. - - Hierholzer's algorithm says to select an arbitrary starting node at - each step. In order to produce a reasonable stitch path, we'll select - the vertex carefully such that we get back-and-forth traversal like - mowing a lawn. - - To do this, we'll use a simple heuristic: try to start from nodes in - the order of most-recently-visited first. - """ - - original_graph = graph - graph = graph.copy() - num_segments = len(segments) - segments_visited = 0 - nodes_visited = deque() - - # start with a simple loop: down one segment and then back along the - # outer border to the starting point. - path = [segments[0], list(reversed(segments[0]))] - - graph.remove_edges_from(path) - - segments_visited += 1 - nodes_visited.extend(segments[0]) - - while segments_visited < num_segments: - result = self.find_loop(graph, nodes_visited) - - if not result: - print >> sys.stderr, _("Unexpected error while generating fill stitches. Please send your SVG file to lexelby@github.") - break - - loop, segments = result - - print >> dbg, "found loop:", loop - dbg.flush() - - segments_visited += segments - nodes_visited += [edge[0] for edge in loop] - graph.remove_edges_from(loop) - - self.insert_loop(path, loop) - - #if segments_visited >= 12: - # break - - # Now we have a loop that covers every grating segment. It returns to - # where it started, which is unnecessary, so we'll snip the last bit off. - #while original_graph.has_edge(*path[-1], key="outline"): - # path.pop() - - return path - - def collapse_sequential_outline_edges(self, graph, path): - """collapse sequential edges that fall on the same outline - - When the path follows multiple edges along the outline of the region, - replace those edges with the starting and ending points. We'll use - these to stitch along the outline later on. - """ - - start_of_run = None - new_path = [] - - for edge in path: - if graph.has_edge(*edge, key="segment"): - if start_of_run: - # close off the last run - new_path.append((start_of_run, edge[0])) - start_of_run = None - - new_path.append(edge) - else: - if not start_of_run: - start_of_run = edge[0] - - if start_of_run: - # if we were still in a run, close it off - new_path.append((start_of_run, edge[1])) - - return new_path - - def outline_distance(self, outline, p1, p2): - # how far around the outline (and in what direction) do I need to go - # to get from p1 to p2? - - p1_projection = outline.project(shgeo.Point(p1)) - p2_projection = outline.project(shgeo.Point(p2)) - - distance = p2_projection - p1_projection - - if abs(distance) > self.outline_length / 2.0: - # if we'd have to go more than halfway around, it's faster to go - # the other way - if distance < 0: - return distance + self.outline_length - elif distance > 0: - return distance - self.outline_length - else: - # this ought not happen, but just for completeness, return 0 if - # p1 and p0 are the same point - return 0 + @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 distance - - def connect_points(self, patch, start, end): - outline_index = self.which_outline(start) - outline = self.shape.boundary[outline_index] - - pos = outline.project(shgeo.Point(start)) - distance = self.outline_distance(outline, start, end) - stitches = abs(int(distance / self.running_stitch_length)) - - direction = math.copysign(1.0, distance) - one_stitch = self.running_stitch_length * direction - - print >> dbg, "connect_points:", outline_index, start, end, distance, stitches, direction - dbg.flush() - - patch.add_stitch(inkstitch.Point(*start)) - - for i in xrange(stitches): - pos = (pos + one_stitch) % self.outline_length - - patch.add_stitch(inkstitch.Point(*outline.interpolate(pos).coords[0])) - - end = inkstitch.Point(*end) - if (end - patch.stitches[-1]).length() > 0.1 * PIXELS_PER_MM: - patch.add_stitch(end) - - print >> dbg, "end connect_points" - dbg.flush() - - def path_to_patch(self, graph, path, angle, row_spacing, max_stitch_length): - path = self.collapse_sequential_outline_edges(graph, path) - - patch = Patch(color=self.color) - #patch.add_stitch(inkstitch.Point(*path[0][0])) - - #for edge in path: - # patch.add_stitch(inkstitch.Point(*edge[1])) - - for edge in path: - if graph.has_edge(*edge, key="segment"): - self.stitch_row(patch, edge[0], edge[1], angle, row_spacing, max_stitch_length) - else: - self.connect_points(patch, *edge) - - return patch - - def do_auto_fill(self, angle, row_spacing, max_stitch_length, starting_point=None): - patches = [] - - print >> dbg, "start do_auto_fill" - dbg.flush() - - rows_of_segments = self.intersect_region_with_grating(angle, row_spacing) - segments = [segment for row in rows_of_segments for segment in row] - - graph = self.build_graph(segments, angle, row_spacing) - path = self.find_stitch_path(graph, segments) - - if starting_point: - patch = Patch(self.color) - self.connect_points(patch, starting_point, path[0][0]) - patches.append(patch) - - patches.append(self.path_to_patch(graph, path, angle, row_spacing, max_stitch_length)) - - print >> dbg, "end do_auto_fill" - dbg.flush() - - return patches - + return self.shape def to_patches(self, last_patch): - patches = [] + stitches = [] if last_patch is None: starting_point = None else: - nearest_point = self.outline.interpolate(self.outline.project(shgeo.Point(last_patch.stitches[-1]))) - starting_point = inkstitch.Point(*nearest_point.coords[0]) + starting_point = last_patch.stitches[-1] if self.fill_underlay: - patches.extend(self.do_auto_fill(self.fill_underlay_angle, self.fill_underlay_row_spacing, self.fill_underlay_max_stitch_length, starting_point)) - starting_point = patches[-1].stitches[-1] + 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] - patches.extend(self.do_auto_fill(self.angle, self.row_spacing, self.max_stitch_length, starting_point)) + 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)) - print >> dbg, "end AutoFill.to_patches" - dbg.flush() - - return patches + return [Patch(stitches=stitches, color=self.color)] class Stroke(EmbroideryElement): diff --git a/inkstitch/stitches/__init__.py b/inkstitch/stitches/__init__.py index 7959ef628..d2ff04463 100644 --- a/inkstitch/stitches/__init__.py +++ b/inkstitch/stitches/__init__.py @@ -1 +1,3 @@ from running_stitch import * +from auto_fill import auto_fill +from fill import legacy_fill diff --git a/inkstitch/stitches/auto_fill.py b/inkstitch/stitches/auto_fill.py new file mode 100644 index 000000000..cf765bc20 --- /dev/null +++ b/inkstitch/stitches/auto_fill.py @@ -0,0 +1,447 @@ +from fill import intersect_region_with_grating, row_num, stitch_row +from .. import _, PIXELS_PER_MM, Point as InkstitchPoint +import sys +import shapely +import networkx +import math +from itertools import groupby +from collections import deque + + +class MaxQueueLengthExceeded(Exception): + pass + + +def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_point=None): + stitches = [] + + rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) + segments = [segment for row in rows_of_segments for segment in row] + + graph = build_graph(shape, segments, angle, row_spacing) + path = find_stitch_path(graph, segments) + + if starting_point: + stitches.extend(connect_points(shape, starting_point, path[0][0], running_stitch_length)) + + stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers)) + + return stitches + + +def which_outline(shape, coords): + """return the index of the outline on which the point resides + + Index 0 is the outer boundary of the fill region. 1+ are the + outlines of the holes. + """ + + # I'd use an intersection check, but floating point errors make it + # fail sometimes. + + point = shapely.geometry.Point(*coords) + outlines = enumerate(list(shape.boundary)) + closest = min(outlines, key=lambda (index, outline): outline.distance(point)) + + return closest[0] + + +def project(shape, coords, outline_index): + """project the point onto the specified outline + + This returns the distance along the outline at which the point resides. + """ + + outline = list(shape.boundary)[outline_index] + return outline.project(shapely.geometry.Point(*coords)) + + +def build_graph(shape, segments, angle, row_spacing): + """build a graph representation of the grating segments + + This function builds a specialized graph (as in graph theory) that will + help us determine a stitching path. The idea comes from this paper: + + http://www.sciencedirect.com/science/article/pii/S0925772100000158 + + The goal is to build a graph that we know must have an Eulerian Path. + An Eulerian Path is a path from edge to edge in the graph that visits + every edge exactly once and ends at the node it started at. Algorithms + exist to build such a path, and we'll use Hierholzer's algorithm. + + A graph must have an Eulerian Path if every node in the graph has an + even number of edges touching it. Our goal here is to build a graph + that will have this property. + + Based on the paper linked above, we'll build the graph as follows: + + * nodes are the endpoints of the grating segments, where they meet + with the outer outline of the region the outlines of the interior + holes in the region. + * edges are: + * each section of the outer and inner outlines of the region, + between nodes + * double every other edge in the outer and inner hole outlines + + Doubling up on some of the edges seems as if it will just mean we have + to stitch those spots twice. This may be true, but it also ensures + that every node has 4 edges touching it, ensuring that a valid stitch + path must exist. + """ + + graph = networkx.MultiGraph() + + # First, add the grating segments as edges. We'll use the coordinates + # of the endpoints as nodes, which networkx will add automatically. + for segment in segments: + # networkx allows us to label nodes with arbitrary data. We'll + # mark this one as a grating segment. + graph.add_edge(*segment, key="segment") + + for node in graph.nodes(): + outline_index = which_outline(shape, node) + outline_projection = project(shape, node, outline_index) + + # Tag each node with its index and projection. + graph.add_node(node, index=outline_index, projection=outline_projection) + + nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] + nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection'])) + + for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']): + nodes = [ node for node, data in nodes ] + + # heuristic: change the order I visit the nodes in the outline if necessary. + # If the start and endpoints are in the same row, I can't tell which row + # I should treat it as being in. + for i in xrange(len(nodes)): + row0 = row_num(InkstitchPoint(*nodes[0]), angle, row_spacing) + row1 = row_num(InkstitchPoint(*nodes[1]), angle, row_spacing) + + if row0 == row1: + nodes = nodes[1:] + [nodes[0]] + else: + break + + # heuristic: it's useful to try to keep the duplicated edges in the same rows. + # this prevents the BFS from having to search a ton of edges. + min_row_num = min(row0, row1) + if min_row_num % 2 == 0: + edge_set = 0 + else: + edge_set = 1 + + #print >> sys.stderr, outline_index, "es", edge_set, "rn", row_num, inkstitch.Point(*nodes[0]) * self.north(angle), inkstitch.Point(*nodes[1]) * self.north(angle) + + # add an edge between each successive node + for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])): + graph.add_edge(node1, node2, key="outline") + + # duplicate every other edge around this outline + if i % 2 == edge_set: + graph.add_edge(node1, node2, key="extra") + + + if not networkx.is_eulerian(graph): + raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected.")) + + return graph + + +def node_list_to_edge_list(node_list): + return zip(node_list[:-1], node_list[1:]) + + +def bfs_for_loop(graph, starting_node, max_queue_length=2000): + to_search = deque() + to_search.appendleft(([starting_node], set(), 0)) + + while to_search: + if len(to_search) > max_queue_length: + raise MaxQueueLengthExceeded() + + path, visited_edges, visited_segments = to_search.pop() + ending_node = path[-1] + + # get a list of neighbors paired with the key of the edge I can follow to get there + neighbors = [ + (node, key) + for node, adj in graph.adj[ending_node].iteritems() + for key in adj + ] + + # heuristic: try grating segments first + neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True) + + for next_node, key in neighbors: + # skip if I've already followed this edge + edge = (tuple(sorted((ending_node, next_node))), key) + if edge in visited_edges: + continue + + new_path = path + [next_node] + + if key == "segment": + new_visited_segments = visited_segments + 1 + else: + new_visited_segments = visited_segments + + if next_node == starting_node: + # ignore trivial loops (down and back a doubled edge) + if len(new_path) > 3: + return node_list_to_edge_list(new_path), new_visited_segments + + new_visited_edges = visited_edges.copy() + new_visited_edges.add(edge) + + to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) + + +def find_loop(graph, starting_nodes): + """find a loop in the graph that is connected to the existing path + + Start at a candidate node and search through edges to find a path + back to that node. We'll use a breadth-first search (BFS) in order to + find the shortest available loop. + + In most cases, the BFS should not need to search far to find a loop. + The queue should stay relatively short. + + An added heuristic will be used: if the BFS queue's length becomes + too long, we'll abort and try a different starting point. Due to + the way we've set up the graph, there's bound to be a better choice + somewhere else. + """ + + #loop = self.simple_loop(graph, starting_nodes[-2]) + + #if loop: + # print >> sys.stderr, "simple_loop success" + # starting_nodes.pop() + # starting_nodes.pop() + # return loop + + loop = None + retry = [] + max_queue_length = 2000 + + while not loop: + while not loop and starting_nodes: + starting_node = starting_nodes.pop() + #print >> sys.stderr, "find loop from", starting_node + + try: + # Note: if bfs_for_loop() returns None, no loop can be + # constructed from the starting_node (because the + # necessary edges have already been consumed). In that + # case we discard that node and try the next. + loop = bfs_for_loop(graph, starting_node, max_queue_length) + + #if not loop: + #print >> dbg, "failed on", starting_node + #dbg.flush() + except MaxQueueLengthExceeded: + #print >> dbg, "gave up on", starting_node + #dbg.flush() + # We're giving up on this node for now. We could try + # this node again later, so add it to the bottm of the + # stack. + retry.append(starting_node) + + # Darn, couldn't find a loop. Try harder. + starting_nodes.extendleft(retry) + max_queue_length *= 2 + + starting_nodes.extendleft(retry) + return loop + + +def insert_loop(path, loop): + """insert a sub-loop into an existing path + + The path will be a series of edges describing a path through the graph + that ends where it starts. The loop will be similar, and its starting + point will be somewhere along the path. + + Insert the loop into the path, resulting in a longer path. + + Both the path and the loop will be a list of edges specified as a + start and end point. The points will be specified in order, such + that they will look like this: + + ((p1, p2), (p2, p3), (p3, p4) ... (pn, p1)) + + path will be modified in place. + """ + + loop_start = loop[0][0] + + for i, (start, end) in enumerate(path): + if start == loop_start: + break + + path[i:i] = loop + + +def find_stitch_path(graph, segments): + """find a path that visits every grating segment exactly once + + Theoretically, we just need to find an Eulerian Path in the graph. + However, we don't actually care whether every single edge is visited. + The edges on the outline of the region are only there to help us get + from one grating segment to the next. + + We'll build a "cycle" (a path that ends where it starts) using + Hierholzer's algorithm. We'll stop once we've visited every grating + segment. + + Hierholzer's algorithm says to select an arbitrary starting node at + each step. In order to produce a reasonable stitch path, we'll select + the vertex carefully such that we get back-and-forth traversal like + mowing a lawn. + + To do this, we'll use a simple heuristic: try to start from nodes in + the order of most-recently-visited first. + """ + + original_graph = graph + graph = graph.copy() + num_segments = len(segments) + segments_visited = 0 + nodes_visited = deque() + + # start with a simple loop: down one segment and then back along the + # outer border to the starting point. + path = [segments[0], list(reversed(segments[0]))] + + graph.remove_edges_from(path) + + segments_visited += 1 + nodes_visited.extend(segments[0]) + + while segments_visited < num_segments: + result = find_loop(graph, nodes_visited) + + if not result: + print >> sys.stderr, _("Unexpected error while generating fill stitches. Please send your SVG file to lexelby@github.") + break + + loop, segments = result + + #print >> dbg, "found loop:", loop + #dbg.flush() + + segments_visited += segments + nodes_visited += [edge[0] for edge in loop] + graph.remove_edges_from(loop) + + insert_loop(path, loop) + + #if segments_visited >= 12: + # break + + # Now we have a loop that covers every grating segment. It returns to + # where it started, which is unnecessary, so we'll snip the last bit off. + #while original_graph.has_edge(*path[-1], key="outline"): + # path.pop() + + return path + + +def collapse_sequential_outline_edges(graph, path): + """collapse sequential edges that fall on the same outline + + When the path follows multiple edges along the outline of the region, + replace those edges with the starting and ending points. We'll use + these to stitch along the outline later on. + """ + + start_of_run = None + new_path = [] + + for edge in path: + if graph.has_edge(*edge, key="segment"): + if start_of_run: + # close off the last run + new_path.append((start_of_run, edge[0])) + start_of_run = None + + new_path.append(edge) + else: + if not start_of_run: + start_of_run = edge[0] + + if start_of_run: + # if we were still in a run, close it off + new_path.append((start_of_run, edge[1])) + + return new_path + + +def outline_distance(outline, p1, p2): + # how far around the outline (and in what direction) do I need to go + # to get from p1 to p2? + + p1_projection = outline.project(shapely.geometry.Point(p1)) + p2_projection = outline.project(shapely.geometry.Point(p2)) + + distance = p2_projection - p1_projection + + if abs(distance) > outline.length / 2.0: + # if we'd have to go more than halfway around, it's faster to go + # the other way + if distance < 0: + return distance + outline.length + elif distance > 0: + return distance - outline.length + else: + # this ought not happen, but just for completeness, return 0 if + # p1 and p0 are the same point + return 0 + else: + return distance + + +def connect_points(shape, start, end, running_stitch_length): + outline_index = which_outline(shape, start) + outline = shape.boundary[outline_index] + + pos = outline.project(shapely.geometry.Point(start)) + distance = outline_distance(outline, start, end) + num_stitches = abs(int(distance / running_stitch_length)) + + direction = math.copysign(1.0, distance) + one_stitch = running_stitch_length * direction + + #print >> dbg, "connect_points:", outline_index, start, end, distance, stitches, direction + #dbg.flush() + + stitches = [InkstitchPoint(*start)] + + for i in xrange(num_stitches): + pos = (pos + one_stitch) % outline.length + + stitches.append(InkstitchPoint(*outline.interpolate(pos).coords[0])) + + end = InkstitchPoint(*end) + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + #print >> dbg, "end connect_points" + #dbg.flush() + + return stitches + + +def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers): + path = collapse_sequential_outline_edges(graph, path) + + stitches = [] + + for edge in path: + if graph.has_edge(*edge, key="segment"): + stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers) + else: + stitches.extend(connect_points(shape, edge[0], edge[1], running_stitch_length)) + + return stitches diff --git a/inkstitch/stitches/fill.py b/inkstitch/stitches/fill.py new file mode 100644 index 000000000..52c951724 --- /dev/null +++ b/inkstitch/stitches/fill.py @@ -0,0 +1,244 @@ +from .. import Point as InkstitchPoint, cache, PIXELS_PER_MM +import shapely +import math +import sys + + +def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): + rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) + groups_of_segments = pull_runs(rows_of_segments, shape, row_spacing) + + return [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers) + for group in groups_of_segments] + + +@cache +def east(angle): + # "east" is the name of the direction that is to the right along a row + return InkstitchPoint(1, 0).rotate(-angle) + + +@cache +def north(angle): + return east(angle).rotate(math.pi / 2) + + +def row_num(point, angle, row_spacing): + return round((point * north(angle)) / row_spacing) + + +def adjust_stagger(stitch, angle, row_spacing, max_stitch_length, staggers): + this_row_num = row_num(stitch, angle, row_spacing) + row_stagger = this_row_num % staggers + stagger_offset = (float(row_stagger) / staggers) * max_stitch_length + offset = ((stitch * east(angle)) - stagger_offset) % max_stitch_length + + return stitch - offset * east(angle) + +def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers): + # We want our stitches to look like this: + # + # ---*-----------*----------- + # ------*-----------*-------- + # ---------*-----------*----- + # ------------*-----------*-- + # ---*-----------*----------- + # + # Each successive row of stitches will be staggered, with + # num_staggers rows before the pattern repeats. A value of + # 4 gives a nice fill while hiding the needle holes. The + # first row is offset 0%, the second 25%, the third 50%, and + # the fourth 75%. + # + # Actually, instead of just starting at an offset of 0, we + # can calculate a row's offset relative to the origin. This + # way if we have two abutting fill regions, they'll perfectly + # tile with each other. That's important because we often get + # abutting fill regions from pull_runs(). + + beg = InkstitchPoint(*beg) + end = InkstitchPoint(*end) + + row_direction = (end - beg).unit() + segment_length = (end - beg).length() + + # only stitch the first point if it's a reasonable distance away from the + # last stitch + if not stitches or (beg - stitches[-1]).length() > 0.5 * PIXELS_PER_MM: + stitches.append(beg) + + first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) + + # we might have chosen our first stitch just outside this row, so move back in + if (first_stitch - beg) * row_direction < 0: + first_stitch += row_direction * max_stitch_length + + offset = (first_stitch - beg).length() + + while offset < segment_length: + stitches.append(beg + offset * row_direction) + offset += max_stitch_length + + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + +def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=None, flip=False): + # the max line length I'll need to intersect the whole shape is the diagonal + (minx, miny, maxx, maxy) = shape.bounds + upper_left = InkstitchPoint(minx, miny) + lower_right = InkstitchPoint(maxx, maxy) + length = (upper_left - lower_right).length() + half_length = length / 2.0 + + # Now get a unit vector rotated to the requested angle. I use -angle + # because shapely rotates clockwise, but my geometry textbooks taught + # me to consider angles as counter-clockwise from the X axis. + direction = InkstitchPoint(1, 0).rotate(-angle) + + # and get a normal vector + normal = direction.rotate(math.pi / 2) + + # I'll start from the center, move in the normal direction some amount, + # and then walk left and right half_length in each direction to create + # a line segment in the grating. + center = InkstitchPoint((minx + maxx) / 2.0, (miny + maxy) / 2.0) + + # I need to figure out how far I need to go along the normal to get to + # the edge of the shape. To do that, I'll rotate the bounding box + # angle degrees clockwise and ask for the new bounding box. The max + # and min y tell me how far to go. + + _, start, _, end = shapely.affinity.rotate(shape, angle, origin='center', use_radians=True).bounds + + # convert start and end to be relative to center (simplifies things later) + start -= center.y + end -= center.y + + height = abs(end - start) + + #print >> dbg, "grating:", start, end, height, row_spacing, end_row_spacing + + # offset start slightly so that rows are always an even multiple of + # row_spacing_px from the origin. This makes it so that abutting + # fill regions at the same angle and spacing always line up nicely. + start -= (start + normal * center) % row_spacing + + rows = [] + + current_row_y = start + + while current_row_y < end: + p0 = center + normal * current_row_y + direction * half_length + p1 = center + normal * current_row_y - direction * half_length + endpoints = [p0.as_tuple(), p1.as_tuple()] + grating_line = shapely.geometry.LineString(endpoints) + + res = grating_line.intersection(shape) + + if (isinstance(res, shapely.geometry.MultiLineString)): + runs = map(lambda line_string: line_string.coords, res.geoms) + else: + if res.is_empty or len(res.coords) == 1: + # ignore if we intersected at a single point or no points + runs = [] + else: + runs = [res.coords] + + if runs: + runs.sort(key=lambda seg: (InkstitchPoint(*seg[0]) - upper_left).length()) + + if flip: + runs.reverse() + runs = map(lambda run: tuple(reversed(run)), runs) + + rows.append(runs) + + if end_row_spacing: + current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) + else: + current_row_y += row_spacing + + return rows + +def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers): + stitches = [] + first_segment = True + swap = False + last_end = None + + for segment in group_of_segments: + (beg, end) = segment + + if (swap): + (beg, end) = (end, beg) + + stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers) + + swap = not swap + + return stitches + + +def make_quadrilateral(segment1, segment2): + return shapely.geometry.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) + + +def is_same_run(segment1, segment2, shape, row_spacing): + line1 = shapely.geometry.LineString(segment1) + line2 = shapely.geometry.LineString(segment2) + + if line1.distance(line2) > row_spacing * 1.1: + return False + + quad = make_quadrilateral(segment1, segment2) + quad_area = quad.area + intersection_area = shape.intersection(quad).area + + return (intersection_area / quad_area) >= 0.9 + + +def pull_runs(rows, shape, row_spacing): + # Given a list of rows, each containing a set of line segments, + # break the area up into contiguous patches of line segments. + # + # This is done by repeatedly pulling off the first line segment in + # each row and calling that a shape. We have to be careful to make + # sure that the line segments are part of the same shape. Consider + # the letter "H", with an embroidery angle of 45 degrees. When + # we get to the bottom of the lower left leg, the next row will jump + # over to midway up the lower right leg. We want to stop there and + # start a new patch. + + # for row in rows: + # print >> sys.stderr, len(row) + + # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) + + runs = [] + count = 0 + while (len(rows) > 0): + run = [] + prev = None + + for row_num in xrange(len(rows)): + row = rows[row_num] + first, rest = row[0], row[1:] + + # TODO: only accept actually adjacent rows here + if prev is not None and not is_same_run(prev, first, shape, row_spacing): + break + + run.append(first) + prev = first + + rows[row_num] = rest + + # print >> sys.stderr, len(run) + runs.append(run) + rows = [row for row in rows if len(row) > 0] + + count += 1 + + return runs + diff --git a/messages.po b/messages.po index 109ad310f..a2461b39d 100644 --- a/messages.po +++ b/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-02-27 23:14-0500\n" +"POT-Creation-Date: 2018-02-28 20:27-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -62,14 +62,7 @@ msgstr "" msgid "Max stitch length" msgstr "" -msgid "" -"Unable to autofill. This most often happens because your shape is made up " -"of multiple sections that aren't connected." -msgstr "" - -msgid "" -"Unexpected error while generating fill stitches. Please send your SVG file " -"to lexelby@github." +msgid "Inset" msgstr "" msgid "Satin stitch along paths"