inkstitch/lib/stitches/guided_fill.py

263 wiersze
10 KiB
Python
Czysty Zwykły widok Historia

from math import atan2, copysign
from random import random
2022-06-22 14:11:12 +00:00
import numpy as np
import shapely.prepared
from shapely import geometry as shgeo
2022-06-22 14:11:12 +00:00
from shapely.affinity import translate
from shapely.ops import linemerge, nearest_points, unary_union
2022-02-18 14:36:01 +00:00
2022-06-22 14:11:12 +00:00
from ..debug import debug
2022-04-06 12:12:45 +00:00
from ..stitch_plan import Stitch
from ..utils.geometry import Point as InkstitchPoint
from ..utils.geometry import (ensure_geometry_collection,
ensure_multi_line_string, reverse_line_string)
from .auto_fill import (auto_fill, build_fill_stitch_graph, build_travel_graph,
collapse_sequential_outline_edges, find_stitch_path,
graph_is_valid, travel)
2022-02-18 14:36:01 +00:00
def guided_fill(shape,
guideline,
angle,
row_spacing,
2022-06-22 14:11:12 +00:00
num_staggers,
2022-02-18 14:36:01 +00:00
max_stitch_length,
running_stitch_length,
2022-06-22 14:11:12 +00:00
running_stitch_tolerance,
2022-02-18 14:36:01 +00:00
skip_last,
starting_point,
2022-06-22 14:11:12 +00:00
ending_point,
underpath,
strategy
):
segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing, num_staggers, max_stitch_length, strategy)
if not segments:
return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
num_staggers, skip_last, starting_point, ending_point, underpath)
2022-06-22 14:11:12 +00:00
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
2022-02-18 14:36:01 +00:00
if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length):
return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
num_staggers, skip_last, starting_point, ending_point, underpath)
2022-02-18 14:36:01 +00:00
travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
2022-06-22 14:11:12 +00:00
result = path_to_stitches(path, travel_graph, fill_stitch_graph, max_stitch_length, running_stitch_length, running_stitch_tolerance, skip_last)
2022-02-18 14:36:01 +00:00
return result
def fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance,
num_staggers, skip_last, starting_point, ending_point, underpath):
# fall back to normal auto-fill with an angle that matches the guideline (sorta)
guide_start, guide_end = [guideline.coords[0], guideline.coords[-1]]
angle = atan2(guide_end[1] - guide_start[1], guide_end[0] - guide_start[0]) * -1
return auto_fill(shape, angle, row_spacing, None, max_stitch_length, running_stitch_length, running_stitch_tolerance,
num_staggers, skip_last, starting_point, ending_point, underpath)
2022-06-22 14:11:12 +00:00
def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, running_stitch_length, running_stitch_tolerance, skip_last):
2022-02-18 14:36:01 +00:00
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(Stitch(*path[0].nodes[0]))
for edge in path:
if edge.is_segment():
current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment']
path_geometry = current_edge['geometry']
if edge[0] != path_geometry.coords[0]:
path_geometry = reverse_line_string(path_geometry)
2022-06-22 14:11:12 +00:00
new_stitches = [Stitch(*point) for point in path_geometry.coords]
# need to tag stitches
if skip_last:
del new_stitches[-1]
2022-02-18 14:36:01 +00:00
stitches.extend(new_stitches)
travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
2022-02-18 14:36:01 +00:00
else:
2022-06-22 14:11:12 +00:00
stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, running_stitch_tolerance, skip_last))
2022-02-18 14:36:01 +00:00
return stitches
2022-06-22 14:11:12 +00:00
def extend_line(line, shape):
(minx, miny, maxx, maxy) = shape.bounds
2022-02-18 14:36:01 +00:00
upper_left = InkstitchPoint(minx, miny)
lower_right = InkstitchPoint(maxx, maxy)
length = (upper_left - lower_right).length()
start_point = InkstitchPoint.from_tuple(line.coords[0])
end_point = InkstitchPoint.from_tuple(line.coords[-1])
direction = (end_point - start_point).unit()
2022-02-18 14:36:01 +00:00
new_start_point = start_point - direction * length
new_end_point = end_point + direction * length
2022-02-18 14:36:01 +00:00
# without this, we seem especially likely to run into this libgeos bug:
# https://github.com/shapely/shapely/issues/820
new_start_point += InkstitchPoint(random() * 0.01, random() * 0.01)
new_end_point += InkstitchPoint(random() * 0.01, random() * 0.01)
return shgeo.LineString((new_start_point, *line.coords, new_end_point))
2022-02-18 14:36:01 +00:00
def repair_multiple_parallel_offset_curves(multi_line):
2022-06-22 14:11:12 +00:00
lines = ensure_multi_line_string(linemerge(multi_line))
longest_line = max(lines.geoms, key=lambda line: line.length)
2022-02-18 14:36:01 +00:00
# need simplify to avoid doubled points caused by linemerge
2022-06-22 14:11:12 +00:00
return longest_line.simplify(0.01, False)
2022-02-18 14:36:01 +00:00
2022-06-22 14:11:12 +00:00
def repair_non_simple_line(line):
2022-02-18 14:36:01 +00:00
repaired = unary_union(line)
counter = 0
# Do several iterations since we might have several concatenated selfcrossings
while repaired.geom_type != 'LineString' and counter < 4:
line_segments = []
for line_seg in repaired.geoms:
if not line_seg.is_ring:
line_segments.append(line_seg)
repaired = unary_union(linemerge(line_segments))
counter += 1
if repaired.geom_type != 'LineString':
# They gave us a line with complicated self-intersections. Use a fallback.
return shgeo.LineString((line.coords[0], line.coords[-1]))
2022-02-18 14:36:01 +00:00
else:
return repaired
2022-06-22 14:11:12 +00:00
def take_only_line_strings(thing):
things = ensure_geometry_collection(thing)
line_strings = [line for line in things.geoms if isinstance(line, shgeo.LineString)]
2022-02-18 14:36:01 +00:00
2022-06-22 14:11:12 +00:00
return shgeo.MultiLineString(line_strings)
def apply_stitches(line, max_stitch_length, num_staggers, row_spacing, row_num):
start = (float(row_num % num_staggers) / num_staggers) * max_stitch_length
projections = np.arange(start, line.length, max_stitch_length)
points = np.array([line.interpolate(projection).coords[0] for projection in projections])
stitched_line = shgeo.LineString(points)
2022-02-18 14:36:01 +00:00
2022-06-22 14:11:12 +00:00
# stitched_line may round corners, which will look terrible. This finds the
# corners.
threshold = row_spacing / 2.0
simplified_line = line.simplify(row_spacing / 2.0, False)
simplified_points = [shgeo.Point(x, y) for x, y in simplified_line.coords]
extra_points = []
extra_point_projections = []
for point in simplified_points:
if point.distance(stitched_line) > threshold:
extra_points.append(point.coords[0])
extra_point_projections.append(line.project(point))
# Now we need to insert the new points into their correct spots in the line.
indices = np.searchsorted(projections, extra_point_projections)
if len(indices) > 0:
points = np.insert(points, indices, extra_points, axis=0)
return shgeo.LineString(points)
def prepare_guide_line(line, shape):
2022-02-18 14:36:01 +00:00
if line.geom_type != 'LineString' or not line.is_simple:
2022-06-22 14:11:12 +00:00
line = repair_non_simple_line(line)
if line.is_ring:
# If they pass us a ring, break it to avoid dividing by zero when
# calculating a unit vector from start to end.
line = shgeo.LineString(line.coords[:-2])
# extend the end points away from each other
2022-06-22 14:11:12 +00:00
line = extend_line(line, shape)
return line
def clean_offset_line(offset_line):
offset_line = take_only_line_strings(offset_line)
if isinstance(offset_line, shgeo.MultiLineString):
offset_line = repair_multiple_parallel_offset_curves(offset_line)
if not offset_line.is_simple:
offset_line = repair_non_simple_line(offset_line)
return offset_line
def _get_start_row(line, shape, row_spacing, line_direction):
if line.intersects(shape):
return 0
point1, point2 = nearest_points(line, shape.centroid)
distance = point1.distance(point2)
row = int(distance / row_spacing)
# This flips the sign of the starting row if the shape is on the other side
# of the guide line
shape_direction = InkstitchPoint.from_shapely_point(point2) - InkstitchPoint.from_shapely_point(point1)
return copysign(row, shape_direction * line_direction)
2022-06-22 14:11:12 +00:00
def intersect_region_with_grating_guideline(shape, line, row_spacing, num_staggers, max_stitch_length, strategy):
line = prepare_guide_line(line, shape)
2022-06-22 14:11:12 +00:00
debug.log_line_string(shape.exterior, "guided fill shape")
translate_direction = InkstitchPoint(*line.coords[-1]) - InkstitchPoint(*line.coords[0])
translate_direction = translate_direction.unit().rotate_left()
2022-06-22 14:11:12 +00:00
shape_envelope = shapely.prepared.prep(shape.convex_hull)
2022-06-22 14:11:12 +00:00
start_row = _get_start_row(line, shape, row_spacing, translate_direction)
row = start_row
2022-06-22 14:11:12 +00:00
direction = 1
offset_line = None
rows = []
2022-06-22 14:11:12 +00:00
while True:
if strategy == 0:
translate_amount = translate_direction * row * row_spacing
2022-06-22 14:11:12 +00:00
offset_line = translate(line, xoff=translate_amount.x, yoff=translate_amount.y)
elif strategy == 1:
offset_line = line.parallel_offset(row * row_spacing, 'left', join_style=shgeo.JOIN_STYLE.round)
2022-06-22 14:11:12 +00:00
offset_line = clean_offset_line(offset_line)
if strategy == 1 and direction == -1:
# negative parallel offsets are reversed, so we need to compensate
offset_line = reverse_line_string(offset_line)
debug.log_line_string(offset_line, f"offset {row}")
2022-02-18 14:36:01 +00:00
2022-06-22 14:11:12 +00:00
stitched_line = apply_stitches(offset_line, max_stitch_length, num_staggers, row_spacing, row * direction)
intersection = shape.intersection(stitched_line)
2022-02-18 14:36:01 +00:00
if shape_envelope.intersects(stitched_line):
for segment in take_only_line_strings(intersection).geoms:
rows.append(segment.coords[:])
row += direction
else:
2022-06-22 14:11:12 +00:00
if direction == 1:
direction = -1
row = start_row - 1
2022-06-22 14:11:12 +00:00
else:
break
return rows