kopia lustrzana https://github.com/inkstitch/inkstitch
558 wiersze
21 KiB
Python
558 wiersze
21 KiB
Python
# -*- coding: UTF-8 -*-
|
|
|
|
from itertools import groupby, chain
|
|
import math
|
|
|
|
import networkx
|
|
from shapely import geometry as shgeo
|
|
from shapely.ops import snap
|
|
from shapely.strtree import STRtree
|
|
|
|
from ..debug import debug
|
|
from ..exceptions import InkstitchException
|
|
from ..i18n import _
|
|
from ..svg import PIXELS_PER_MM
|
|
from ..utils.geometry import Point as InkstitchPoint
|
|
from .fill import intersect_region_with_grating, stitch_row
|
|
from .running_stitch import running_stitch
|
|
|
|
|
|
class InvalidPath(InkstitchException):
|
|
pass
|
|
|
|
|
|
class PathEdge(object):
|
|
OUTLINE_KEYS = ("outline", "extra", "initial")
|
|
SEGMENT_KEY = "segment"
|
|
|
|
def __init__(self, nodes, key):
|
|
self.nodes = nodes
|
|
self._sorted_nodes = tuple(sorted(self.nodes))
|
|
self.key = key
|
|
|
|
def __getitem__(self, item):
|
|
return self.nodes[item]
|
|
|
|
def __hash__(self):
|
|
return hash((self._sorted_nodes, self.key))
|
|
|
|
def __eq__(self, other):
|
|
return self._sorted_nodes == other._sorted_nodes and self.key == other.key
|
|
|
|
def is_outline(self):
|
|
return self.key in self.OUTLINE_KEYS
|
|
|
|
def is_segment(self):
|
|
return self.key == self.SEGMENT_KEY
|
|
|
|
|
|
@debug.time
|
|
def auto_fill(shape,
|
|
angle,
|
|
row_spacing,
|
|
end_row_spacing,
|
|
max_stitch_length,
|
|
running_stitch_length,
|
|
staggers,
|
|
skip_last,
|
|
starting_point,
|
|
ending_point=None,
|
|
underpath=True):
|
|
|
|
fill_stitch_graph = build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing)
|
|
check_graph(fill_stitch_graph, shape, max_stitch_length)
|
|
travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
|
|
path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
|
|
result = path_to_stitches(path, travel_graph, fill_stitch_graph, angle, row_spacing,
|
|
max_stitch_length, running_stitch_length, staggers, skip_last)
|
|
|
|
return result
|
|
|
|
|
|
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 = shgeo.Point(*coords)
|
|
outlines = list(shape.boundary)
|
|
outline_indices = range(len(outlines))
|
|
closest = min(outline_indices, key=lambda index: outlines[index].distance(point))
|
|
|
|
return closest
|
|
|
|
|
|
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(shgeo.Point(*coords))
|
|
|
|
|
|
@debug.time
|
|
def build_fill_stitch_graph(shape, angle, row_spacing, end_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.
|
|
"""
|
|
|
|
debug.add_layer("auto-fill fill stitch")
|
|
|
|
# Convert the shape into a set of parallel line segments.
|
|
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 = 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", underpath_edges=[])
|
|
|
|
tag_nodes_with_outline_and_projection(graph, shape, graph.nodes())
|
|
add_edges_between_outline_nodes(graph, duplicate_every_other=True)
|
|
|
|
debug.log_graph(graph, "graph")
|
|
|
|
return graph
|
|
|
|
|
|
def tag_nodes_with_outline_and_projection(graph, shape, nodes):
|
|
for node in nodes:
|
|
outline_index = which_outline(shape, node)
|
|
outline_projection = project(shape, node, outline_index)
|
|
|
|
graph.add_node(node, outline=outline_index, projection=outline_projection)
|
|
|
|
|
|
def add_edges_between_outline_nodes(graph, duplicate_every_other=False):
|
|
"""Add edges around the outlines of the graph, connecting sequential nodes.
|
|
|
|
This function assumes that all nodes in the graph are on the outline of the
|
|
shape. It figures out which nodes are next to each other on the shape and
|
|
connects them in the graph with an edge.
|
|
|
|
Edges are tagged with their outline number and their position on that
|
|
outline.
|
|
"""
|
|
|
|
nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...]
|
|
nodes.sort(key=lambda node: (node[1]['outline'], node[1]['projection']))
|
|
|
|
for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['outline']):
|
|
nodes = [node for node, data in nodes]
|
|
|
|
# add an edge between each successive node
|
|
for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])):
|
|
data = dict(outline=outline_index, index=i)
|
|
graph.add_edge(node1, node2, key="outline", **data)
|
|
|
|
if i % 2 == 0:
|
|
graph.add_edge(node1, node2, key="extra", **data)
|
|
|
|
|
|
@debug.time
|
|
def build_travel_graph(fill_stitch_graph, shape, fill_stitch_angle, underpath):
|
|
"""Build a graph for travel stitches.
|
|
|
|
This graph will be used to find a stitch path between two spots on the
|
|
outline of the shape.
|
|
|
|
If underpath is False, we'll just be traveling
|
|
around the outline of the shape, so the graph will only contain outline
|
|
edges.
|
|
|
|
If underpath is True, we'll also allow travel inside the shape. We'll
|
|
fill the shape with a cross-hatched grid of lines. We'll construct a
|
|
graph from them and use a shortest path algorithm to construct travel
|
|
stitch paths in travel().
|
|
|
|
When underpathing, we "encourage" the travel() function to travel inside
|
|
the shape rather than on the boundary. We do this by weighting the
|
|
boundary edges extra so that they're more "expensive" in the shortest path
|
|
calculation. We also weight the interior edges extra proportional to
|
|
how close they are to the boundary.
|
|
"""
|
|
|
|
graph = networkx.MultiGraph()
|
|
|
|
# Add all the nodes from the main graph. This will be all of the endpoints
|
|
# of the rows of stitches. Every node will be on the outline of the shape.
|
|
# They'll all already have their `outline` and `projection` tags set.
|
|
graph.add_nodes_from(fill_stitch_graph.nodes(data=True))
|
|
|
|
if underpath:
|
|
boundary_points, travel_edges = build_travel_edges(shape, fill_stitch_angle)
|
|
|
|
# This will ensure that a path traveling inside the shape can reach its
|
|
# target on the outline, which will be one of the points added above.
|
|
tag_nodes_with_outline_and_projection(graph, shape, boundary_points)
|
|
|
|
add_edges_between_outline_nodes(graph)
|
|
|
|
if underpath:
|
|
process_travel_edges(graph, fill_stitch_graph, shape, travel_edges)
|
|
|
|
debug.log_graph(graph, "travel graph")
|
|
|
|
return graph
|
|
|
|
|
|
def weight_edges_by_length(graph, multiplier=1):
|
|
for start, end, key in graph.edges:
|
|
p1 = InkstitchPoint(*start)
|
|
p2 = InkstitchPoint(*end)
|
|
|
|
graph[start][end][key]["weight"] = multiplier * p1.distance(p2)
|
|
|
|
|
|
def get_segments(graph):
|
|
segments = []
|
|
for start, end, key, data in graph.edges(keys=True, data=True):
|
|
if key == 'segment':
|
|
segments.append(shgeo.LineString((start, end)))
|
|
|
|
return segments
|
|
|
|
|
|
def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges):
|
|
"""Weight the interior edges and pre-calculate intersection with fill stitch rows."""
|
|
|
|
# Set the weight equal to 5x the edge length, to encourage travel()
|
|
# to avoid them.
|
|
weight_edges_by_length(graph, 5)
|
|
|
|
segments = get_segments(fill_stitch_graph)
|
|
|
|
# The shapely documentation is pretty unclear on this. An STRtree
|
|
# allows for building a set of shapes and then efficiently testing
|
|
# the set for intersection. This allows us to do blazing-fast
|
|
# queries of which line segments overlap each underpath edge.
|
|
strtree = STRtree(segments)
|
|
|
|
# This makes the distance calculations below a bit faster. We're
|
|
# not looking for high precision anyway.
|
|
outline = shape.boundary.simplify(0.5 * PIXELS_PER_MM, preserve_topology=False)
|
|
|
|
for ls in travel_edges:
|
|
# In most cases, ls will be a simple line segment. If we're
|
|
# unlucky, in rare cases we can get a tiny little extra squiggle
|
|
# at the end that can be ignored.
|
|
points = [InkstitchPoint(*coord) for coord in ls.coords]
|
|
p1, p2 = points[0], points[-1]
|
|
|
|
edge = (p1.as_tuple(), p2.as_tuple(), 'travel')
|
|
|
|
for segment in strtree.query(ls):
|
|
# It seems like the STRTree only gives an approximate answer of
|
|
# segments that _might_ intersect ls. Refining the result is
|
|
# necessary but the STRTree still saves us a ton of time.
|
|
if segment.crosses(ls):
|
|
start, end = segment.coords
|
|
fill_stitch_graph[start][end]['segment']['underpath_edges'].append(edge)
|
|
|
|
# The weight of a travel edge is the length of the line segment.
|
|
weight = p1.distance(p2)
|
|
|
|
# Give a bonus to edges that are far from the outline of the shape.
|
|
# This includes the outer outline and the outlines of the holes.
|
|
# The result is that travel stitching will tend to hug the center
|
|
# of the shape.
|
|
weight /= ls.distance(outline) + 0.1
|
|
|
|
graph.add_edge(*edge, weight=weight)
|
|
|
|
# without this, we sometimes get exceptions like this:
|
|
# Exception AttributeError: "'NoneType' object has no attribute 'GEOSSTRtree_destroy'" in
|
|
# <bound method STRtree.__del__ of <shapely.strtree.STRtree instance at 0x0D2BFD50>> ignored
|
|
del strtree
|
|
|
|
|
|
def travel_grating(shape, angle, row_spacing):
|
|
rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing)
|
|
segments = list(chain(*rows_of_segments))
|
|
|
|
return shgeo.MultiLineString(segments)
|
|
|
|
|
|
def build_travel_edges(shape, fill_angle):
|
|
r"""Given a graph, compute the interior travel edges.
|
|
|
|
We want to fill the shape with a grid of line segments that can be used for
|
|
travel stitch routing. Our goals:
|
|
|
|
* not too many edges so that the shortest path algorithm is speedy
|
|
* don't travel in the direction of the fill stitch rows so that the
|
|
travel stitch doesn't visually disrupt the fill stitch pattern
|
|
|
|
To do this, we'll fill the shape with three gratings: one at +45 degrees
|
|
from the fill stitch angle, one at -45 degrees, and one at +90 degrees.
|
|
The pattern looks like this:
|
|
|
|
/|\|/|\|/|\
|
|
\|/|\|/|\|/
|
|
/|\|/|\|/|\
|
|
\|/|\|/|\|/
|
|
|
|
Returns: (endpoints, edges)
|
|
endpoints - the points on travel edges that intersect with the boundary
|
|
of the shape
|
|
edges - the line segments we can travel on, as individual LineString
|
|
instances
|
|
"""
|
|
|
|
# If the shape is smaller, we'll have less room to maneuver and it's more likely
|
|
# we'll travel around the outside border of the shape. Counteract that by making
|
|
# the grid denser.
|
|
if shape.area < 10000:
|
|
scale = 0.5
|
|
else:
|
|
scale = 1.0
|
|
|
|
grating1 = travel_grating(shape, fill_angle + math.pi / 4, scale * 2 * PIXELS_PER_MM)
|
|
grating2 = travel_grating(shape, fill_angle - math.pi / 4, scale * 2 * PIXELS_PER_MM)
|
|
grating3 = travel_grating(shape, fill_angle - math.pi / 2, scale * math.sqrt(2) * PIXELS_PER_MM)
|
|
|
|
debug.add_layer("auto-fill travel")
|
|
debug.log_line_strings(grating1, "grating1")
|
|
debug.log_line_strings(grating2, "grating2")
|
|
debug.log_line_strings(grating3, "grating3")
|
|
|
|
endpoints = [coord for mls in (grating1, grating2, grating3)
|
|
for ls in mls
|
|
for coord in ls.coords]
|
|
|
|
diagonal_edges = grating1.symmetric_difference(grating2)
|
|
|
|
# without this, floating point inaccuracies prevent the intersection points from lining up perfectly.
|
|
vertical_edges = snap(grating3.difference(grating1), diagonal_edges, 0.005)
|
|
|
|
return endpoints, chain(diagonal_edges, vertical_edges)
|
|
|
|
|
|
def check_graph(graph, shape, max_stitch_length):
|
|
if networkx.is_empty(graph) or not networkx.is_eulerian(graph):
|
|
if shape.area < max_stitch_length ** 2:
|
|
message = "This shape is so small that it cannot be filled with rows of stitches. " \
|
|
"It would probably look best as a satin column or running stitch."
|
|
raise InvalidPath(_(message))
|
|
else:
|
|
message = "Cannot parse shape. " \
|
|
"This most often happens because your shape is made up of multiple sections that aren't connected."
|
|
raise InvalidPath(_(message))
|
|
|
|
|
|
def nearest_node(nodes, point, attr=None):
|
|
point = shgeo.Point(*point)
|
|
nearest = min(nodes, key=lambda node: shgeo.Point(*node).distance(point))
|
|
|
|
return nearest
|
|
|
|
|
|
@debug.time
|
|
def find_stitch_path(graph, travel_graph, starting_point=None, ending_point=None):
|
|
"""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 Eulerian Path using Hierholzer's algorithm. A true
|
|
Eulerian Path would visit every single edge (including all the extras
|
|
we inserted in build_graph()),but we'll stop short once we've visited
|
|
every grating segment since that's all we really care about.
|
|
|
|
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 starting node 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.
|
|
"""
|
|
|
|
graph = graph.copy()
|
|
|
|
if not starting_point:
|
|
starting_point = graph.nodes.keys()[0]
|
|
|
|
starting_node = nearest_node(graph, starting_point)
|
|
|
|
if ending_point:
|
|
ending_node = nearest_node(graph, ending_point)
|
|
else:
|
|
ending_point = starting_point
|
|
ending_node = starting_node
|
|
|
|
# The algorithm below is adapted from networkx.eulerian_circuit().
|
|
path = []
|
|
vertex_stack = [(ending_node, None)]
|
|
last_vertex = None
|
|
last_key = None
|
|
|
|
while vertex_stack:
|
|
current_vertex, current_key = vertex_stack[-1]
|
|
if graph.degree(current_vertex) == 0:
|
|
if last_vertex:
|
|
path.append(PathEdge((last_vertex, current_vertex), last_key))
|
|
last_vertex, last_key = current_vertex, current_key
|
|
vertex_stack.pop()
|
|
else:
|
|
ignore, next_vertex, next_key = pick_edge(graph.edges(current_vertex, keys=True))
|
|
vertex_stack.append((next_vertex, next_key))
|
|
graph.remove_edge(current_vertex, next_vertex, next_key)
|
|
|
|
# The above has the excellent property that it tends to do travel stitches
|
|
# before the rows in that area, so we can hide the travel stitches under
|
|
# the rows.
|
|
#
|
|
# The only downside is that the path is a loop starting and ending at the
|
|
# ending node. We need to start at the starting node, so we'll just
|
|
# start off by traveling to the ending node.
|
|
#
|
|
# Note, it's quite possible that part of this PathEdge will be eliminated by
|
|
# collapse_sequential_outline_edges().
|
|
|
|
if starting_node is not ending_node:
|
|
path.insert(0, PathEdge((starting_node, ending_node), key="initial"))
|
|
|
|
# If the starting and/or ending point falls far away from the end of a row
|
|
# of stitches (like can happen at the top of a square), then we need to
|
|
# add travel stitch to that point.
|
|
real_start = nearest_node(travel_graph, starting_point)
|
|
path.insert(0, PathEdge((real_start, starting_node), key="outline"))
|
|
|
|
# We're willing to start inside the shape, since we'll just cover the
|
|
# stitches. We have to end on the outline of the shape. This is mostly
|
|
# relevant in the case that the user specifies an underlay with an inset
|
|
# value, because the starting point (and possibly ending point) can be
|
|
# inside the shape.
|
|
outline_nodes = [node for node, outline in travel_graph.nodes(data="outline") if outline is not None]
|
|
real_end = nearest_node(outline_nodes, ending_point)
|
|
path.append(PathEdge((ending_node, real_end), key="outline"))
|
|
|
|
return path
|
|
|
|
|
|
def pick_edge(edges):
|
|
"""Pick the next edge to traverse in the pathfinding algorithm"""
|
|
|
|
# Prefer a segment if one is available. This has the effect of
|
|
# creating long sections of back-and-forth row traversal.
|
|
for source, node, key in edges:
|
|
if key == 'segment':
|
|
return source, node, key
|
|
|
|
return list(edges)[0]
|
|
|
|
|
|
def collapse_sequential_outline_edges(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 edge.is_segment():
|
|
if start_of_run:
|
|
# close off the last run
|
|
new_path.append(PathEdge((start_of_run, edge[0]), "collapsed"))
|
|
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(PathEdge((start_of_run, edge[1]), "collapsed"))
|
|
|
|
return new_path
|
|
|
|
|
|
def travel(travel_graph, start, end, running_stitch_length, skip_last):
|
|
"""Create stitches to get from one point on an outline of the shape to another."""
|
|
|
|
path = networkx.shortest_path(travel_graph, start, end, weight='weight')
|
|
path = [InkstitchPoint(*p) for p in path]
|
|
stitches = running_stitch(path, running_stitch_length)
|
|
|
|
# The path's first stitch will start at the end of a row of stitches. We
|
|
# don't want to double that last stitch, so we'd like to skip it.
|
|
if skip_last and len(path) > 2:
|
|
# However, we don't want to skip it if we've had to do any actual
|
|
# travel in the interior of the shape. The reason is that we can
|
|
# potentially cut a corner and stitch outside the shape.
|
|
#
|
|
# If the path is longer than two nodes, then it is not a simple
|
|
# transition from one row to the next, so we'll keep the stitch.
|
|
return stitches
|
|
else:
|
|
# Just a normal transition from one row to the next, so skip the first
|
|
# stitch.
|
|
return stitches[1:]
|
|
|
|
|
|
@debug.time
|
|
def path_to_stitches(path, travel_graph, fill_stitch_graph, angle, row_spacing, max_stitch_length, running_stitch_length, staggers, skip_last):
|
|
path = collapse_sequential_outline_edges(path)
|
|
|
|
stitches = []
|
|
|
|
# If the very first stitch is travel, we'll omit it in travel(), so add it here.
|
|
if not path[0].is_segment():
|
|
stitches.append(InkstitchPoint(*path[0].nodes[0]))
|
|
|
|
for edge in path:
|
|
if edge.is_segment():
|
|
stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers, skip_last)
|
|
travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
|
|
else:
|
|
stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, skip_last))
|
|
|
|
return stitches
|