summaryrefslogtreecommitdiff
path: root/lib/stitches/auto_satin.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/stitches/auto_satin.py')
-rw-r--r--lib/stitches/auto_satin.py573
1 files changed, 573 insertions, 0 deletions
diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py
new file mode 100644
index 00000000..59bf6b0a
--- /dev/null
+++ b/lib/stitches/auto_satin.py
@@ -0,0 +1,573 @@
+from itertools import chain
+import math
+
+import cubicsuperpath
+import inkex
+from shapely import geometry as shgeo
+from shapely.geometry import Point as ShapelyPoint
+import simplestyle
+
+import networkx as nx
+
+from ..elements import Stroke
+from ..svg import PIXELS_PER_MM, line_strings_to_csp
+from ..svg.tags import SVG_PATH_TAG
+from ..utils import Point as InkstitchPoint, cut, cache
+
+
+class SatinSegment(object):
+ """A portion of SatinColumn.
+
+ Attributes:
+ satin -- the SatinColumn instance
+ start -- how far along the satin this graph edge starts (a float from 0.0 to 1.0)
+ end -- how far along the satin this graph edge ends (a float from 0.0 to 1.0)
+ reverse -- if True, reverse the direction of the satin
+ """
+
+ def __init__(self, satin, start=0.0, end=1.0, reverse=False):
+ """Initialize a SatinEdge.
+
+ Arguments:
+ satin -- the SatinColumn instance
+ start, end -- a tuple or Point falling somewhere on the
+ satin column, OR a floating point specifying a
+ normalized projection of a distance along the satin
+ (0.0 to 1.0 inclusive)
+ reverse -- boolean
+ """
+
+ self.satin = satin
+ self.reverse = reverse
+
+ # start and end are stored as normalized projections
+ self.start = self._parse_init_param(start)
+ self.end = self._parse_init_param(end)
+
+ if self.start > self.end:
+ self.end, self.start = self.start, self.end
+ self.reverse = True
+
+ def _parse_init_param(self, param):
+ if isinstance(param, (float, int)):
+ return param
+ elif isinstance(param, (tuple, InkstitchPoint, ShapelyPoint)):
+ return self.satin.center.project(ShapelyPoint(param), normalized=True)
+
+ def to_satin(self):
+ satin = self.satin
+
+ if self.start > 0.0:
+ before, satin = satin.split(self.start)
+
+ if self.end < 1.0:
+ satin, after = satin.split(
+ (self.end - self.start) / (1.0 - self.start))
+
+ if self.reverse:
+ satin = satin.reverse()
+
+ satin = satin.apply_transform()
+
+ return satin
+
+ to_element = to_satin
+
+ def to_running_stitch(self):
+ return RunningStitch(self.center_line, self.satin)
+
+ def break_up(self, segment_size):
+ """Break this SatinSegment up into SatinSegments of the specified size."""
+
+ num_segments = int(math.ceil(self.center_line.length / segment_size))
+ segments = []
+ satin = self.to_satin()
+ for i in xrange(num_segments):
+ segments.append(SatinSegment(satin, float(
+ i) / num_segments, float(i + 1) / num_segments, self.reverse))
+
+ if self.reverse:
+ segments.reverse()
+
+ return segments
+
+ def reversed(self):
+ """Return a copy of this SatinSegment in the opposite direction."""
+ return SatinSegment(self.satin, self.start, self.end, not self.reverse)
+
+ @property
+ def center_line(self):
+ center_line = self.satin.center_line
+
+ if self.start < 1.0:
+ before, center_line = cut(center_line, self.start, normalized=True)
+
+ if self.end > 0.0:
+ center_line, after = cut(
+ center_line, (self.end - self.start) / (1.0 - self.start), normalized=True)
+
+ if self.reverse:
+ center_line = shgeo.LineString(reversed(center_line.coords))
+
+ return center_line
+
+ @property
+ @cache
+ def start_point(self):
+ return self.satin.center_line.interpolate(self.start, normalized=True)
+
+ @property
+ @cache
+ def end_point(self):
+ return self.satin.center_line.interpolate(self.end, normalized=True)
+
+ def is_sequential(self, other):
+ """Check if a satin segment immediately follows this one on the same satin."""
+
+ if not isinstance(other, SatinSegment):
+ return False
+
+ if self.satin is not other.satin:
+ return False
+
+ if self.reverse != other.reverse:
+ return False
+
+ if self.reverse:
+ return self.start == other.end
+ else:
+ return self.end == other.start
+
+ def __add__(self, other):
+ """Combine two sequential SatinSegments.
+
+ If self.is_sequential(other) is not True then adding results in
+ undefined behavior.
+ """
+ if self.reverse:
+ return SatinSegment(self.satin, other.start, self.end, reverse=True)
+ else:
+ return SatinSegment(self.satin, self.start, other.end)
+
+ def __eq__(self, other):
+ # Two SatinSegments are equal if they refer to the same section of the same
+ # satin (even if in opposite directions).
+ return self.satin is other.satin and self.start == other.start and self.end == other.end
+
+ def __hash__(self):
+ return hash((id(self.satin), self.start, self.end))
+
+ def __repr__(self):
+ return "SatinSegment(%s, %s, %s, %s)" % (self.satin, self.start, self.end, self.reverse)
+
+
+class JumpStitch(object):
+ """A jump stitch between two points."""
+
+ def __init__(self, start, end):
+ """Initialize a JumpStitch.
+
+ Arguments:
+ start, end -- instances of shgeo.Point
+ """
+
+ self.start = start
+ self.end = end
+
+ def is_sequential(self, other):
+ # Don't bother joining jump stitches.
+ return False
+
+ @property
+ @cache
+ def length(self):
+ return self.start.distance(self.end)
+
+
+class RunningStitch(object):
+ """Running stitch along a path."""
+
+ def __init__(self, path_or_stroke, original_element=None):
+ if isinstance(path_or_stroke, Stroke):
+ # Technically a Stroke object's underlying path could have multiple
+ # subpaths. We don't have a particularly good way of dealing with
+ # that so we'll just use the first one.
+ self.path = path_or_stroke.shape.geoms[0]
+ original_element = path_or_stroke
+ else:
+ self.path = path_or_stroke
+
+ self.original_element = original_element
+ self.style = original_element.node.get('style', '')
+ self.running_stitch_length = \
+ original_element.node.get('embroider_running_stitch_length_mm', '') or \
+ original_element.node.get('embroider_center_walk_underlay_stitch_length_mm', '') or \
+ original_element.node.get('embroider_contour_underlay_stitch_length_mm', '')
+
+ def to_element(self):
+ node = inkex.etree.Element(SVG_PATH_TAG)
+ node.set("d", cubicsuperpath.formatPath(
+ line_strings_to_csp([self.path])))
+
+ style = simplestyle.parseStyle(self.style)
+ style['stroke-dasharray'] = "0.5,0.5"
+ style = simplestyle.formatStyle(style)
+ node.set("style", style)
+
+ node.set("embroider_running_stitch_length_mm", self.running_stitch_length)
+
+ return Stroke(node)
+
+ @property
+ @cache
+ def start_point(self):
+ return self.path.interpolate(0.0, normalized=True)
+
+ @property
+ @cache
+ def end_point(self):
+ return self.path.interpolate(1.0, normalized=True)
+
+ @cache
+ def reversed(self):
+ return RunningStitch(shgeo.LineString(reversed(self.path.coords)), self.style)
+
+ def is_sequential(self, other):
+ if not isinstance(other, RunningStitch):
+ return False
+
+ return self.path.distance(other.path) < 0.5
+
+ def __add__(self, other):
+ new_path = shgeo.LineString(chain(self.path.coords, other.path.coords))
+ return RunningStitch(new_path, self.original_element)
+
+
+def auto_satin(elements, preserve_order=False, starting_point=None, ending_point=None):
+ """Find an optimal order to stitch a list of SatinColumns.
+
+ Add running stitch and jump stitches as necessary to construct a stitch
+ order. Cut satins as necessary to minimize jump stitch length.
+
+ For example, consider three satins making up the letters "PO":
+
+ * one vertical satin for the "P"
+ * the loop of the "P"
+ * the "O"
+
+ A good stitch path would be:
+
+ 1. up the leg
+ 2. down through half of the loop
+ 3. running stitch to the bottom of the loop
+ 4. satin stitch back up to the middle of the loop
+ 5. jump to the closest point on the O
+ 6. satin stitch around the O
+
+ If passed, stitching will start from starting_point and end at
+ ending_point. It is expected that the starting and ending points will
+ fall on satin columns in the list. If they don't, the nearest
+ point on a satin column in the list will be used.
+
+ If preserve_order is True, then the algorithm is constrained to keep the
+ satins in the same order they were in the original list. It will only split
+ them and add running stitch as necessary to achieve an optimal stitch path.
+
+ Elements should be primarily made up of SatinColumn instances. Some Stroke
+ instances (that are running stitch) can be included to indicate how to travel
+ between two SatinColumns. This works best when preserve_order is True.
+
+ Returns: a list of SVG path nodes making up the selected stitch order.
+ Jumps between objects are implied if they are not right next to each
+ other.
+ """
+
+ graph = build_graph(elements, preserve_order)
+ add_jumps(graph, elements, preserve_order)
+ starting_node, ending_node = get_starting_and_ending_nodes(
+ graph, elements, preserve_order, starting_point, ending_point)
+ path = find_path(graph, starting_node, ending_node)
+ operations = path_to_operations(graph, path)
+ operations = collapse_sequential_segments(operations)
+ return operations_to_elements_and_trims(operations)
+
+
+def build_graph(elements, preserve_order=False):
+ if preserve_order:
+ graph = nx.DiGraph()
+ else:
+ graph = nx.Graph()
+
+ # Take each satin and dice it up into pieces 1mm long. This allows many
+ # possible spots for jump-stitches between satins. NetworkX will find the
+ # best spots for us.
+
+ for element in elements:
+ segments = []
+ if isinstance(element, Stroke):
+ segments.append(RunningStitch(element))
+ else:
+ whole_satin = SatinSegment(element)
+ segments = whole_satin.break_up(PIXELS_PER_MM)
+
+ for segment in segments:
+ # This is necessary because shapely points aren't hashable and thus
+ # can't be used as nodes directly.
+ graph.add_node(str(segment.start_point), point=segment.start_point)
+ graph.add_node(str(segment.end_point), point=segment.end_point)
+ graph.add_edge(str(segment.start_point), str(
+ segment.end_point), segment=segment, element=element)
+
+ if preserve_order:
+ # The graph is a directed graph, but we want to allow travel in
+ # any direction in a satin, so we add the edge in the opposite
+ # direction too.
+ graph.add_edge(str(segment.end_point), str(
+ segment.start_point), segment=segment, element=element)
+
+ return graph
+
+
+def get_starting_and_ending_nodes(graph, elements, preserve_order, starting_point, ending_point):
+ """Find or choose the starting and ending graph nodes.
+
+ If points were passed, we'll find the nearest graph nodes. Since we split
+ every satin up into 1mm-chunks, we'll be at most 1mm away which is good
+ enough.
+
+ If we weren't given starting and ending points, we'll pic kthe far left and
+ right nodes.
+
+ returns:
+ (starting graph node, ending graph node)
+ """
+
+ nodes = []
+
+ nodes.append(find_node(graph, starting_point,
+ min, preserve_order, elements[0]))
+ nodes.append(find_node(graph, ending_point,
+ max, preserve_order, elements[-1]))
+
+ return nodes
+
+
+def find_node(graph, point, extreme_function, constrain_to_satin=False, satin=None):
+ if constrain_to_satin:
+ nodes = get_nodes_on_element(graph, satin)
+ else:
+ nodes = graph.nodes()
+
+ if point is None:
+ return extreme_function(nodes, key=lambda node: graph.nodes[node]['point'].x)
+ else:
+ point = shgeo.Point(*point)
+ return min(nodes, key=lambda node: graph.nodes[node]['point'].distance(point))
+
+
+def get_nodes_on_element(graph, element):
+ nodes = set()
+
+ for start_node, end_node, element_for_edge in graph.edges(data='element'):
+ if element_for_edge is element:
+ nodes.add(start_node)
+ nodes.add(end_node)
+
+ return nodes
+
+
+def add_jumps(graph, elements, preserve_order):
+ """Add jump stitches between elements as necessary.
+
+ Jump stitches are added to ensure that all elements can be reached. Only the
+ minimal number and length of jumps necessary will be added.
+ """
+
+ if preserve_order:
+ # For each sequential pair of elements, find the shortest possible jump
+ # stitch between them and add it. The directions of these new edges
+ # will enforce stitching the satins in order.
+
+ for element1, element2 in zip(elements[:-1], elements[1:]):
+ potential_edges = []
+
+ nodes1 = get_nodes_on_element(graph, element1)
+ nodes2 = get_nodes_on_element(graph, element2)
+
+ for node1 in nodes1:
+ for node2 in nodes2:
+ point1 = graph.nodes[node1]['point']
+ point2 = graph.nodes[node2]['point']
+ potential_edges.append((point1, point2))
+
+ edge = min(potential_edges, key=lambda (p1, p2): p1.distance(p2))
+ graph.add_edge(str(edge[0]), str(edge[1]), jump=True)
+ else:
+ # networkx makes this super-easy! k_edge_agumentation tells us what edges
+ # we need to add to ensure that the graph is fully connected. We give it a
+ # set of possible edges that it can consider adding (avail). Each edge has
+ # a weight, which we'll set as the length of the jump stitch. The
+ # algorithm will minimize the total length of jump stitches added.
+ for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))):
+ graph.add_edge(*jump, jump=True)
+
+
+def possible_jumps(graph):
+ """All possible jump stitches in the graph with their lengths.
+
+ Returns: a generator of tuples: (node1, node2, length)
+ """
+
+ # We'll take the easy approach and list all edges that aren't already in
+ # the graph. networkx's algorithm is pretty efficient at ignoring
+ # pointless options like jumping between two points on the same satin.
+
+ for start, end in nx.complement(graph).edges():
+ start_point = graph.nodes[start]['point']
+ end_point = graph.nodes[end]['point']
+ yield (start, end, start_point.distance(end_point))
+
+
+def find_path(graph, starting_node, ending_node):
+ """Find a path through the graph that sews every satin."""
+
+ # This is done in two steps. First, we find the shortest path from the
+ # start to the end. We remove it from the graph, and proceed to step 2.
+ #
+ # Then, we traverse the path node by node. At each node, we follow any
+ # branchings with a depth-first search. We travel down each branch of
+ # the tree, inserting each seen branch into the tree. When the DFS
+ # hits a dead-end, as it back-tracks, we also add the seen edges _again_.
+ # Repeat until there are no more edges left in the graph.
+ #
+ # Visiting the edges again on the way back allows us to set up
+ # "underpathing". As we stitch down each branch, we'll do running stitch.
+ # Then when we come back up, we'll do satin stitch, covering the previous
+ # running stitch.
+ path = nx.shortest_path(graph, starting_node, ending_node)
+
+ # Copy the graph so that we can remove the edges as we visit them.
+ # This also converts the directed graph into an undirected graph in the
+ # case that "preserve_order" is set. This way we avoid going back and
+ # forth on each satin twice due to the satin edges being in the graph
+ # twice (forward and reverse).
+ graph = nx.Graph(graph)
+ graph.remove_edges_from(zip(path[:-1], path[1:]))
+
+ final_path = []
+ prev = None
+ for node in path:
+ if prev is not None:
+ final_path.append((prev, node))
+ prev = node
+
+ for n1, n2, edge_type in list(nx.dfs_labeled_edges(graph, node)):
+ if n1 == n2:
+ # dfs_labeled_edges gives us (start, start, "forward") for
+ # the starting node for some reason
+ continue
+
+ if edge_type == "forward":
+ final_path.append((n1, n2))
+ graph.remove_edge(n1, n2)
+ elif edge_type == "reverse":
+ final_path.append((n2, n1))
+ elif edge_type == "nontree":
+ # a "nontree" happens when there exists an edge from n1 to n2
+ # but n2 has already been visited. It's a dead-end that runs
+ # into part of the graph that we've already traversed. We
+ # do still need to make sure that satin is sewn, so we travel
+ # down and back on this edge.
+ #
+ # It's possible for a given "nontree" edge to be listed more
+ # than once so we'll deduplicate.
+ if (n1, n2) in graph.edges:
+ final_path.append((n1, n2))
+ final_path.append((n2, n1))
+ graph.remove_edge(n1, n2)
+
+ return final_path
+
+
+def reversed_path(path):
+ """Generator for a version of the path travelling in the opposite direction.
+
+ Example:
+
+ [(1, 2), (2, 3), (3, 4), (4, 5), (5, 1)] =>
+ [(1, 5), (5, 4), (4, 3), (3, 2), (2, 1)]
+ """
+
+ for node1, node2 in reversed(path):
+ yield (node2, node1)
+
+
+def path_to_operations(graph, path):
+ """Convert an edge path to a list of SatinSegment and JumpStitch instances."""
+
+ graph = nx.Graph(graph)
+
+ operations = []
+
+ for start, end in path:
+ segment = graph[start][end].get('segment')
+ if segment:
+ start_point = graph.nodes[start]['point']
+ if segment.start_point != start_point:
+ segment = segment.reversed()
+ operations.append(segment)
+ else:
+ operations.append(JumpStitch(graph.nodes[start]['point'], graph.nodes[end]['point']))
+
+ # find_path() will have duplicated some of the edges in the graph. We don't
+ # want to sew the same satin twice. If a satin section appears twice in the
+ # path, we'll sew the first occurrence as running stitch. It will later be
+ # covered by the satin stitch.
+ seen = set()
+
+ for i, item in reversed(list(enumerate(operations))):
+ if isinstance(item, SatinSegment):
+ if item in seen:
+ operations[i] = item.to_running_stitch()
+ else:
+ seen.add(item)
+
+ return operations
+
+
+def collapse_sequential_segments(old_operations):
+ old_operations = iter(old_operations)
+ new_operations = [next(old_operations)]
+
+ for operation in old_operations:
+ if new_operations[-1].is_sequential(operation):
+ new_operations[-1] += operation
+ else:
+ new_operations.append(operation)
+
+ return new_operations
+
+
+def operations_to_elements_and_trims(operations):
+ """Convert a list of operations to Elements and locations of trims.
+
+ Returns:
+ (nodes, trims)
+
+ element -- a list of Element instances
+ trims -- indices of nodes after which the thread should be trimmed
+ """
+
+ elements = []
+ trims = []
+
+ for operation in operations:
+ # Ignore JumpStitch opertions. Jump stitches in Ink/Stitch are
+ # implied and added by Embroider if needed.
+ if isinstance(operation, (SatinSegment, RunningStitch)):
+ elements.append(operation.to_element())
+ elif isinstance(operation, (JumpStitch)):
+ if elements and operation.length > PIXELS_PER_MM:
+ trims.append(len(elements) - 1)
+
+ return elements, list(set(trims))