summaryrefslogtreecommitdiff
path: root/lib/stitches/auto_fill.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/stitches/auto_fill.py')
-rw-r--r--lib/stitches/auto_fill.py447
1 files changed, 447 insertions, 0 deletions
diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py
new file mode 100644
index 00000000..7f265909
--- /dev/null
+++ b/lib/stitches/auto_fill.py
@@ -0,0 +1,447 @@
+from fill import intersect_region_with_grating, row_num, stitch_row
+from .. import _, PIXELS_PER_MM, Point as InkstitchPoint
+import sys
+import shapely
+import networkx
+import math
+from itertools import groupby
+from collections import deque
+
+
+class MaxQueueLengthExceeded(Exception):
+ pass
+
+
+def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_point=None):
+ stitches = []
+
+ rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing)
+ segments = [segment for row in rows_of_segments for segment in row]
+
+ graph = build_graph(shape, segments, angle, row_spacing)
+ path = find_stitch_path(graph, segments)
+
+ if starting_point:
+ stitches.extend(connect_points(shape, starting_point, path[0][0], running_stitch_length))
+
+ stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers))
+
+ return stitches
+
+
+def which_outline(shape, coords):
+ """return the index of the outline on which the point resides
+
+ Index 0 is the outer boundary of the fill region. 1+ are the
+ outlines of the holes.
+ """
+
+ # I'd use an intersection check, but floating point errors make it
+ # fail sometimes.
+
+ point = shapely.geometry.Point(*coords)
+ outlines = enumerate(list(shape.boundary))
+ closest = min(outlines, key=lambda (index, outline): outline.distance(point))
+
+ return closest[0]
+
+
+def project(shape, coords, outline_index):
+ """project the point onto the specified outline
+
+ This returns the distance along the outline at which the point resides.
+ """
+
+ outline = list(shape.boundary)[outline_index]
+ return outline.project(shapely.geometry.Point(*coords))
+
+
+def build_graph(shape, segments, angle, row_spacing):
+ """build a graph representation of the grating segments
+
+ This function builds a specialized graph (as in graph theory) that will
+ help us determine a stitching path. The idea comes from this paper:
+
+ http://www.sciencedirect.com/science/article/pii/S0925772100000158
+
+ The goal is to build a graph that we know must have an Eulerian Path.
+ An Eulerian Path is a path from edge to edge in the graph that visits
+ every edge exactly once and ends at the node it started at. Algorithms
+ exist to build such a path, and we'll use Hierholzer's algorithm.
+
+ A graph must have an Eulerian Path if every node in the graph has an
+ even number of edges touching it. Our goal here is to build a graph
+ that will have this property.
+
+ Based on the paper linked above, we'll build the graph as follows:
+
+ * nodes are the endpoints of the grating segments, where they meet
+ with the outer outline of the region the outlines of the interior
+ holes in the region.
+ * edges are:
+ * each section of the outer and inner outlines of the region,
+ between nodes
+ * double every other edge in the outer and inner hole outlines
+
+ Doubling up on some of the edges seems as if it will just mean we have
+ to stitch those spots twice. This may be true, but it also ensures
+ that every node has 4 edges touching it, ensuring that a valid stitch
+ path must exist.
+ """
+
+ graph = networkx.MultiGraph()
+
+ # First, add the grating segments as edges. We'll use the coordinates
+ # of the endpoints as nodes, which networkx will add automatically.
+ for segment in segments:
+ # networkx allows us to label nodes with arbitrary data. We'll
+ # mark this one as a grating segment.
+ graph.add_edge(*segment, key="segment")
+
+ for node in graph.nodes():
+ outline_index = which_outline(shape, node)
+ outline_projection = project(shape, node, outline_index)
+
+ # Tag each node with its index and projection.
+ graph.add_node(node, index=outline_index, projection=outline_projection)
+
+ nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...]
+ nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection']))
+
+ for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']):
+ nodes = [ node for node, data in nodes ]
+
+ # heuristic: change the order I visit the nodes in the outline if necessary.
+ # If the start and endpoints are in the same row, I can't tell which row
+ # I should treat it as being in.
+ for i in xrange(len(nodes)):
+ row0 = row_num(InkstitchPoint(*nodes[0]), angle, row_spacing)
+ row1 = row_num(InkstitchPoint(*nodes[1]), angle, row_spacing)
+
+ if row0 == row1:
+ nodes = nodes[1:] + [nodes[0]]
+ else:
+ break
+
+ # heuristic: it's useful to try to keep the duplicated edges in the same rows.
+ # this prevents the BFS from having to search a ton of edges.
+ min_row_num = min(row0, row1)
+ if min_row_num % 2 == 0:
+ edge_set = 0
+ else:
+ edge_set = 1
+
+ #print >> sys.stderr, outline_index, "es", edge_set, "rn", row_num, inkstitch.Point(*nodes[0]) * self.north(angle), inkstitch.Point(*nodes[1]) * self.north(angle)
+
+ # add an edge between each successive node
+ for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])):
+ graph.add_edge(node1, node2, key="outline")
+
+ # duplicate every other edge around this outline
+ if i % 2 == edge_set:
+ graph.add_edge(node1, node2, key="extra")
+
+
+ if not networkx.is_eulerian(graph):
+ raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected."))
+
+ return graph
+
+
+def node_list_to_edge_list(node_list):
+ return zip(node_list[:-1], node_list[1:])
+
+
+def bfs_for_loop(graph, starting_node, max_queue_length=2000):
+ to_search = deque()
+ to_search.appendleft(([starting_node], set(), 0))
+
+ while to_search:
+ if len(to_search) > max_queue_length:
+ raise MaxQueueLengthExceeded()
+
+ path, visited_edges, visited_segments = to_search.pop()
+ ending_node = path[-1]
+
+ # get a list of neighbors paired with the key of the edge I can follow to get there
+ neighbors = [
+ (node, key)
+ for node, adj in graph.adj[ending_node].iteritems()
+ for key in adj
+ ]
+
+ # heuristic: try grating segments first
+ neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True)
+
+ for next_node, key in neighbors:
+ # skip if I've already followed this edge
+ edge = (tuple(sorted((ending_node, next_node))), key)
+ if edge in visited_edges:
+ continue
+
+ new_path = path + [next_node]
+
+ if key == "segment":
+ new_visited_segments = visited_segments + 1
+ else:
+ new_visited_segments = visited_segments
+
+ if next_node == starting_node:
+ # ignore trivial loops (down and back a doubled edge)
+ if len(new_path) > 3:
+ return node_list_to_edge_list(new_path), new_visited_segments
+
+ new_visited_edges = visited_edges.copy()
+ new_visited_edges.add(edge)
+
+ to_search.appendleft((new_path, new_visited_edges, new_visited_segments))
+
+
+def find_loop(graph, starting_nodes):
+ """find a loop in the graph that is connected to the existing path
+
+ Start at a candidate node and search through edges to find a path
+ back to that node. We'll use a breadth-first search (BFS) in order to
+ find the shortest available loop.
+
+ In most cases, the BFS should not need to search far to find a loop.
+ The queue should stay relatively short.
+
+ An added heuristic will be used: if the BFS queue's length becomes
+ too long, we'll abort and try a different starting point. Due to
+ the way we've set up the graph, there's bound to be a better choice
+ somewhere else.
+ """
+
+ #loop = self.simple_loop(graph, starting_nodes[-2])
+
+ #if loop:
+ # print >> sys.stderr, "simple_loop success"
+ # starting_nodes.pop()
+ # starting_nodes.pop()
+ # return loop
+
+ loop = None
+ retry = []
+ max_queue_length = 2000
+
+ while not loop:
+ while not loop and starting_nodes:
+ starting_node = starting_nodes.pop()
+ #print >> sys.stderr, "find loop from", starting_node
+
+ try:
+ # Note: if bfs_for_loop() returns None, no loop can be
+ # constructed from the starting_node (because the
+ # necessary edges have already been consumed). In that
+ # case we discard that node and try the next.
+ loop = bfs_for_loop(graph, starting_node, max_queue_length)
+
+ #if not loop:
+ #print >> dbg, "failed on", starting_node
+ #dbg.flush()
+ except MaxQueueLengthExceeded:
+ #print >> dbg, "gave up on", starting_node
+ #dbg.flush()
+ # We're giving up on this node for now. We could try
+ # this node again later, so add it to the bottm of the
+ # stack.
+ retry.append(starting_node)
+
+ # Darn, couldn't find a loop. Try harder.
+ starting_nodes.extendleft(retry)
+ max_queue_length *= 2
+
+ starting_nodes.extendleft(retry)
+ return loop
+
+
+def insert_loop(path, loop):
+ """insert a sub-loop into an existing path
+
+ The path will be a series of edges describing a path through the graph
+ that ends where it starts. The loop will be similar, and its starting
+ point will be somewhere along the path.
+
+ Insert the loop into the path, resulting in a longer path.
+
+ Both the path and the loop will be a list of edges specified as a
+ start and end point. The points will be specified in order, such
+ that they will look like this:
+
+ ((p1, p2), (p2, p3), (p3, p4) ... (pn, p1))
+
+ path will be modified in place.
+ """
+
+ loop_start = loop[0][0]
+
+ for i, (start, end) in enumerate(path):
+ if start == loop_start:
+ break
+
+ path[i:i] = loop
+
+
+def find_stitch_path(graph, segments):
+ """find a path that visits every grating segment exactly once
+
+ Theoretically, we just need to find an Eulerian Path in the graph.
+ However, we don't actually care whether every single edge is visited.
+ The edges on the outline of the region are only there to help us get
+ from one grating segment to the next.
+
+ We'll build a "cycle" (a path that ends where it starts) using
+ Hierholzer's algorithm. We'll stop once we've visited every grating
+ segment.
+
+ Hierholzer's algorithm says to select an arbitrary starting node at
+ each step. In order to produce a reasonable stitch path, we'll select
+ the vertex carefully such that we get back-and-forth traversal like
+ mowing a lawn.
+
+ To do this, we'll use a simple heuristic: try to start from nodes in
+ the order of most-recently-visited first.
+ """
+
+ original_graph = graph
+ graph = graph.copy()
+ num_segments = len(segments)
+ segments_visited = 0
+ nodes_visited = deque()
+
+ # start with a simple loop: down one segment and then back along the
+ # outer border to the starting point.
+ path = [segments[0], list(reversed(segments[0]))]
+
+ graph.remove_edges_from(path)
+
+ segments_visited += 1
+ nodes_visited.extend(segments[0])
+
+ while segments_visited < num_segments:
+ result = find_loop(graph, nodes_visited)
+
+ if not result:
+ print >> sys.stderr, _("Unexpected error while generating fill stitches. Please send your SVG file to lexelby@github.")
+ break
+
+ loop, segments = result
+
+ #print >> dbg, "found loop:", loop
+ #dbg.flush()
+
+ segments_visited += segments
+ nodes_visited += [edge[0] for edge in loop]
+ graph.remove_edges_from(loop)
+
+ insert_loop(path, loop)
+
+ #if segments_visited >= 12:
+ # break
+
+ # Now we have a loop that covers every grating segment. It returns to
+ # where it started, which is unnecessary, so we'll snip the last bit off.
+ #while original_graph.has_edge(*path[-1], key="outline"):
+ # path.pop()
+
+ return path
+
+
+def collapse_sequential_outline_edges(graph, path):
+ """collapse sequential edges that fall on the same outline
+
+ When the path follows multiple edges along the outline of the region,
+ replace those edges with the starting and ending points. We'll use
+ these to stitch along the outline later on.
+ """
+
+ start_of_run = None
+ new_path = []
+
+ for edge in path:
+ if graph.has_edge(*edge, key="segment"):
+ if start_of_run:
+ # close off the last run
+ new_path.append((start_of_run, edge[0]))
+ start_of_run = None
+
+ new_path.append(edge)
+ else:
+ if not start_of_run:
+ start_of_run = edge[0]
+
+ if start_of_run:
+ # if we were still in a run, close it off
+ new_path.append((start_of_run, edge[1]))
+
+ return new_path
+
+
+def outline_distance(outline, p1, p2):
+ # how far around the outline (and in what direction) do I need to go
+ # to get from p1 to p2?
+
+ p1_projection = outline.project(shapely.geometry.Point(p1))
+ p2_projection = outline.project(shapely.geometry.Point(p2))
+
+ distance = p2_projection - p1_projection
+
+ if abs(distance) > outline.length / 2.0:
+ # if we'd have to go more than halfway around, it's faster to go
+ # the other way
+ if distance < 0:
+ return distance + outline.length
+ elif distance > 0:
+ return distance - outline.length
+ else:
+ # this ought not happen, but just for completeness, return 0 if
+ # p1 and p0 are the same point
+ return 0
+ else:
+ return distance
+
+
+def connect_points(shape, start, end, running_stitch_length):
+ outline_index = which_outline(shape, start)
+ outline = shape.boundary[outline_index]
+
+ pos = outline.project(shapely.geometry.Point(start))
+ distance = outline_distance(outline, start, end)
+ num_stitches = abs(int(distance / running_stitch_length))
+
+ direction = math.copysign(1.0, distance)
+ one_stitch = running_stitch_length * direction
+
+ #print >> dbg, "connect_points:", outline_index, start, end, distance, stitches, direction
+ #dbg.flush()
+
+ stitches = [InkstitchPoint(*outline.interpolate(pos).coords[0])]
+
+ for i in xrange(num_stitches):
+ pos = (pos + one_stitch) % outline.length
+
+ stitches.append(InkstitchPoint(*outline.interpolate(pos).coords[0]))
+
+ end = InkstitchPoint(*end)
+ if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM:
+ stitches.append(end)
+
+ #print >> dbg, "end connect_points"
+ #dbg.flush()
+
+ return stitches
+
+
+def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers):
+ path = collapse_sequential_outline_edges(graph, path)
+
+ stitches = []
+
+ for edge in path:
+ if graph.has_edge(*edge, key="segment"):
+ stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers)
+ else:
+ stitches.extend(connect_points(shape, edge[0], edge[1], running_stitch_length))
+
+ return stitches