diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index 7de293cbf..eef8341c9 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -22,6 +22,7 @@ from ..svg.tags import INKSCAPE_LABEL from ..utils import cache, version from .element import EmbroideryElement, param from .validation import ValidationError, ValidationWarning +from ..utils.threading import ExitThread class SmallShapeWarning(ValidationWarning): @@ -571,6 +572,8 @@ class FillStitch(EmbroideryElement): stitch_groups.extend(self.do_contour_fill(fill_shape, last_patch, start)) elif self.fill_method == 2: stitch_groups.extend(self.do_guided_fill(fill_shape, last_patch, start, end)) + except ExitThread: + raise except Exception: self.fatal_fill_error() last_patch = stitch_groups[-1] diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index be614a044..4028ad275 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -21,6 +21,7 @@ from ..utils import Point, cache, cut, cut_multiple, prng from ..stitches import running_stitch from .element import EmbroideryElement, param, PIXELS_PER_MM from .validation import ValidationError, ValidationWarning +from ..utils.threading import check_stop_flag class TooFewPathsError(ValidationError): @@ -818,10 +819,12 @@ class SatinColumn(EmbroideryElement): index1 = 0 while index0 < last_index0 and index1 < last_index1: + check_stop_flag() + # Each iteration of this outer loop is one stitch. Keep going # until we fall off the end of the section. - old_center = shgeo.Point(x/2 for x in (pos0 + pos1)) + old_center = shgeo.Point(x / 2 for x in (pos0 + pos1)) while to_travel > 0 and index0 < last_index0 and index1 < last_index1: # In this loop, we inch along each rail a tiny bit per diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py index 0d18449a9..c997e2016 100644 --- a/lib/extensions/lettering.py +++ b/lib/extensions/lettering.py @@ -24,6 +24,7 @@ from ..svg.tags import (INKSCAPE_LABEL, INKSTITCH_LETTERING, SVG_GROUP_TAG, from ..utils import DotDict, cache, get_bundled_dir, get_resource_dir from .commands import CommandsExtension from .lettering_custom_font_dir import get_custom_font_dir +from ..utils.threading import ExitThread class LetteringFrame(wx.Frame): @@ -344,6 +345,8 @@ class LetteringFrame(wx.Frame): patches.extend(element.embroider(None)) except SystemExit: raise + except ExitThread: + raise except Exception: raise # Ignore errors. This can be things like incorrect paths for diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 306e5e560..1262ceb6f 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -25,6 +25,7 @@ from ..i18n import _ from ..svg.tags import SVG_POLYLINE_TAG from ..utils import get_resource_dir from .base import InkstitchExtension +from ..utils.threading import ExitThread, check_stop_flag def grouper(iterable_obj, count, fillvalue=None): @@ -509,9 +510,13 @@ class SettingsFrame(wx.Frame): # for many params in embroider.py. patches.extend(copy(node).embroider(None)) + + check_stop_flag() except SystemExit: wx.CallAfter(self._show_warning) raise + except ExitThread: + raise except Exception as e: # Ignore errors. This can be things like incorrect paths for # satins or division by zero caused by incorrect param values. diff --git a/lib/gui/simulator.py b/lib/gui/simulator.py index 1cc7066e3..d9f51b487 100644 --- a/lib/gui/simulator.py +++ b/lib/gui/simulator.py @@ -10,6 +10,8 @@ from threading import Event, Thread import wx from wx.lib.intctrl import IntCtrl +from lib.debug import debug +from lib.utils.threading import ExitThread from ..i18n import _ from ..stitch_plan import stitch_groups_to_stitch_plan, stitch_plan_from_file from ..svg import PIXELS_PER_MM @@ -749,6 +751,10 @@ class SimulatorPreview(Thread): self.simulate_window = None self.refresh_needed = Event() + # This is read by utils.threading.check_stop_flag() to abort stitch plan + # generation. + self.stop = Event() + # used when closing to avoid having the window reopen at the last second self._disabled = False @@ -770,17 +776,27 @@ class SimulatorPreview(Thread): if not self.is_alive(): self.start() + self.stop.set() self.refresh_needed.set() def run(self): while True: self.refresh_needed.wait() self.refresh_needed.clear() - self.update_patches() + self.stop.clear() + + try: + debug.log("update_patches") + self.update_patches() + except ExitThread: + debug.log("ExitThread caught") + self.stop.clear() def update_patches(self): try: patches = self.parent.generate_patches(self.refresh_needed) + except ExitThread: + raise except: # noqa: E722 # If something goes wrong when rendering patches, it's not great, # but we don't really want the simulator thread to crash. Instead, diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py index 313bc6e06..741ec0061 100644 --- a/lib/stitch_plan/stitch_plan.py +++ b/lib/stitch_plan/stitch_plan.py @@ -11,6 +11,7 @@ from ..i18n import _ from ..svg import PIXELS_PER_MM from .color_block import ColorBlock from .ties import add_ties +from ..utils.threading import check_stop_flag def stitch_groups_to_stitch_plan(stitch_groups, collapse_len=None, min_stitch_len=0.1, disable_ties=False): # noqa: C901 @@ -34,6 +35,8 @@ def stitch_groups_to_stitch_plan(stitch_groups, collapse_len=None, min_stitch_le color_block = stitch_plan.new_color_block(color=stitch_groups[0].color) for stitch_group in stitch_groups: + check_stop_flag() + if not stitch_group.stitches: continue diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 3ff5a24f5..fde3433af 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -19,6 +19,7 @@ from ..svg import PIXELS_PER_MM from ..utils.geometry import Point as InkstitchPoint, line_string_to_point_list, ensure_multi_line_string from .fill import intersect_region_with_grating, stitch_row from .running_stitch import running_stitch +from ..utils.threading import check_stop_flag class PathEdge(object): @@ -153,6 +154,8 @@ def build_fill_stitch_graph(shape, segments, starting_point=None, ending_point=N # mark this one as a grating segment. graph.add_edge(segment[0], segment[-1], key="segment", underpath_edges=[], geometry=shgeo.LineString(segment)) + check_stop_flag() + tag_nodes_with_outline_and_projection(graph, shape, graph.nodes()) add_edges_between_outline_nodes(graph, duplicate_every_other=True) @@ -196,6 +199,8 @@ def tag_nodes_with_outline_and_projection(graph, shape, nodes): graph.add_node(node, outline=outline_index, projection=outline_projection) + check_stop_flag() + def add_boundary_travel_nodes(graph, shape): outlines = ensure_multi_line_string(shape.boundary).geoms @@ -215,6 +220,8 @@ def add_boundary_travel_nodes(graph, shape): subpoint = segment.interpolate(i) graph.add_node((subpoint.x, subpoint.y), projection=outline.project(subpoint), outline=outline_index) + check_stop_flag() + graph.add_node((point.x, point.y), projection=outline.project(point), outline=outline_index) prev = point @@ -245,6 +252,8 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False): if i % 2 == 0: graph.add_edge(node1, node2, key="extra", **data) + check_stop_flag() + def graph_is_valid(graph, shape, max_stitch_length): # The graph may be empty if the shape is so small that it fits between the @@ -382,6 +391,8 @@ def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges): graph.add_edge(*edge, weight=weight) + check_stop_flag() + # without this, we sometimes get exceptions like this: # Exception AttributeError: "'NoneType' object has no attribute 'GEOSSTRtree_destroy'" in # > ignored @@ -444,9 +455,13 @@ def build_travel_edges(shape, fill_angle): diagonal_edges = ensure_multi_line_string(grating1.symmetric_difference(grating2)) + check_stop_flag() + # without this, floating point inaccuracies prevent the intersection points from lining up perfectly. vertical_edges = ensure_multi_line_string(snap(grating3.difference(grating1), diagonal_edges, 0.005)) + check_stop_flag() + return endpoints, chain(diagonal_edges.geoms, vertical_edges.geoms) @@ -540,6 +555,8 @@ def find_stitch_path(graph, travel_graph, starting_point=None, ending_point=None real_end = nearest_node(outline_nodes, ending_point) path.append(PathEdge((ending_node, real_end), key="outline")) + check_stop_flag() + return path @@ -629,4 +646,6 @@ def path_to_stitches(path, travel_graph, fill_stitch_graph, angle, row_spacing, else: stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, running_stitch_tolerance, skip_last)) + check_stop_flag() + return stitches diff --git a/lib/stitches/auto_run.py b/lib/stitches/auto_run.py index ff26eb165..82c7fc7eb 100644 --- a/lib/stitches/auto_run.py +++ b/lib/stitches/auto_run.py @@ -21,6 +21,7 @@ from .utils.autoroute import (add_elements_to_group, add_jumps, get_starting_and_ending_nodes, preserve_original_groups, remove_original_elements) +from ..utils.threading import check_stop_flag class LineSegments: @@ -59,9 +60,13 @@ class LineSegments: self._lines.append(line) self._elements.append(element) + check_stop_flag() + def _get_intersection_points(self): for i, line1 in enumerate(self._lines): for j in range(i + 1, len(self._lines)): + check_stop_flag() + line2 = self._lines[j] distance = line1.distance(line2) if distance > 50: @@ -169,6 +174,8 @@ def build_graph(elements, preserve_order, break_up): # any direction, so we add the edge in the opposite direction too. graph.add_edge(str(end), str(start), element=element) + check_stop_flag() + return graph @@ -199,6 +206,8 @@ def path_to_elements(graph, path, trim): # noqa: C901 just_trimmed = False el = None for start, end, direction in path: + check_stop_flag() + element = graph[start][end].get('element') start_coord = graph.nodes[start]['point'] end_coord = graph.nodes[end]['point'] diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py index 6d792b4ed..228f13252 100644 --- a/lib/stitches/auto_satin.py +++ b/lib/stitches/auto_satin.py @@ -24,6 +24,7 @@ from .utils.autoroute import (add_elements_to_group, add_jumps, get_starting_and_ending_nodes, preserve_original_groups, remove_original_elements) +from ..utils.threading import check_stop_flag class SatinSegment(object): @@ -366,6 +367,8 @@ def build_graph(elements, preserve_order=False): # best spots for us. for element in elements: + check_stop_flag() + segments = [] if isinstance(element, Stroke): segments.append(RunningStitch(element)) diff --git a/lib/stitches/contour_fill.py b/lib/stitches/contour_fill.py index 86a554063..885a7e6c7 100644 --- a/lib/stitches/contour_fill.py +++ b/lib/stitches/contour_fill.py @@ -15,6 +15,7 @@ from ..utils import DotDict from ..utils.geometry import (cut, ensure_geometry_collection, ensure_multi_polygon, reverse_line_string, roll_linear_ring) +from ..utils.threading import check_stop_flag from .running_stitch import running_stitch @@ -132,6 +133,8 @@ def offset_polygon(polygon, offset, join_style, clockwise): active_holes[0].append(hole_node) while len(active_polygons) > 0: + check_stop_flag() + current_poly = active_polygons.pop() current_holes = active_holes.pop() @@ -325,6 +328,7 @@ def _find_path_inner_to_outer(tree, node, offset, starting_point, avoid_self_cro Return value: LineString -- the stitching path """ + check_stop_flag() current_node = tree.nodes[node] current_ring = current_node.val @@ -473,6 +477,8 @@ def _check_and_prepare_tree_for_valid_spiral(tree): """ def process_node(node): + check_stop_flag() + children = set(tree[node]) if len(children) == 0: @@ -526,6 +532,8 @@ def _get_spiral_rings(tree): node = 'root' while True: + check_stop_flag() + rings.append(tree.nodes[node].val) children = tree[node] @@ -556,6 +564,8 @@ def _make_spiral(rings, stitch_length, starting_point): path = [] for ring1, ring2 in zip(rings[:-1], rings[1:]): + check_stop_flag() + spiral_part = _interpolate_linear_rings(ring1, ring2, stitch_length, starting_point) path.extend(spiral_part.coords) diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 11c9259b3..3bd7761d6 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -11,6 +11,7 @@ from ..stitch_plan import Stitch from ..svg import PIXELS_PER_MM from ..utils import Point as InkstitchPoint from ..utils import cache +from ..utils.threading import check_stop_flag def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers, skip_last): @@ -139,6 +140,8 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non current_row_y = start rows = [] while current_row_y < end: + check_stop_flag() + p0 = center + normal * current_row_y + direction * half_length p1 = center + normal * current_row_y - direction * half_length endpoints = [p0.as_tuple(), p1.as_tuple()] @@ -169,6 +172,8 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non else: current_row_y += row_spacing + check_stop_flag() + return rows diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py index a9906bfbf..4c441b321 100644 --- a/lib/stitches/guided_fill.py +++ b/lib/stitches/guided_fill.py @@ -15,6 +15,7 @@ from ..utils.geometry import (ensure_geometry_collection, from .auto_fill import (auto_fill, build_fill_stitch_graph, build_travel_graph, collapse_sequential_outline_edges, find_stitch_path, graph_is_valid, travel) +from ..utils.threading import check_stop_flag def guided_fill(shape, @@ -68,6 +69,8 @@ def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, runni stitches.append(Stitch(*path[0].nodes[0])) for edge in path: + check_stop_flag() + if edge.is_segment(): current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment'] path_geometry = current_edge['geometry'] @@ -233,6 +236,8 @@ def intersect_region_with_grating_guideline(shape, line, row_spacing, num_stagge offset_line = None rows = [] while True: + check_stop_flag() + if strategy == 0: translate_amount = translate_direction * row * row_spacing offset_line = translate(line, xoff=translate_amount.x, yoff=translate_amount.y) diff --git a/lib/stitches/ripple_stitch.py b/lib/stitches/ripple_stitch.py index a66eff746..330541193 100644 --- a/lib/stitches/ripple_stitch.py +++ b/lib/stitches/ripple_stitch.py @@ -9,6 +9,7 @@ from .running_stitch import running_stitch from ..elements import SatinColumn from ..utils import Point as InkstitchPoint from ..utils.geometry import line_string_to_point_list +from ..utils.threading import check_stop_flag def ripple_stitch(stroke): @@ -80,6 +81,8 @@ def _get_satin_ripple_helper_lines(stroke): helper_lines = [] for point0, point1 in rail_pairs: + check_stop_flag() + helper_lines.append([]) helper_line = LineString((point0, point1)) for step in steps: @@ -95,6 +98,8 @@ def _converge_helper_line_points(helper_lines, point_edge=False): num_lines = len(helper_lines) steps = _get_steps(num_lines) for i, line in enumerate(helper_lines): + check_stop_flag() + points = [] for j in range(len(line) - 1): if point_edge and j % 2 == 1: @@ -133,6 +138,8 @@ def _target_point_helper_lines(stroke, outline): target = stroke.get_ripple_target() steps = _get_steps(stroke.get_line_count(), exponent=stroke.exponent, flip=stroke.flip_exponent) for i, point in enumerate(outline.coords): + check_stop_flag() + line = LineString([point, target]) for step in steps: @@ -193,6 +200,8 @@ def _generate_guided_helper_lines(stroke, outline, max_distance, guide_line): previous_guide_point = None for i in range(stroke.get_line_count()): + check_stop_flag() + guide_point = InkstitchPoint.from_shapely_point(guide_line.interpolate(outline_steps[i], normalized=True)) translation = guide_point - start_point scaling = scale_steps[i] @@ -232,6 +241,8 @@ def _generate_satin_guide_helper_lines(stroke, outline, guide_line): # add scaled and rotated outlines along the satin column guide line for i, (point0, point1) in enumerate(zip(*rail_points)): + check_stop_flag() + guide_center = (point0 + point1) / 2 translation = guide_center - outline_center if stroke.rotate_ripples: diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index c1c2d99c1..8ba534988 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -12,6 +12,7 @@ import numpy as np from shapely import geometry as shgeo from ..utils import prng from ..utils.geometry import Point +from ..utils.threading import check_stop_flag """ Utility functions to produce running stitches. """ @@ -196,6 +197,8 @@ def stitch_curve_evenly(points: typing.Sequence[Point], stitch_length: float, to last = points[0] stitches = [] while i is not None and i < len(points): + check_stop_flag() + d = last.distance(points[i]) + distLeft[i] if d == 0: return stitches diff --git a/lib/stitches/utils/autoroute.py b/lib/stitches/utils/autoroute.py index 5acb14002..3ada42995 100644 --- a/lib/stitches/utils/autoroute.py +++ b/lib/stitches/utils/autoroute.py @@ -13,6 +13,7 @@ import inkex from ...svg import get_correction_transform from ...svg.tags import INKSCAPE_LABEL +from ...utils.threading import check_stop_flag def find_path(graph, starting_node, ending_node): @@ -31,6 +32,8 @@ def find_path(graph, starting_node, ending_node): # "underpathing". path = nx.shortest_path(graph, starting_node, ending_node) + check_stop_flag() + # Copy the graph so that we can remove the edges as we visit them. # This also converts the directed graph into an undirected graph in the # case that "preserve_order" is set. @@ -40,6 +43,8 @@ def find_path(graph, starting_node, ending_node): final_path = [] prev = None for node in path: + check_stop_flag() + if prev is not None: final_path.append((prev, node)) prev = node @@ -85,6 +90,8 @@ def add_jumps(graph, elements, preserve_order): # will enforce stitching the elements in order. for element1, element2 in zip(elements[:-1], elements[1:]): + check_stop_flag() + potential_edges = [] nodes1 = get_nodes_on_element(graph, element1) @@ -106,6 +113,7 @@ def add_jumps(graph, elements, preserve_order): # a weight, which we'll set as the length of the jump stitch. The # algorithm will minimize the total length of jump stitches added. for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): + check_stop_flag() graph.add_edge(*jump, jump=True) return graph diff --git a/lib/utils/threading.py b/lib/utils/threading.py new file mode 100644 index 000000000..f0c228876 --- /dev/null +++ b/lib/utils/threading.py @@ -0,0 +1,22 @@ +import threading + +from ..exceptions import InkstitchException +from ..debug import debug + + +class ExitThread(InkstitchException): + """This exception is thrown in a thread to cause it to terminate. + + Presumably we should only catch this at the thread's top level. + """ + pass + + +# A default flag used for the main thread. It will never be set. +_default_stop_flag = threading.Event() + + +def check_stop_flag(): + if getattr(threading.current_thread(), 'stop', _default_stop_flag).is_set(): + debug.log("exiting thread") + raise ExitThread()