summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/debug.py55
-rw-r--r--lib/elements/fill_stitch.py56
-rw-r--r--lib/elements/satin_column.py8
-rw-r--r--lib/extensions/params.py63
-rw-r--r--lib/stitches/contour_fill.py9
-rw-r--r--lib/stitches/meander_fill.py177
-rw-r--r--lib/stitches/running_stitch.py6
-rw-r--r--lib/svg/tags.py3
-rw-r--r--lib/tiles.py188
-rw-r--r--lib/utils/clamp_path.py121
-rw-r--r--lib/utils/geometry.py7
-rw-r--r--lib/utils/list.py23
-rw-r--r--lib/utils/prng.py28
-rw-r--r--lib/utils/smoothing.py84
-rw-r--r--lib/utils/string.py7
15 files changed, 791 insertions, 44 deletions
diff --git a/lib/debug.py b/lib/debug.py
index 0d6af104..4751e6af 100644
--- a/lib/debug.py
+++ b/lib/debug.py
@@ -21,12 +21,46 @@ from .svg.tags import INKSCAPE_GROUPMODE, INKSCAPE_LABEL
def check_enabled(func):
def decorated(self, *args, **kwargs):
if self.enabled:
- func(self, *args, **kwargs)
+ return func(self, *args, **kwargs)
+
+ return decorated
+
+
+def _unwrap(arg):
+ if callable(arg):
+ return arg()
+ else:
+ return arg
+
+
+def unwrap_arguments(func):
+ def decorated(self, *args, **kwargs):
+ unwrapped_args = [_unwrap(arg) for arg in args]
+ unwrapped_kwargs = {name: _unwrap(value) for name, value in kwargs.items()}
+
+ return func(self, *unwrapped_args, **unwrapped_kwargs)
return decorated
class Debug(object):
+ """Tools to help debug Ink/Stitch
+
+ This class contains methods to log strings and SVG elements. Strings are
+ logged to debug.log, and SVG elements are stored in debug.svg to aid in
+ debugging stitch algorithms.
+
+ All functionality is gated by self.enabled. If debugging is not enabled,
+ then debug calls will consume very few resources. Any method argument
+ can be a callable, in which case it is called and the return value is
+ logged instead. This way one can log potentially expensive expressions
+ by wrapping them in a lambda:
+
+ debug.log(lambda: some_expensive_function(some_argument))
+
+ The lambda is only called if debugging is enabled.
+ """
+
def __init__(self):
self.enabled = False
self.last_log_time = None
@@ -145,6 +179,7 @@ class Debug(object):
tree.write(debug_svg)
@check_enabled
+ @unwrap_arguments
def add_layer(self, name="Debug"):
layer = etree.Element("g", {
INKSCAPE_GROUPMODE: "layer",
@@ -155,6 +190,7 @@ class Debug(object):
self.current_layer = layer
@check_enabled
+ @unwrap_arguments
def open_group(self, name="Group"):
group = etree.Element("g", {
INKSCAPE_LABEL: name
@@ -164,11 +200,13 @@ class Debug(object):
self.group_stack.append(group)
@check_enabled
+ @unwrap_arguments
def close_group(self):
if self.group_stack:
self.group_stack.pop()
@check_enabled
+ @unwrap_arguments
def log(self, message, *args):
if self.last_log_time:
message = "(+%s) %s" % (datetime.now() - self.last_log_time, message)
@@ -201,6 +239,7 @@ class Debug(object):
return decorated
@check_enabled
+ @unwrap_arguments
def log_svg_element(self, element):
if self.current_layer is None:
self.add_layer()
@@ -211,11 +250,13 @@ class Debug(object):
self.current_layer.append(element)
@check_enabled
+ @unwrap_arguments
def log_line_string(self, line_string, name=None, color=None):
"""Add a Shapely LineString to the SVG log."""
self.log_line_strings([line_string], name, color)
@check_enabled
+ @unwrap_arguments
def log_line_strings(self, line_strings, name=None, color=None):
path = line_strings_to_path(line_strings)
path.set('style', str(inkex.Style({"stroke": color or "#000000", "stroke-width": "0.3", "fill": None})))
@@ -226,6 +267,7 @@ class Debug(object):
self.log_svg_element(path)
@check_enabled
+ @unwrap_arguments
def log_line(self, start, end, name="line", color=None):
self.log_svg_element(etree.Element("path", {
"d": "M%s,%s %s,%s" % (start + end),
@@ -234,6 +276,17 @@ class Debug(object):
}))
@check_enabled
+ @unwrap_arguments
+ def log_point(self, point, name="point", color=None):
+ self.log_svg_element(etree.Element("circle", {
+ "cx": str(point.x),
+ "cy": str(point.y),
+ "r": "1",
+ "style": str(inkex.Style({"fill": "#000000"})),
+ }))
+
+ @check_enabled
+ @unwrap_arguments
def log_graph(self, graph, name="Graph", color=None):
d = ""
diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py
index 77b4ac7c..c6762ad1 100644
--- a/lib/elements/fill_stitch.py
+++ b/lib/elements/fill_stitch.py
@@ -17,8 +17,10 @@ from ..i18n import _
from ..marker import get_marker_elements
from ..stitch_plan import StitchGroup
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.tags import INKSCAPE_LABEL
+from .. import tiles
from ..utils import cache, version
from .element import EmbroideryElement, param
from .validation import ValidationError, ValidationWarning
@@ -107,7 +109,7 @@ class FillStitch(EmbroideryElement):
@property
@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):
return self.get_int_param('fill_method', 0)
@@ -138,11 +140,37 @@ class FillStitch(EmbroideryElement):
return self.get_boolean_param('avoid_self_crossing', False)
@property
+ @param('smoothness_mm', _('Smoothness'),
+ tooltip=_(
+ 'Smooth the stitch path. Smoothness limits how far the smoothed stitch path ' +
+ 'is allowed to deviate from the original path. Try low numbers like 0.2. ' +
+ 'Hint: a lower running stitch tolerance may be needed too.'
+ ),
+ type='integer',
+ unit='mm',
+ default=0,
+ select_items=[('fill_method', 1), ('fill_method', 4)],
+ sort_index=5)
+ def smoothness(self):
+ return self.get_float_param('smoothness_mm', 0)
+
+ @property
@param('clockwise', _('Clockwise'), type='boolean', default=True, select_items=[('fill_method', 1)], sort_index=5)
def clockwise(self):
return self.get_boolean_param('clockwise', True)
@property
+ @param('meander_pattern', _('Meander Pattern'), type='combo', default=0,
+ options=sorted(tiles.all_tiles()), select_items=[('fill_method', 4)], sort_index=3)
+ def meander_pattern(self):
+ return self.get_param('meander_pattern', None)
+
+ @property
+ @param('meander_scale_percent', _('Meander pattern scale'), type='float', unit="%", default=100, select_items=[('fill_method', 4)], sort_index=4)
+ def meander_scale(self):
+ return self.get_split_float_param('meander_scale_percent', (100, 100)) / 100
+
+ @property
@param('angle',
_('Angle of lines of stitches'),
tooltip=_('The angle increases in a counter-clockwise direction. 0 is horizontal. Negative angles are allowed.'),
@@ -194,6 +222,7 @@ class FillStitch(EmbroideryElement):
unit='mm',
sort_index=6,
type='float',
+ select_items=[('fill_method', 0), ('fill_method', 1), ('fill_method', 2), ('fill_method', 3)],
default=0.25)
def row_spacing(self):
return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM)
@@ -210,6 +239,7 @@ class FillStitch(EmbroideryElement):
unit='mm',
sort_index=6,
type='float',
+ select_items=[('fill_method', 0), ('fill_method', 1), ('fill_method', 2), ('fill_method', 3)],
default=3.0)
def max_stitch_length(self):
return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM)
@@ -375,12 +405,13 @@ class FillStitch(EmbroideryElement):
@property
@param('running_stitch_length_mm',
- _('Running stitch length (traversal between sections)'),
- tooltip=_('Length of stitches around the outline of the fill region used when moving from section to section.'),
+ _('Running stitch length'),
+ tooltip=_(
+ 'Length of stitches around the outline of the fill region used when moving from section to section. Also used for meander fill.'),
unit='mm',
type='float',
default=1.5,
- select_items=[('fill_method', 0), ('fill_method', 2)],
+ select_items=[('fill_method', 0), ('fill_method', 2), ('fill_method', 4)],
sort_index=6)
def running_stitch_length(self):
return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
@@ -475,12 +506,12 @@ class FillStitch(EmbroideryElement):
@property
@param('expand_mm',
_('Expand'),
- tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes.'),
+ tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes. Negative values contract instead.'),
unit='mm',
type='float',
default=0,
sort_index=5,
- select_items=[('fill_method', 0), ('fill_method', 2)])
+ select_items=[('fill_method', 0), ('fill_method', 2), ('fill_method', 4)])
def expand(self):
return self.get_float_param('expand_mm', 0)
@@ -571,13 +602,15 @@ class FillStitch(EmbroideryElement):
stitch_groups.extend(underlay_stitch_groups)
fill_shapes = self.fill_shape(shape)
- for fill_shape in fill_shapes.geoms:
+ for i, fill_shape in enumerate(fill_shapes.geoms):
if self.fill_method == 0:
stitch_groups.extend(self.do_auto_fill(fill_shape, previous_stitch_group, start, end))
if self.fill_method == 1:
stitch_groups.extend(self.do_contour_fill(fill_shape, previous_stitch_group, start))
elif self.fill_method == 2:
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, i, start, end))
except ExitThread:
raise
except Exception:
@@ -651,9 +684,11 @@ class FillStitch(EmbroideryElement):
if self.contour_strategy == 0:
stitches = contour_fill.inner_to_outer(
tree,
+ polygon,
self.row_spacing,
self.max_stitch_length,
self.running_stitch_tolerance,
+ self.smoothness,
starting_point,
self.avoid_self_crossing
)
@@ -707,6 +742,13 @@ class FillStitch(EmbroideryElement):
))
return [stitch_group]
+ def do_meander_fill(self, shape, i, starting_point, ending_point):
+ stitch_group = StitchGroup(
+ color=self.color,
+ tags=("meander_fill", "meander_fill_top"),
+ stitches=meander_fill(self, shape, i, starting_point, ending_point))
+ return [stitch_group]
+
@cache
def _get_guide_lines(self, multiple=False):
guide_lines = get_marker_elements(self.node, "guide-line", False, True)
diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py
index 4028ad27..eba63c6c 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -783,7 +783,7 @@ class SatinColumn(EmbroideryElement):
# pre-cache ramdomised parameters to avoid property calls in loop
if use_random:
- seed = prng.joinArgs(self.random_seed, "satin-points")
+ seed = prng.join_args(self.random_seed, "satin-points")
offset_proportional_min = np.array(offset_proportional) - self.random_width_decrease
offset_range = (self.random_width_increase + self.random_width_decrease)
spacing_sigma = spacing * self.random_zigzag_spacing
@@ -857,7 +857,7 @@ class SatinColumn(EmbroideryElement):
if to_travel <= 0:
if use_random:
- roll = prng.uniformFloats(seed, cycle)
+ roll = prng.uniform_floats(seed, cycle)
offset_prop = offset_proportional_min + roll[0:2] * offset_range
to_travel = spacing + ((roll[2] - 0.5) * 2 * spacing_sigma)
else:
@@ -987,7 +987,7 @@ class SatinColumn(EmbroideryElement):
if last_point is not None:
split_points, _ = self.get_split_points(
last_point, a, last_short_point, a_short, max_stitch_length, last_count,
- length_sigma, random_phase, min_split_length, prng.joinArgs(seed, 'satin-split', 2*i))
+ length_sigma, random_phase, min_split_length, prng.join_args(seed, 'satin-split', 2 * i))
patch.add_stitches(split_points, ("satin_column", "satin_split_stitch"))
patch.add_stitch(a_short)
@@ -995,7 +995,7 @@ class SatinColumn(EmbroideryElement):
split_points, last_count = self.get_split_points(
a, b, a_short, b_short, max_stitch_length, None,
- length_sigma, random_phase, min_split_length, prng.joinArgs(seed, 'satin-split', 2*i+1))
+ length_sigma, random_phase, min_split_length, prng.join_args(seed, 'satin-split', 2 * i + 1))
patch.add_stitches(split_points, ("satin_column", "satin_split_stitch"))
patch.add_stitch(b_short)
diff --git a/lib/extensions/params.py b/lib/extensions/params.py
index 1262ceb6..e342c592 100644
--- a/lib/extensions/params.py
+++ b/lib/extensions/params.py
@@ -17,7 +17,7 @@ import wx
from wx.lib.scrolledpanel import ScrolledPanel
from ..commands import is_command, is_command_symbol
-from ..elements import (FillStitch, Clone, EmbroideryElement, Polyline,
+from ..elements import (Clone, EmbroideryElement, FillStitch, Polyline,
SatinColumn, Stroke)
from ..elements.clone import is_clone
from ..gui import PresetsPanel, SimulatorPreview, WarningPanel
@@ -129,9 +129,12 @@ class ParamsTab(ScrolledPanel):
if event:
event.Skip()
- def update_choice_state(self, event=None):
+ def update_choice_state(self, event=None, combo=False):
input = event.GetEventObject()
- selection = input.GetSelection()
+ if combo:
+ selection = input.GetClientData(input.GetSelection()).id
+ else:
+ selection = input.GetSelection()
param = self.inputs_to_params[input]
@@ -143,6 +146,15 @@ class ParamsTab(ScrolledPanel):
if event:
event.Skip()
+ def update_combo_state(self, event=None):
+ self.update_choice_state(event, True)
+
+ def get_combo_value_index(self, param, options):
+ for option in options:
+ if option.id == param:
+ return options.index(option)
+ return 0
+
def pair_changed(self, value):
new_value = not value
@@ -187,11 +199,15 @@ class ParamsTab(ScrolledPanel):
for name, input in self.param_inputs.items():
if input in self.changed_inputs and input != self.toggle_checkbox:
- try:
- values[name] = input.GetValue()
- except AttributeError:
- # dropdown
+ # there are two types of combo boxes:
+ # 1. multiple values for the same param on selected elements - 2. param type
+ # multiple values will be handled with the GetValue() method
+ if name in self.dict_of_choices and self.dict_of_choices[name]['param'].type == 'combo':
+ values[name] = input.GetClientData(input.GetSelection()).id
+ elif isinstance(input, wx.Choice):
values[name] = input.GetSelection()
+ else:
+ values[name] = input.GetValue()
return values
@@ -234,12 +250,16 @@ class ParamsTab(ScrolledPanel):
def load_preset(self, preset):
preset_data = preset.get(self.name, {})
+ # print(self.param_inputs, '\n\n', preset_data.items(), file=sys.stderr)
+
for name, value in preset_data.items():
if name in self.param_inputs:
- try:
- self.param_inputs[name].SetValue(value)
- except AttributeError:
+ if name in self.dict_of_choices and self.dict_of_choices[name]['param'].type == 'combo':
+ self.param_inputs[name].SetSelection(self.get_combo_value_index(value, self.dict_of_choices[name]["param"].options))
+ elif isinstance(self.param_inputs[name], wx.Choice):
self.param_inputs[name].SetSelection(int(value))
+ else:
+ self.param_inputs[name].SetValue(value)
self.changed_inputs.add(self.param_inputs[name])
self.update_toggle_state()
@@ -299,16 +319,21 @@ class ParamsTab(ScrolledPanel):
def update_choice_widgets(self, choice_tuple=None):
if choice_tuple is None: # update all choices
for choice in self.dict_of_choices.values():
- self.update_choice_widgets(
- (choice["param"].name, choice["widget"].GetSelection()))
+ if choice["param"].type == "combo":
+ self.update_choice_widgets((choice["param"].name, choice["widget"].GetClientData(choice["widget"].GetSelection()).id))
+ else:
+ self.update_choice_widgets((choice["param"].name, choice["widget"].GetSelection()))
else:
choice = self.dict_of_choices[choice_tuple[0]]
last_selection = choice["last_initialized_choice"]
- current_selection = choice["widget"].GetSelection()
+ if choice["param"].type == "combo":
+ current_selection = choice["widget"].GetClientData(choice["widget"].GetSelection()).id
+ else:
+ current_selection = choice["widget"].GetSelection()
+
if last_selection != -1 and last_selection != current_selection: # Hide the old widgets
for widget in self.choice_widgets[(choice["param"].name, last_selection)]:
widget.Hide()
- # self.settings_grid.Detach(widget)
for widgets in grouper(self.choice_widgets[choice_tuple], 4):
widgets[0].Show(True)
@@ -375,6 +400,16 @@ class ParamsTab(ScrolledPanel):
input.Bind(wx.EVT_CHOICE, self.update_choice_state)
self.dict_of_choices[param.name] = {
"param": param, "widget": input, "last_initialized_choice": 1}
+ elif param.type == 'combo':
+ input = wx.ComboBox(self, wx.ID_ANY, choices=[], style=wx.CB_READONLY)
+ for option in param.options:
+ input.Append(option.name, option)
+ value = self.get_combo_value_index(param.values[0], param.options)
+ input.SetSelection(value)
+ input.Bind(wx.EVT_COMBOBOX, self.changed)
+ input.Bind(wx.EVT_COMBOBOX, self.update_combo_state)
+ self.dict_of_choices[param.name] = {
+ "param": param, "widget": input, "last_initialized_choice": 1}
elif len(param.values) > 1:
input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(
str(value) for value in param.values), style=wx.CB_DROPDOWN)
diff --git a/lib/stitches/contour_fill.py b/lib/stitches/contour_fill.py
index 885a7e6c..f5f2a3ee 100644
--- a/lib/stitches/contour_fill.py
+++ b/lib/stitches/contour_fill.py
@@ -12,9 +12,11 @@ from shapely.validation import make_valid
from ..stitch_plan import Stitch
from ..utils import DotDict
+from ..utils.clamp_path import clamp_path_to_polygon
from ..utils.geometry import (cut, ensure_geometry_collection,
ensure_multi_polygon, reverse_line_string,
roll_linear_ring)
+from ..utils.smoothing import smooth_path
from ..utils.threading import check_stop_flag
from .running_stitch import running_stitch
@@ -406,11 +408,16 @@ def _find_path_inner_to_outer(tree, node, offset, starting_point, avoid_self_cro
return LineString(result_coords)
-def inner_to_outer(tree, offset, stitch_length, tolerance, starting_point, avoid_self_crossing):
+def inner_to_outer(tree, polygon, offset, stitch_length, tolerance, smoothness, starting_point, avoid_self_crossing):
"""Fill a shape with spirals, from innermost to outermost."""
stitch_path = _find_path_inner_to_outer(tree, 'root', offset, starting_point, avoid_self_crossing)
points = [Stitch(*point) for point in stitch_path.coords]
+
+ if smoothness > 0:
+ smoothed = smooth_path(points, smoothness)
+ points = clamp_path_to_polygon(smoothed, polygon)
+
stitches = running_stitch(points, stitch_length, tolerance)
return stitches
diff --git a/lib/stitches/meander_fill.py b/lib/stitches/meander_fill.py
new file mode 100644
index 00000000..964a7a41
--- /dev/null
+++ b/lib/stitches/meander_fill.py
@@ -0,0 +1,177 @@
+from itertools import combinations
+
+import networkx as nx
+from shapely.geometry import MultiPoint, Point
+from shapely.ops import nearest_points
+
+from .running_stitch import running_stitch
+from .. import tiles
+from ..debug import debug
+from ..utils.clamp_path import clamp_path_to_polygon
+from ..utils.geometry import Point as InkStitchPoint, ensure_geometry_collection
+from ..utils.list import poprandom
+from ..utils.prng import iter_uniform_floats
+from ..utils.smoothing import smooth_path
+from ..utils.threading import check_stop_flag
+
+
+def meander_fill(fill, shape, shape_index, starting_point, ending_point):
+ debug.log(f"meander pattern: {fill.meander_pattern}")
+ tile = get_tile(fill.meander_pattern)
+ if not tile:
+ return []
+
+ debug.log(f"tile name: {tile.name}")
+
+ debug.log_line_strings(lambda: ensure_geometry_collection(shape.boundary).geoms, 'Meander shape')
+ graph = tile.to_graph(shape, fill.meander_scale)
+ debug.log_graph(graph, 'Meander graph')
+ ensure_connected(graph)
+ start, end = find_starting_and_ending_nodes(graph, shape, starting_point, ending_point)
+ rng = iter_uniform_floats(fill.random_seed, 'meander-fill', shape_index)
+
+ return post_process(generate_meander_path(graph, start, end, rng), shape, fill)
+
+
+def get_tile(tile_id):
+ all_tiles = {tile.id: tile for tile in tiles.all_tiles()}
+
+ try:
+ return all_tiles.get(tile_id, all_tiles.popitem()[1])
+ except KeyError:
+ return None
+
+
+def ensure_connected(graph):
+ """If graph is unconnected, add edges to make it connected."""
+
+ # TODO: combine this with possible_jumps() in lib/stitches/utils/autoroute.py
+ possible_connections = []
+ for component1, component2 in combinations(nx.connected_components(graph), 2):
+ points1 = MultiPoint([Point(node) for node in component1])
+ points2 = MultiPoint([Point(node) for node in component2])
+
+ start_point, end_point = nearest_points(points1, points2)
+ possible_connections.append(((start_point.x, start_point.y), (end_point.x, end_point.y), start_point.distance(end_point)))
+
+ if possible_connections:
+ for start, end in nx.k_edge_augmentation(graph, 1, avail=possible_connections):
+ check_stop_flag()
+ graph.add_edge(start, end)
+
+
+def find_starting_and_ending_nodes(graph, shape, starting_point, ending_point):
+ if starting_point is None:
+ starting_point = shape.exterior.coords[0]
+ starting_point = Point(starting_point)
+
+ if ending_point is None:
+ ending_point = starting_point
+ else:
+ ending_point = 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.
+
+ # TODO: handle if this can't find a path
+ return nx.shortest_path(graph, start, end)
+
+
+@debug.time
+def generate_meander_path(graph, start, end, rng):
+ 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:
+ check_stop_flag()
+
+ edge = poprandom(edges_to_consider, rng)
+ edges_to_consider.extend(replace_edge(meander_path, edge, graph, graph_nodes))
+
+ edge_pairs = list(zip(meander_path[:-1], meander_path[1:]))
+ while edge_pairs:
+ check_stop_flag()
+
+ edge1, edge2 = poprandom(edge_pairs, rng)
+ edges_to_consider.extend(replace_edge_pair(meander_path, edge1, edge2, graph, graph_nodes))
+ break
+
+ return path_to_points(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)
+ # do I need to remove the last one too?
+ 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)
+ # do I need to remove the last one too?
+ 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
+
+
+@debug.time
+def post_process(points, shape, fill):
+ debug.log(f"smoothness: {fill.smoothness}")
+ # debug.log_line_string(LineString(points), "pre-smoothed", "#FF0000")
+ smoothed_points = smooth_path(points, fill.smoothness)
+ smoothed_points = [InkStitchPoint.from_tuple(point) for point in smoothed_points]
+
+ stitches = running_stitch(smoothed_points, fill.running_stitch_length, fill.running_stitch_tolerance)
+ stitches = clamp_path_to_polygon(stitches, shape)
+
+ return stitches
+
+
+def path_to_points(path):
+ points = [start for start, end in path]
+ if path:
+ points.append(path[-1][1])
+
+ return points
diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py
index 8ba53498..1dbfcaaf 100644
--- a/lib/stitches/running_stitch.py
+++ b/lib/stitches/running_stitch.py
@@ -24,7 +24,7 @@ def split_segment_even_n(a, b, segments: int, jitter_sigma: float = 0.0, random_
splits = np.array(range(1, segments)) / segments
if random_seed is not None:
- jitters = (prng.nUniformFloats(len(splits), random_seed) * 2) - 1
+ jitters = (prng.n_uniform_floats(len(splits), random_seed) * 2) - 1
splits = splits + jitters * (jitter_sigma / segments)
# sort the splits in case a bad roll transposes any of them
@@ -39,12 +39,12 @@ def split_segment_even_dist(a, b, max_length: float, jitter_sigma: float = 0.0,
def split_segment_random_phase(a, b, length: float, length_sigma: float, random_seed: str) -> typing.List[shgeo.Point]:
line = shgeo.LineString([a, b])
- progress = length * prng.uniformFloats(random_seed, "phase")[0]
+ progress = length * prng.uniform_floats(random_seed, "phase")[0]
splits = [progress]
distance = line.length
if progress >= distance:
return []
- for x in prng.iterUniformFloats(random_seed):
+ for x in prng.iter_uniform_floats(random_seed):
progress += length * (1 + length_sigma * (x - 0.5) * 2)
if progress >= distance:
break
diff --git a/lib/svg/tags.py b/lib/svg/tags.py
index 64f6c2f3..5a7d31c2 100644
--- a/lib/svg/tags.py
+++ b/lib/svg/tags.py
@@ -66,8 +66,11 @@ inkstitch_attribs = [
'guided_fill_strategy',
'join_style',
'avoid_self_crossing',
+ 'smoothness_mm',
'clockwise',
'reverse',
+ 'meander_pattern',
+ 'meander_scale_percent',
'expand_mm',
'fill_underlay',
'fill_underlay_angle',
diff --git a/lib/tiles.py b/lib/tiles.py
new file mode 100644
index 00000000..683804a6
--- /dev/null
+++ b/lib/tiles.py
@@ -0,0 +1,188 @@
+import os
+from math import ceil, floor
+
+import inkex
+import json
+import lxml
+import networkx as nx
+from shapely.geometry import LineString
+from shapely.prepared import prep
+
+from .debug import debug
+from .i18n import _
+from .svg import apply_transforms
+from .utils import Point, cache, get_bundled_dir, guess_inkscape_config_path
+from .utils.threading import check_stop_flag
+
+
+class Tile:
+ def __init__(self, path):
+ self._load_tile(path)
+
+ def _load_tile(self, tile_path):
+ self.tile_svg = inkex.load_svg(os.path.join(tile_path, "tile.svg"))
+ self._load_metadata(tile_path)
+ self.tile = None
+ self.width = None
+ self.height = None
+ self.shift0 = None
+ self.shift1 = None
+
+ def __lt__(self, other):
+ return self.name < other.name
+
+ def __repr__(self):
+ return f"Tile({self.name}, {self.id})"
+
+ __str__ = __repr__
+
+ def _load_metadata(self, tile_path):
+ with open(os.path.join(tile_path, "tile.json"), "rb") as tile_json:
+ tile_metadata = json.load(tile_json)
+ self.name = _(tile_metadata.get('name'))
+ self.id = tile_metadata.get('id')
+
+ def _get_name(self, tile_path):
+ 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_parallelogram(self.tile_svg)
+
+ def _load_paths(self, tile_svg):
+ path_elements = tile_svg.findall('.//svg:path', namespaces=inkex.NSS)
+ 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):
+ svg_element = tile_svg.getroot()
+ self.width = svg_element.viewport_width
+ self.height = svg_element.viewport_height
+
+ def _load_parallelogram(self, tile_svg):
+ parallelogram_elements = tile_svg.findall(".//svg:*[@class='para']", namespaces=inkex.NSS)
+ if parallelogram_elements:
+ path_element = parallelogram_elements[0]
+ path = apply_transforms(path_element.get_path(), path_element)
+ subpaths = path.to_superpath()
+ subpath = subpaths[0]
+ points = [Point.from_tuple(p[1]) for p in subpath]
+ self.shift0 = points[1] - points[0]
+ self.shift1 = points[2] - points[1]
+ else:
+ self.shift0 = Point(self.width, 0)
+ self.shift1 = Point(0, self.height)
+
+ def _path_elements_to_line_strings(self, path_elements):
+ lines = []
+ for path_element in path_elements:
+ path = apply_transforms(path_element.get_path(), path_element)
+ for subpath in path.to_superpath():
+ # We only care about the endpoints of each subpath. They're
+ # supposed to be simple line segments.
+ lines.append([Point.from_tuple(subpath[0][1]), Point.from_tuple(subpath[-1][1])])
+
+ return lines
+
+ def _get_center_and_dimensions(self, shape):
+ min_x, min_y, max_x, max_y = shape.bounds
+ center = Point((max_x + min_x) / 2, (max_y + min_y) / 2)
+ width = max_x - min_x
+ height = max_y - min_y
+
+ return center, width, height
+
+ def _translate_tile(self, shift):
+ translated_tile = []
+
+ for start, end in self.tile:
+ start += shift
+ end += shift
+ translated_tile.append((start.as_int().as_tuple(), end.as_int().as_tuple()))
+
+ return translated_tile
+
+ def _scale(self, x_scale, y_scale):
+ self.shift0 = self.shift0.scale(x_scale, y_scale)
+ self.shift1 = self.shift1.scale(x_scale, y_scale)
+
+ scaled_tile = []
+ for start, end in self.tile:
+ start = start.scale(x_scale, y_scale)
+ end = end.scale(x_scale, y_scale)
+ scaled_tile.append((start, end))
+ self.tile = scaled_tile
+
+ @debug.time
+ def to_graph(self, shape, scale):
+ """Apply this tile to a shape, repeating as necessary.
+
+ Return value:
+ networkx.Graph with edges corresponding to lines in the pattern.
+ Each edge has an attribute 'line_string' with the LineString
+ representation of this edge.
+ """
+ self._load()
+ x_scale, y_scale = scale
+ self._scale(x_scale, y_scale)
+
+ shape_center, shape_width, shape_height = self._get_center_and_dimensions(shape)
+ shape_diagonal = Point(shape_width, shape_height).length()
+ prepared_shape = prep(shape)
+
+ return self._generate_graph(prepared_shape, shape_center, shape_diagonal)
+
+ def _generate_graph(self, shape, shape_center, shape_diagonal):
+ graph = nx.Graph()
+ tiles0 = ceil(shape_diagonal / self.shift0.length()) + 2
+ tiles1 = ceil(shape_diagonal / self.shift1.length()) + 2
+ for repeat0 in range(floor(-tiles0 / 2), ceil(tiles0 / 2)):
+ for repeat1 in range(floor(-tiles1 / 2), ceil(tiles1 / 2)):
+ check_stop_flag()
+
+ shift0 = repeat0 * self.shift0
+ shift1 = repeat1 * self.shift1
+ this_tile = self._translate_tile(shift0 + shift1 + shape_center)
+ for line in this_tile:
+ line_string = LineString(line)
+ if shape.contains(line_string):
+ graph.add_edge(line[0], line[1])
+
+ self._remove_dead_ends(graph)
+
+ return graph
+
+ def _remove_dead_ends(self, graph):
+ graph.remove_edges_from(nx.selfloop_edges(graph))
+ while True:
+ dead_end_nodes = [node for node, degree in graph.degree() if degree <= 1]
+
+ if dead_end_nodes:
+ graph.remove_nodes_from(dead_end_nodes)
+ else:
+ return
+
+
+def all_tile_paths():
+ return [os.path.join(guess_inkscape_config_path(), 'tiles'),
+ get_bundled_dir('tiles')]
+
+
+@cache
+def all_tiles():
+ tiles = []
+ for tiles_path in all_tile_paths():
+ try:
+ for tile_dir in sorted(os.listdir(tiles_path)):
+ try:
+ tiles.append(Tile(os.path.join(tiles_path, tile_dir)))
+ except (OSError, lxml.etree.XMLSyntaxError, json.JSONDecodeError, KeyError) as exc:
+ debug.log(f"error loading tile {tiles_path}/{tile_dir}: {exc}")
+ except Exception as exc:
+ debug.log(f"unexpected error loading tile {tiles_path}/{tile_dir}: {exc}")
+ raise
+ except FileNotFoundError:
+ pass
+
+ return tiles
diff --git a/lib/utils/clamp_path.py b/lib/utils/clamp_path.py
new file mode 100644
index 00000000..e5ef78d8
--- /dev/null
+++ b/lib/utils/clamp_path.py
@@ -0,0 +1,121 @@
+from shapely.geometry import LineString, Point as ShapelyPoint, MultiPolygon
+from shapely.prepared import prep
+from .geometry import Point, ensure_multi_line_string
+
+
+def path_to_segments(path):
+ """Convert a path of Points into a list of segments as LineStrings"""
+ for start, end in zip(path[:-1], path[1:]):
+ yield LineString((start, end))
+
+
+def segments_to_path(segments):
+ """Convert a list of contiguous LineStrings into a list of Points."""
+ coords = [segments[0].coords[0]]
+
+ for segment in segments:
+ coords.extend(segment.coords[1:])
+
+ return [Point(x, y) for x, y in coords]
+
+
+def fix_starting_point(border_pieces):
+ """Reconnect the starting point of a polygon border's pieces.
+
+ When splitting a polygon border with two lines, we want to get two
+ pieces. However, that's not quite how Shapely works. The outline
+ of the polygon is a LinearRing that starts and ends at the same place,
+ but Shapely still knows where that starting point is and splits there
+ too.
+
+ We don't want that third piece, so we'll reconnect the segments that
+ touch the starting point.
+ """
+
+ if len(border_pieces) == 3:
+ # Fortunately, Shapely keeps the starting point of the LinearRing
+ # as the starting point of the first segment. That means it's also
+ # the ending point of the last segment. Reconnecting is super simple:
+ return [border_pieces[1],
+ LineString(border_pieces[2].coords[:] + border_pieces[0].coords[1:])]
+ else:
+ # We probably cut exactly at the starting point.
+ return border_pieces
+
+
+def adjust_line_end(line, end):
+ """Reverse line if necessary to ensure that it ends near end."""
+
+ line_start = ShapelyPoint(*line.coords[0])
+ line_end = ShapelyPoint(*line.coords[-1])
+
+ if line_end.distance(end) < line_start.distance(end):
+ return line
+ else:
+ return LineString(line.coords[::-1])
+
+
+def find_border(polygon, point):
+ for border in polygon.interiors:
+ if border.intersects(point):
+ return border
+ else:
+ return polygon.exterior
+
+
+def clamp_path_to_polygon(path, polygon):
+ """Constrain a path to a Polygon.
+
+ Description: https://gis.stackexchange.com/questions/428848/clamp-linestring-to-polygon
+ """
+
+ path = LineString(path)
+
+ # This splits the path at the points where it intersects with the polygon
+ # border and returns the pieces in the same order as the original path.
+ split_path = ensure_multi_line_string(path.difference(polygon.boundary))
+
+ # contains() checks can fail without this.
+ buffered_polygon = prep(polygon.buffer(1e-9))
+
+ last_segment_inside = None
+ was_inside = False
+ result = []
+
+ for segment in split_path.geoms:
+ if buffered_polygon.contains(segment):
+ if not was_inside:
+ if last_segment_inside is not None:
+ # The path crossed out of the polygon, and now it's crossed
+ # back in. We need to add a path along the border between
+ # the exiting and entering points.
+
+ # First, find the two points. Buffer them just a bit to
+ # ensure intersection with the border.
+ x, y = last_segment_inside.coords[-1]
+ exit_point = ShapelyPoint(x, y).buffer(0.01, resolution=1)
+ x, y = segment.coords[0]
+ entry_point = ShapelyPoint(x, y).buffer(0.01, resolution=1)
+
+ if not exit_point.intersects(entry_point):
+ # Now break the border into pieces using those points.
+ border = find_border(polygon, exit_point)
+ border_pieces = border.difference(MultiPolygon((entry_point, exit_point))).geoms
+ border_pieces = fix_starting_point(border_pieces)
+
+ # Pick the shortest way to get from the exiting to the
+ # entering point along the border.
+ shorter = min(border_pieces, key=lambda piece: piece.length)
+
+ # We don't know which direction the polygon border
+ # piece should be. adjust_line_end() will figure
+ # that out.
+ result.append(adjust_line_end(shorter, entry_point))
+
+ result.append(segment)
+ was_inside = True
+ last_segment_inside = segment
+ else:
+ was_inside = False
+
+ return segments_to_path(result)
diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py
index 789f8720..8f34c467 100644
--- a/lib/utils/geometry.py
+++ b/lib/utils/geometry.py
@@ -220,6 +220,9 @@ class Point:
def rotate(self, angle):
return self.__class__(self.x * math.cos(angle) - self.y * math.sin(angle), self.y * math.cos(angle) + self.x * math.sin(angle))
+ def scale(self, x_scale, y_scale):
+ return self.__class__(self.x * x_scale, self.y * y_scale)
+
def as_int(self):
return self.__class__(int(round(self.x)), int(round(self.y)))
@@ -238,3 +241,7 @@ class Point:
def line_string_to_point_list(line_string):
return [Point(*point) for point in line_string.coords]
+
+
+def coordinate_list_to_point_list(coordinate_list):
+ return [Point.from_tuple(coords) for coords in coordinate_list]
diff --git a/lib/utils/list.py b/lib/utils/list.py
new file mode 100644
index 00000000..efa3969e
--- /dev/null
+++ b/lib/utils/list.py
@@ -0,0 +1,23 @@
+import random
+
+
+def _uniform_rng():
+ while True:
+ yield random.uniform(0, 1)
+
+
+_rng = _uniform_rng()
+
+
+def poprandom(sequence, rng=_rng):
+ index = int(round(next(rng) * (len(sequence) - 1)))
+ 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
diff --git a/lib/utils/prng.py b/lib/utils/prng.py
index 2ec037c6..33102205 100644
--- a/lib/utils/prng.py
+++ b/lib/utils/prng.py
@@ -3,7 +3,7 @@ from math import ceil
from itertools import count, chain
import numpy as np
-# Framework for reproducable pseudo-random number generation.
+# Framework for reproducible pseudo-random number generation.
# Unlike python's random module (which uses a stateful generator based on global variables),
# a counter-mode PRNG like uniformFloats can be used to generate multiple, independent random streams
@@ -13,7 +13,7 @@ import numpy as np
# Using multiple counters for n-dimentional random streams is also possible and is useful for grid-like structures.
-def joinArgs(*args):
+def join_args(*args):
# Stringifies parameters into a slash-separated string for use in hash keys.
# Idempotent and associative.
return "/".join([str(x) for x in args])
@@ -22,37 +22,37 @@ def joinArgs(*args):
MAX_UNIFORM_INT = 2 ** 32 - 1
-def uniformInts(*args):
+def uniform_ints(*args):
# Single pseudo-random drawing determined by the joined parameters.
# To get a longer sequence of random numbers, call this loop with a counter as one of the parameters.
# Returns 8 uniformly random uint32.
- s = joinArgs(*args)
+ s = join_args(*args)
# blake2s is python's fastest hash algorithm for small inputs and is designed to be usable as a PRNG.
h = blake2s(s.encode()).hexdigest()
nums = []
for i in range(0, 64, 8):
- nums.append(int(h[i:i+8], 16))
+ nums.append(int(h[i:i + 8], 16))
return np.array(nums)
-def uniformFloats(*args):
+def uniform_floats(*args):
# Single pseudo-random drawing determined by the joined parameters.
# To get a longer sequence of random numbers, call this loop with a counter as one of the parameters.
# Returns an array of 8 floats in the range [0,1]
- return uniformInts(*args) / MAX_UNIFORM_INT
+ return uniform_ints(*args) / MAX_UNIFORM_INT
-def nUniformFloats(n: int, *args):
+def n_uniform_floats(n: int, *args):
# returns a fixed number (which may exceed 8) of floats in the range [0,1]
- seed = joinArgs(*args)
- nBlocks = ceil(n/8)
- blocks = [uniformFloats(seed, x) for x in range(nBlocks)]
+ seed = join_args(*args)
+ nBlocks = ceil(n / 8)
+ blocks = [uniform_floats(seed, x) for x in range(nBlocks)]
return np.concatenate(blocks)[0:n]
-def iterUniformFloats(*args):
+def iter_uniform_floats(*args):
# returns an infinite sequence of floats in the range [0,1]
- seed = joinArgs(*args)
- blocks = map(lambda x: list(uniformFloats(seed, x)), count(0))
+ seed = join_args(*args)
+ blocks = map(lambda x: list(uniform_floats(seed, x)), count(0))
return chain.from_iterable(blocks)
diff --git a/lib/utils/smoothing.py b/lib/utils/smoothing.py
new file mode 100644
index 00000000..9d43a9f1
--- /dev/null
+++ b/lib/utils/smoothing.py
@@ -0,0 +1,84 @@
+import numpy as np
+from scipy.interpolate import splprep, splev
+
+from .geometry import Point, coordinate_list_to_point_list
+from ..stitches.running_stitch import running_stitch
+from ..debug import debug
+
+
+def _remove_duplicate_coordinates(coords_array):
+ """Remove consecutive duplicate points from an array.
+
+ Arguments:
+ coords_array -- numpy.array
+
+ Returns:
+ a numpy.array of coordinates, minus consecutive duplicates
+ """
+
+ differences = np.diff(coords_array, axis=0)
+ zero_differences = np.isclose(differences, 0)
+ keepers = np.r_[True, np.any(zero_differences == False, axis=1)] # noqa: E712
+
+ return coords_array[keepers]
+
+
+@debug.time
+def smooth_path(path, smoothness=1.0):
+ """Smooth a path of coordinates.
+
+ Arguments:
+ path -- an iterable of coordinate tuples or Points
+ smoothness -- float, how much smoothing to apply. Bigger numbers
+ smooth more.
+
+ Returns:
+ A list of Points.
+ """
+ from ..debug import debug
+
+ if smoothness == 0:
+ # s of exactly zero seems to indicate a default level of smoothing
+ # in splprep, so we'll just exit instead.
+ return path
+
+ # Smoothing seems to look nicer if the line segments in the path are mostly
+ # similar in length. If we have some especially long segments, then the
+ # smoothed path sometimes diverges more from the original path as the
+ # spline curve struggles to fit the path. This can be especially bad at
+ # the start and end.
+ #
+ # Fortunately, we can convert the path to segments that are mostly the same
+ # length by using the running stitch algorithm.
+ path = running_stitch(coordinate_list_to_point_list(path), 5 * smoothness, smoothness / 2)
+
+ # splprep blows up on duplicated consecutive points with "Invalid inputs"
+ coords = _remove_duplicate_coordinates(np.array(path))
+ num_points = len(coords)
+
+ if num_points <= 3:
+ # splprep throws an error unless num_points > k
+ return path
+
+ # s is explained in this issue: https://github.com/scipy/scipy/issues/11916
+ # the smoothness parameter limits how much the smoothed path can deviate
+ # from the original path. The standard deviation of the distance between
+ # the smoothed path and the original path is equal to the smoothness.
+ # In practical terms, if smoothness is 1mm, then the smoothed path can be
+ # up to 1mm away from the original path.
+ s = num_points * (smoothness ** 2)
+
+ # .T transposes the array (for some reason splprep expects
+ # [[x1, x2, ...], [y1, y2, ...]]
+ tck, fp, ier, msg = splprep(coords.T, s=s, k=3, nest=-1, full_output=1)
+ if ier > 0:
+ debug.log(f"error {ier} smoothing path: {msg}")
+ return path
+
+ # Evaluate the spline curve at many points along its length to produce the
+ # smoothed point list. 2 * num_points seems to be a good number, but it
+ # does produce a lot of points.
+ smoothed_x_values, smoothed_y_values = splev(np.linspace(0, 1, int(num_points * 2)), tck[0])
+ coords = np.array([smoothed_x_values, smoothed_y_values]).T
+
+ return [Point(x, y) for x, y in coords]
diff --git a/lib/utils/string.py b/lib/utils/string.py
index cb852ce3..e9204076 100644
--- a/lib/utils/string.py
+++ b/lib/utils/string.py
@@ -8,3 +8,10 @@ def string_to_floats(string, delimiter=","):
floats = string.split(delimiter)
return [float(num) for num in floats]
+
+
+def remove_suffix(string, suffix):
+ if string.endswith(suffix):
+ return string[:-len(suffix)]
+ else:
+ return string