meander fill: initial version

pull/1803/head
Lex Neva 2023-01-16 14:27:06 -05:00
rodzic b76146aa91
commit ba835b4f5e
5 zmienionych plików z 200 dodań i 37 usunięć

Wyświetl plik

@ -17,8 +17,10 @@ from ..i18n import _
from ..marker import get_marker_elements from ..marker import get_marker_elements
from ..stitch_plan import StitchGroup from ..stitch_plan import StitchGroup
from ..stitches import auto_fill, contour_fill, guided_fill, legacy_fill from ..stitches import auto_fill, contour_fill, guided_fill, legacy_fill
from ..stitches.meander_fill import meander_fill
from ..svg import PIXELS_PER_MM from ..svg import PIXELS_PER_MM
from ..svg.tags import INKSCAPE_LABEL from ..svg.tags import INKSCAPE_LABEL
from .. import tiles
from ..utils import cache, version from ..utils import cache, version
from .element import EmbroideryElement, param from .element import EmbroideryElement, param
from .validation import ValidationError, ValidationWarning from .validation import ValidationError, ValidationWarning
@ -107,7 +109,7 @@ class FillStitch(EmbroideryElement):
@property @property
@param('fill_method', _('Fill method'), type='dropdown', default=0, @param('fill_method', _('Fill method'), type='dropdown', default=0,
options=[_("Auto Fill"), _("Contour Fill"), _("Guided Fill"), _("Legacy Fill")], sort_index=2) options=[_("Auto Fill"), _("Contour Fill"), _("Guided Fill"), _("Legacy Fill"), _("Meander Fill")], sort_index=2)
def fill_method(self): def fill_method(self):
return self.get_int_param('fill_method', 0) return self.get_int_param('fill_method', 0)
@ -146,7 +148,7 @@ class FillStitch(EmbroideryElement):
type='integer', type='integer',
unit='mm', unit='mm',
default=0, default=0,
select_items=[('fill_method', 1)], select_items=[('fill_method', 1), ('fill_method', 4)],
sort_index=5) sort_index=5)
def smoothness(self): def smoothness(self):
return self.get_float_param('smoothness_mm', 0) return self.get_float_param('smoothness_mm', 0)
@ -156,6 +158,12 @@ class FillStitch(EmbroideryElement):
def clockwise(self): def clockwise(self):
return self.get_boolean_param('clockwise', True) return self.get_boolean_param('clockwise', True)
@property
@param('meander_pattern', _('Meander Pattern'), type='dropdown', default=0,
options=[tile.name for tile in tiles.all_tiles()], select_items=[('fill_method', 4)], sort_index=3)
def meander_pattern(self):
return self.get_param('meander_pattern', None)
@property @property
@param('angle', @param('angle',
_('Angle of lines of stitches'), _('Angle of lines of stitches'),
@ -592,6 +600,8 @@ class FillStitch(EmbroideryElement):
stitch_groups.extend(self.do_contour_fill(fill_shape, previous_stitch_group, start)) stitch_groups.extend(self.do_contour_fill(fill_shape, previous_stitch_group, start))
elif self.fill_method == 2: elif self.fill_method == 2:
stitch_groups.extend(self.do_guided_fill(fill_shape, previous_stitch_group, start, end)) stitch_groups.extend(self.do_guided_fill(fill_shape, previous_stitch_group, start, end))
elif self.fill_method == 4:
stitch_groups.extend(self.do_meander_fill(fill_shape, start, end))
except ExitThread: except ExitThread:
raise raise
except Exception: except Exception:
@ -723,6 +733,13 @@ class FillStitch(EmbroideryElement):
)) ))
return [stitch_group] return [stitch_group]
def do_meander_fill(self, shape, starting_point, ending_point):
stitch_group = StitchGroup(
color=self.color,
tags=("meander_fill", "meander_fill_top"),
stitches=meander_fill(self, shape, starting_point, ending_point))
return [stitch_group]
@cache @cache
def _get_guide_lines(self, multiple=False): def _get_guide_lines(self, multiple=False):
guide_lines = get_marker_elements(self.node, "guide-line", False, True) guide_lines = get_marker_elements(self.node, "guide-line", False, True)

Wyświetl plik

@ -0,0 +1,104 @@
from shapely.geometry import MultiPoint, Point
from shapely.ops import nearest_points
import networkx as nx
from .. import tiles
from ..debug import debug
from ..utils.list import poprandom
def meander_fill(fill, shape, starting_point, ending_point):
tile = get_tile(fill.meander_pattern)
if not tile:
return []
graph = tile.to_graph(shape)
start, end = find_starting_and_ending_nodes(graph, starting_point, ending_point)
return generate_meander_path(graph, start, end)
def get_tile(tile_name):
all_tiles = {tile.name: tile for tile in tiles.all_tiles()}
try:
return all_tiles.get(tile_name, all_tiles.popitem()[1])
except KeyError:
return None
def find_starting_and_ending_nodes(graph, starting_point, ending_point):
all_points = MultiPoint(list(graph))
starting_node = nearest_points(starting_point, all_points)[1].coords[0]
ending_node = nearest_points(ending_point, all_points)[1].coords[0]
if starting_node == ending_node:
# We need a path to start with, so pick a new ending node
all_points = all_points.difference(Point(starting_node))
ending_node = nearest_points(ending_point, all_points)[1].coords[0]
return starting_node, ending_node
def find_initial_path(graph, start, end):
# We need some path to start with. We could use
# nx.all_simple_paths(graph, start, end) and choose the first one.
# However, that tends to pick a really "orderly" path. Shortest
# path looks more random.
return nx.shortest_path(graph, start, end)
def generate_meander_path(graph, start, end):
path = find_initial_path(graph, start, end)
path_edges = list(zip(path[:-1], path[1:]))
graph.remove_edges_from(path_edges)
graph_nodes = set(graph) - set(path)
edges_to_consider = list(path_edges)
meander_path = path_edges
while edges_to_consider:
while edges_to_consider:
edge = poprandom(edges_to_consider)
edges_to_consider.extend(replace_edge(meander_path, edge, graph, graph_nodes))
edge_pairs = list(zip(path[:-1], path[1:]))
while edge_pairs:
edge1, edge2 = poprandom(edge_pairs)
edges_to_consider.extend(replace_edge_pair(meander_path, edge1, edge2, graph, graph_nodes))
return meander_path
def replace_edge(path, edge, graph, graph_nodes):
subgraph = graph.subgraph(graph_nodes | set(edge))
new_path = None
for new_path in nx.all_simple_edge_paths(subgraph, edge[0], edge[1], 7):
if len(new_path) > 1:
break
if new_path is None or len(new_path) == 1:
return []
i = path.index(edge)
path[i:i + 1] = new_path
graph.remove_edges_from(new_path)
graph_nodes.difference_update(start for start, end in new_path)
debug.log(f"found new path of length {len(new_path)} at position {i}")
return new_path
def replace_edge_pair(path, edge1, edge2, graph, graph_nodes):
subgraph = graph.subgraph(graph_nodes | {edge1[0], edge2[1]})
new_path = None
for new_path in nx.all_simple_edge_paths(subgraph, edge1[0], edge2[1], 10):
if len(new_path) > 2:
break
if new_path is None or len(new_path) <= 2:
return []
i = path.index(edge1)
path[i:i + 2] = new_path
graph.remove_edges_from(new_path)
graph_nodes.difference_update(start for start, end in new_path)
debug.log(f"found new pair path of length {len(new_path)} at position {i}")
return new_path

Wyświetl plik

@ -69,6 +69,7 @@ inkstitch_attribs = [
'smoothness_mm', 'smoothness_mm',
'clockwise', 'clockwise',
'reverse', 'reverse',
'meander_pattern',
'expand_mm', 'expand_mm',
'fill_underlay', 'fill_underlay',
'fill_underlay_angle', 'fill_underlay_angle',

Wyświetl plik

@ -5,8 +5,10 @@ import os
from shapely.geometry import LineString from shapely.geometry import LineString
from shapely.prepared import prep from shapely.prepared import prep
from .debug import debug
from .svg import apply_transforms from .svg import apply_transforms
from .utils import get_bundled_dir, guess_inkscape_config_path, Point from .svg.tags import SODIPODI_NAMEDVIEW
from .utils import cache, get_bundled_dir, guess_inkscape_config_path, Point
from random import random from random import random
@ -15,51 +17,68 @@ class Tile:
self._load_tile(path) self._load_tile(path)
def _load_tile(self, tile_path): def _load_tile(self, tile_path):
tile_svg = inkex.load_svg(tile_path) self.tile_svg = inkex.load_svg(tile_path)
self.name = self._get_name(tile_path) self.tile_path = tile_path
self._load_paths(tile_svg) self.name = self._get_name(self.tile_svg, tile_path)
self._load_dimensions(tile_svg) self.tile = None
self._load_buffer_size(tile_svg) self.width = None
self._load_parallelogram(tile_svg) self.height = None
self.buffer_size = None
self.shift0 = None
self.shift1 = None
def __repr__(self): def __repr__(self):
return f"Tile({self.name}, {self.shift0}, {self.shift1})" return f"Tile({self.name}, {self.shift0}, {self.shift1})"
__str__ = __repr__ __str__ = __repr__
def _get_name(self, tile_path): def _get_name(self, tile_svg, tile_path):
return os.path.splitext(os.path.basename(tile_path))[0] name = tile_svg.get(SODIPODI_NAMEDVIEW)
if name:
return name
else:
return os.path.splitext(os.path.basename(tile_path))[0]
def _load(self):
self._load_paths(self.tile_svg)
self._load_dimensions(self.tile_svg)
self._load_buffer_size(self.tile_svg)
self._load_parallelogram(self.tile_svg)
def _load_paths(self, tile_svg): def _load_paths(self, tile_svg):
path_elements = tile_svg.findall('.//svg:path', namespaces=inkex.NSS) if self.tile is None:
self.tile = self._path_elements_to_line_strings(path_elements) path_elements = tile_svg.findall('.//svg:path', namespaces=inkex.NSS)
# self.center, ignore, ignore = self._get_center_and_dimensions(self.tile) self.tile = self._path_elements_to_line_strings(path_elements)
# self.center, ignore, ignore = self._get_center_and_dimensions(self.tile)
def _load_dimensions(self, tile_svg): def _load_dimensions(self, tile_svg):
svg_element = tile_svg.getroot() if self.width is None:
self.width = svg_element.viewport_width svg_element = tile_svg.getroot()
self.height = svg_element.viewport_height self.width = svg_element.viewport_width
self.height = svg_element.viewport_height
def _load_buffer_size(self, tile_svg): def _load_buffer_size(self, tile_svg):
circle_elements = tile_svg.findall('.//svg:circle', namespaces=inkex.NSS) if self.buffer_size is None:
if circle_elements: circle_elements = tile_svg.findall('.//svg:circle', namespaces=inkex.NSS)
self.buffer_size = circle_elements[0].radius if circle_elements:
else: self.buffer_size = circle_elements[0].radius
self.buffer_size = 0 else:
self.buffer_size = 0
def _load_parallelogram(self, tile_svg): def _load_parallelogram(self, tile_svg):
parallelogram_elements = tile_svg.findall(".//svg:*[@class='para']", namespaces=inkex.NSS) if self.shift0 is None:
if parallelogram_elements: parallelogram_elements = tile_svg.findall(".//svg:*[@class='para']", namespaces=inkex.NSS)
path_element = parallelogram_elements[0] if parallelogram_elements:
path = apply_transforms(path_element.get_path(), path_element) path_element = parallelogram_elements[0]
subpaths = path.to_superpath() path = apply_transforms(path_element.get_path(), path_element)
subpath = subpaths[0] subpaths = path.to_superpath()
points = [Point.from_tuple(p[1]) for p in subpath] subpath = subpaths[0]
self.shift0 = points[1] - points[0] points = [Point.from_tuple(p[1]) for p in subpath]
self.shift1 = points[2] - points[1] self.shift0 = points[1] - points[0]
else: self.shift1 = points[2] - points[1]
self.shift0 = Point(self.width, 0) else:
self.shift1 = Point(0, self.height) self.shift0 = Point(self.width, 0)
self.shift1 = Point(0, self.height)
def _path_elements_to_line_strings(self, path_elements): def _path_elements_to_line_strings(self, path_elements):
lines = [] lines = []
@ -80,7 +99,7 @@ class Tile:
return center, width, height return center, width, height
def translate_tile(self, shift): def _translate_tile(self, shift):
translated_tile = [] translated_tile = []
for start, end in self.tile: for start, end in self.tile:
@ -90,6 +109,7 @@ class Tile:
return translated_tile return translated_tile
@debug.time
def to_graph(self, shape, only_inside=True, pad=True): def to_graph(self, shape, only_inside=True, pad=True):
"""Apply this tile to a shape, repeating as necessary. """Apply this tile to a shape, repeating as necessary.
@ -98,6 +118,8 @@ class Tile:
Each edge has an attribute 'line_string' with the LineString Each edge has an attribute 'line_string' with the LineString
representation of this edge. representation of this edge.
""" """
self._load()
shape_center, shape_width, shape_height = self._get_center_and_dimensions(shape) shape_center, shape_width, shape_height = self._get_center_and_dimensions(shape)
shape_diagonal = (shape_width ** 2 + shape_height ** 2) ** 0.5 shape_diagonal = (shape_width ** 2 + shape_height ** 2) ** 0.5
graph = Graph() graph = Graph()
@ -113,7 +135,7 @@ class Tile:
for repeat1 in range(floor(-tiles1 / 2), ceil(tiles1 / 2)): for repeat1 in range(floor(-tiles1 / 2), ceil(tiles1 / 2)):
shift0 = repeat0 * self.shift0 + shape_center shift0 = repeat0 * self.shift0 + shape_center
shift1 = repeat1 * self.shift1 + shape_center shift1 = repeat1 * self.shift1 + shape_center
this_tile = self.translate_tile(shift0 + shift1) this_tile = self._translate_tile(shift0 + shift1)
for line in this_tile: for line in this_tile:
line_string = LineString(line) line_string = LineString(line)
if not only_inside or prepared_shape.contains(line_string): if not only_inside or prepared_shape.contains(line_string):
@ -127,10 +149,14 @@ def all_tile_paths():
get_bundled_dir('tiles')] get_bundled_dir('tiles')]
@cache
def all_tiles(): def all_tiles():
tiles = []
for tile_dir in all_tile_paths(): for tile_dir in all_tile_paths():
try: try:
for tile_file in sorted(os.listdir(tile_dir)): for tile_file in sorted(os.listdir(tile_dir)):
yield Tile(os.path.join(tile_dir, tile_file)) tiles.append(Tile(os.path.join(tile_dir, tile_file)))
except FileNotFoundError: except FileNotFoundError:
pass pass
return tiles

15
lib/utils/list.py 100644
Wyświetl plik

@ -0,0 +1,15 @@
from random import randrange
def poprandom(sequence):
index = randrange(len(sequence))
item = sequence[index]
# It's O(1) to pop the last item, and O(n) to pop any other item. So we'll
# always pop the last item and put it in the slot vacated by the item we're
# popping.
last_item = sequence.pop()
if index < len(sequence):
sequence[index] = last_item
return item