2022-04-06 12:12:45 +00:00
|
|
|
from enum import IntEnum
|
|
|
|
|
2022-04-22 03:09:05 +00:00
|
|
|
import networkx as nx
|
2022-05-03 20:58:55 +00:00
|
|
|
from shapely.geometry import Polygon, MultiPolygon, GeometryCollection
|
2021-10-21 14:24:40 +00:00
|
|
|
from shapely.geometry.polygon import orient
|
2022-04-06 12:12:45 +00:00
|
|
|
from shapely.ops import polygonize
|
|
|
|
|
2022-05-03 03:48:46 +00:00
|
|
|
from .running_stitch import running_stitch
|
|
|
|
from ..stitch_plan import Stitch
|
2021-10-21 14:24:40 +00:00
|
|
|
from ..stitches import constants
|
2022-04-06 12:12:45 +00:00
|
|
|
from ..stitches import tangential_fill_stitch_pattern_creator
|
2022-04-22 03:09:05 +00:00
|
|
|
from ..utils import DotDict
|
2022-05-03 20:58:55 +00:00
|
|
|
from ..utils.geometry import reverse_line_string, ensure_geometry_collection, ensure_multi_polygon
|
2022-05-01 17:16:23 +00:00
|
|
|
|
2022-04-22 03:09:05 +00:00
|
|
|
|
|
|
|
class Tree(nx.DiGraph):
|
|
|
|
# This lets us do tree.nodes['somenode'].parent instead of the default
|
|
|
|
# tree.nodes['somenode']['parent'].
|
|
|
|
node_attr_dict_factory = DotDict
|
2021-10-21 14:24:40 +00:00
|
|
|
|
|
|
|
|
2022-03-21 19:39:06 +00:00
|
|
|
def offset_linear_ring(ring, offset, resolution, join_style, mitre_limit):
|
2022-05-03 20:58:55 +00:00
|
|
|
result = Polygon(ring).buffer(-offset, resolution, cap_style=2, join_style=join_style, mitre_limit=mitre_limit, single_sided=True)
|
|
|
|
result = ensure_multi_polygon(result)
|
2021-10-29 14:18:22 +00:00
|
|
|
|
2022-05-03 20:58:55 +00:00
|
|
|
rings = GeometryCollection([poly.exterior for poly in result.geoms])
|
|
|
|
rings = rings.simplify(constants.simplification_threshold, False)
|
|
|
|
|
|
|
|
return take_only_valid_linear_rings(rings)
|
2021-10-21 14:24:40 +00:00
|
|
|
|
2022-03-21 19:39:06 +00:00
|
|
|
|
2021-10-21 14:24:40 +00:00
|
|
|
def take_only_valid_linear_rings(rings):
|
2021-10-29 14:18:22 +00:00
|
|
|
"""
|
|
|
|
Removes all geometries which do not form a "valid" LinearRing
|
|
|
|
(meaning a ring which does not form a straight line)
|
|
|
|
"""
|
2022-05-03 20:58:55 +00:00
|
|
|
|
|
|
|
valid_rings = []
|
|
|
|
|
|
|
|
for ring in ensure_geometry_collection(rings).geoms:
|
|
|
|
if len(ring.coords) > 3 or (len(ring.coords) == 3 and ring.coords[0] != ring.coords[-1]):
|
|
|
|
valid_rings.append(ring)
|
|
|
|
|
|
|
|
return GeometryCollection(valid_rings)
|
2021-10-21 14:24:40 +00:00
|
|
|
|
|
|
|
|
2022-05-03 03:48:46 +00:00
|
|
|
def orient_linear_ring(ring, clockwise=True):
|
|
|
|
# Unfortunately for us, Inkscape SVGs have an inverted Y coordinate.
|
|
|
|
# Normally we don't have to care about that, but in this very specific
|
|
|
|
# case, the meaning of is_ccw is flipped. It actually tests whether
|
|
|
|
# a ring is clockwise. That makes this logic super-confusing.
|
|
|
|
if ring.is_ccw != clockwise:
|
|
|
|
return reverse_line_string(ring)
|
|
|
|
else:
|
|
|
|
return ring
|
|
|
|
|
|
|
|
|
|
|
|
def make_tree_uniform(tree, clockwise=True):
|
2021-10-29 14:18:22 +00:00
|
|
|
"""
|
|
|
|
Since naturally holes have the opposite point ordering than non-holes we
|
|
|
|
make all lines within the tree "root" uniform (having all the same
|
|
|
|
ordering direction)
|
|
|
|
"""
|
2022-05-03 03:48:46 +00:00
|
|
|
|
|
|
|
for node in tree.nodes.values():
|
|
|
|
node.val = orient_linear_ring(node.val, clockwise)
|
2021-10-21 14:24:40 +00:00
|
|
|
|
|
|
|
|
2021-10-29 14:18:22 +00:00
|
|
|
# Used to define which stitching strategy shall be used
|
2021-10-21 14:24:40 +00:00
|
|
|
class StitchingStrategy(IntEnum):
|
2022-04-29 03:25:52 +00:00
|
|
|
INNER_TO_OUTER = 0
|
2022-05-03 18:34:21 +00:00
|
|
|
SINGLE_SPIRAL = 1
|
|
|
|
DOUBLE_SPIRAL = 2
|
2021-10-21 14:24:40 +00:00
|
|
|
|
2021-10-29 14:18:22 +00:00
|
|
|
|
2022-04-22 03:09:05 +00:00
|
|
|
def check_and_prepare_tree_for_valid_spiral(tree):
|
2021-11-21 11:44:06 +00:00
|
|
|
"""
|
|
|
|
Takes a tree consisting of offsetted curves. If a parent has more than one child we
|
|
|
|
cannot create a spiral. However, to make the routine more robust, we allow more than
|
|
|
|
one child if only one of the childs has own childs. The other childs are removed in this
|
|
|
|
routine then. If the routine returns true, the tree will have been cleaned up from unwanted
|
|
|
|
childs. If the routine returns false even under the mentioned weaker conditions the
|
|
|
|
tree cannot be connected by one spiral.
|
|
|
|
"""
|
2022-04-22 03:09:05 +00:00
|
|
|
|
|
|
|
def process_node(node):
|
|
|
|
children = set(tree[node])
|
|
|
|
|
|
|
|
if len(children) == 0:
|
|
|
|
return True
|
|
|
|
elif len(children) == 1:
|
|
|
|
child = children.pop()
|
|
|
|
return process_node(child)
|
|
|
|
else:
|
|
|
|
children_with_children = {child for child in children if tree[child]}
|
|
|
|
if len(children_with_children) > 1:
|
|
|
|
# Node has multiple children with children, so a perfect spiral is not possible.
|
|
|
|
# This False value will be returned all the way up the stack.
|
2021-11-21 11:44:06 +00:00
|
|
|
return False
|
2022-04-22 03:09:05 +00:00
|
|
|
elif len(children_with_children) == 1:
|
|
|
|
children_without_children = children - children_with_children
|
|
|
|
child = children_with_children.pop()
|
|
|
|
tree.remove_nodes_from(children_without_children)
|
|
|
|
return process_node(child)
|
|
|
|
else:
|
|
|
|
# None of the children has its own children, so we'll just take the longest.
|
|
|
|
longest = max(children, key=lambda child: tree[child]['val'].length)
|
|
|
|
shorter_children = children - {longest}
|
|
|
|
tree.remove_nodes_from(shorter_children)
|
|
|
|
return process_node(longest)
|
|
|
|
|
|
|
|
return process_node('root')
|
2021-11-21 11:44:06 +00:00
|
|
|
|
|
|
|
|
2022-05-03 20:58:55 +00:00
|
|
|
def offset_poly(poly, offset, join_style, clockwise):
|
2021-10-29 14:18:22 +00:00
|
|
|
"""
|
|
|
|
Takes a polygon (which can have holes) as input and creates offsetted
|
|
|
|
versions until the polygon is filled with these smaller offsets.
|
|
|
|
These created geometries are afterwards connected to each other and
|
|
|
|
resampled with a maximum stitch_distance.
|
|
|
|
The return value is a LineString which should cover the full polygon.
|
|
|
|
Input:
|
|
|
|
-poly: The shapely polygon which can have holes
|
|
|
|
-offset: The used offset for the curves
|
|
|
|
-join_style: Join style for the offset - can be round, mitered or bevel
|
|
|
|
(https://shapely.readthedocs.io/en/stable/manual.html#shapely.geometry.JOIN_STYLE)
|
|
|
|
For examples look at
|
|
|
|
https://shapely.readthedocs.io/en/stable/_images/parallel_offset.png
|
|
|
|
-stitch_distance maximum allowed stitch distance between two points
|
2022-03-20 16:15:39 +00:00
|
|
|
-min_stitch_distance stitches within a row shall be at least min_stitch_distance apart. Stitches connecting
|
|
|
|
offsetted paths might be shorter.
|
2021-10-29 14:18:22 +00:00
|
|
|
-offset_by_half: True if the points shall be interlaced
|
|
|
|
-strategy: According to StitchingStrategy enum class you can select between
|
2021-11-21 11:44:06 +00:00
|
|
|
different strategies for the connection between parent and childs. In
|
|
|
|
addition it offers the option "SPIRAL" which creates a real spiral towards inner.
|
|
|
|
In contrast to the other two options, "SPIRAL" does not end at the starting point
|
|
|
|
but at the innermost point
|
2021-10-29 14:18:22 +00:00
|
|
|
-starting_point: Defines the starting point for the stitching
|
2022-05-02 19:00:52 +00:00
|
|
|
-avoid_self_crossing: don't let the path cross itself when using the Inner to Outer strategy
|
2021-10-29 14:18:22 +00:00
|
|
|
Output:
|
|
|
|
-List of point coordinate tuples
|
|
|
|
-Tag (origin) of each point to analyze why a point was placed
|
|
|
|
at this position
|
|
|
|
"""
|
2021-11-21 11:44:06 +00:00
|
|
|
|
2021-10-21 14:24:40 +00:00
|
|
|
ordered_poly = orient(poly, -1)
|
2022-04-22 03:09:05 +00:00
|
|
|
tree = Tree()
|
2022-05-03 20:58:55 +00:00
|
|
|
tree.add_node('root', type='node', parent=None, val=ordered_poly.exterior)
|
2022-04-22 03:09:05 +00:00
|
|
|
active_polys = ['root']
|
2021-10-21 14:24:40 +00:00
|
|
|
active_holes = [[]]
|
|
|
|
|
2022-04-22 03:09:05 +00:00
|
|
|
# We don't care about the names of the nodes, we just need them to be unique.
|
|
|
|
node_num = 0
|
|
|
|
|
|
|
|
for hole in ordered_poly.interiors:
|
2022-05-03 20:58:55 +00:00
|
|
|
tree.add_node(node_num, type="hole", val=hole)
|
2022-04-22 03:09:05 +00:00
|
|
|
active_holes[0].append(node_num)
|
|
|
|
node_num += 1
|
2021-10-21 14:24:40 +00:00
|
|
|
|
2021-10-29 14:18:22 +00:00
|
|
|
while len(active_polys) > 0:
|
2021-10-21 14:24:40 +00:00
|
|
|
current_poly = active_polys.pop()
|
|
|
|
current_holes = active_holes.pop()
|
2022-05-03 20:58:55 +00:00
|
|
|
outer, inners = offset_polygon_and_holes(tree, current_poly, current_holes, offset, join_style)
|
2021-10-21 14:24:40 +00:00
|
|
|
|
|
|
|
if not outer.is_empty:
|
2022-05-03 20:58:55 +00:00
|
|
|
polygons = match_polygons_and_holes(outer, inners)
|
|
|
|
|
|
|
|
if not polygons.is_empty:
|
|
|
|
for polygon in polygons.geoms:
|
|
|
|
new_polygon, new_holes = convert_polygon_to_nodes(tree, polygon, parent_polygon=current_poly, child_holes=current_holes)
|
|
|
|
|
|
|
|
if new_polygon:
|
|
|
|
active_polys.append(new_polygon)
|
|
|
|
active_holes.append(new_holes)
|
|
|
|
|
2021-10-29 14:18:22 +00:00
|
|
|
for previous_hole in current_holes:
|
|
|
|
# If the previous holes are not
|
|
|
|
# contained in the new holes they
|
|
|
|
# have been merged with the
|
|
|
|
# outer polygon
|
2022-04-30 18:41:27 +00:00
|
|
|
if not tree.nodes[previous_hole].parent:
|
2022-04-22 03:09:05 +00:00
|
|
|
tree.nodes[previous_hole].parent = current_poly
|
|
|
|
tree.add_edge(current_poly, previous_hole)
|
2021-10-21 14:24:40 +00:00
|
|
|
|
2022-05-03 03:48:46 +00:00
|
|
|
make_tree_uniform(tree, clockwise)
|
2022-02-02 20:19:31 +00:00
|
|
|
|
2022-05-03 20:58:55 +00:00
|
|
|
return tree
|
|
|
|
|
|
|
|
|
|
|
|
def offset_polygon_and_holes(tree, poly, holes, offset, join_style):
|
|
|
|
outer = offset_linear_ring(
|
|
|
|
tree.nodes[poly].val,
|
|
|
|
offset,
|
|
|
|
resolution=5,
|
|
|
|
join_style=join_style,
|
|
|
|
mitre_limit=10,
|
|
|
|
)
|
|
|
|
|
|
|
|
inners = []
|
|
|
|
for hole in holes:
|
|
|
|
inner = offset_linear_ring(
|
|
|
|
tree.nodes[hole].val,
|
|
|
|
-offset, # take negative offset for holes
|
|
|
|
resolution=5,
|
|
|
|
join_style=join_style,
|
|
|
|
mitre_limit=10,
|
|
|
|
)
|
|
|
|
if not inner.is_empty:
|
|
|
|
inners.append(Polygon(inner.geoms[0]))
|
|
|
|
|
|
|
|
return outer, inners
|
|
|
|
|
|
|
|
|
|
|
|
def match_polygons_and_holes(outer, inners):
|
|
|
|
result = MultiPolygon(polygonize(outer))
|
|
|
|
if len(inners) > 0:
|
|
|
|
result = ensure_geometry_collection(result.difference(MultiPolygon(inners)))
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def convert_polygon_to_nodes(tree, polygon, parent_polygon, child_holes):
|
|
|
|
polygon = orient(polygon, -1)
|
|
|
|
|
|
|
|
if polygon.area < 0.1:
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
polygon = polygon.simplify(constants.simplification_threshold, False)
|
|
|
|
valid_rings = take_only_valid_linear_rings(polygon.exterior)
|
|
|
|
|
|
|
|
try:
|
|
|
|
exterior = valid_rings.geoms[0]
|
|
|
|
except IndexError:
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
node = id(polygon) # just needs to be unique
|
|
|
|
|
|
|
|
tree.add_node(node, type='node', parent=parent_polygon, val=exterior)
|
|
|
|
tree.add_edge(parent_polygon, node)
|
|
|
|
|
|
|
|
hole_node_list = []
|
|
|
|
for hole in polygon.interiors:
|
|
|
|
hole_node = id(hole)
|
|
|
|
tree.add_node(hole_node, type="hole", val=hole)
|
|
|
|
for previous_hole in child_holes:
|
|
|
|
if Polygon(hole).contains(Polygon(tree.nodes[previous_hole].val)):
|
|
|
|
tree.nodes[previous_hole].parent = hole_node
|
|
|
|
tree.add_edge(hole_node, previous_hole)
|
|
|
|
hole_node_list.append(hole_node)
|
|
|
|
|
|
|
|
return node, hole_node_list
|
|
|
|
|
|
|
|
|
|
|
|
def tangential_fill(poly, strategy, offset, stitch_distance, join_style, clockwise, starting_point, avoid_self_crossing):
|
|
|
|
if strategy in (StitchingStrategy.SINGLE_SPIRAL, StitchingStrategy.DOUBLE_SPIRAL) and len(poly.interiors) > 1:
|
|
|
|
raise ValueError(
|
|
|
|
"Single spiral geometry must not have more than one hole!")
|
|
|
|
|
|
|
|
tree = offset_poly(poly, offset, join_style, clockwise)
|
|
|
|
|
2022-04-29 03:25:52 +00:00
|
|
|
if strategy == StitchingStrategy.INNER_TO_OUTER:
|
2022-05-01 17:16:23 +00:00
|
|
|
connected_line = tangential_fill_stitch_pattern_creator.connect_raster_tree_from_inner_to_outer(
|
2022-05-03 20:58:55 +00:00
|
|
|
tree, 'root', offset, stitch_distance, starting_point, avoid_self_crossing)
|
2022-05-01 17:16:23 +00:00
|
|
|
path = [Stitch(*point) for point in connected_line.coords]
|
2022-05-03 20:58:55 +00:00
|
|
|
return running_stitch(path, stitch_distance)
|
2022-05-03 18:34:21 +00:00
|
|
|
elif strategy == StitchingStrategy.SINGLE_SPIRAL:
|
2022-04-22 03:09:05 +00:00
|
|
|
if not check_and_prepare_tree_for_valid_spiral(tree):
|
2021-11-21 11:44:06 +00:00
|
|
|
raise ValueError("Geometry cannot be filled with one spiral!")
|
2022-05-03 20:58:55 +00:00
|
|
|
connected_line = tangential_fill_stitch_pattern_creator.connect_raster_tree_single_spiral(
|
|
|
|
tree, offset, stitch_distance, starting_point)
|
2022-05-03 18:34:21 +00:00
|
|
|
elif strategy == StitchingStrategy.DOUBLE_SPIRAL:
|
|
|
|
if not check_and_prepare_tree_for_valid_spiral(tree):
|
|
|
|
raise ValueError("Geometry cannot be filled with a double spiral!")
|
2022-05-03 20:58:55 +00:00
|
|
|
connected_line = tangential_fill_stitch_pattern_creator.connect_raster_tree_double_spiral(
|
|
|
|
tree, offset, stitch_distance, starting_point)
|
2021-10-21 14:24:40 +00:00
|
|
|
else:
|
2021-10-29 14:18:22 +00:00
|
|
|
raise ValueError("Invalid stitching stratety!")
|
2021-10-21 14:24:40 +00:00
|
|
|
|
2022-05-03 20:58:55 +00:00
|
|
|
return connected_line
|