diff --git a/bin/build-python b/bin/build-python index 14f77dae8..ab0e3c27e 100755 --- a/bin/build-python +++ b/bin/build-python @@ -75,7 +75,7 @@ elif [ "$BUILD" = "linux" ]; then # error: # # ELF load command address/offset not properly aligned - find dist/inkstitch -type f | grep -E '.so($|\.)' | grep -v _fblas | xargs strip + find dist/inkstitch -type f | grep -E '\.so($|\.)' | grep -v _fblas | grep -v _flapack | xargs strip else LD_LIBRARY_PATH="${site_packages}/wx" python -m PyInstaller $pyinstaller_args --strip inkstitch.py; fi diff --git a/inkstitch.py b/inkstitch.py index 612def214..1dc5a3e32 100644 --- a/inkstitch.py +++ b/inkstitch.py @@ -2,7 +2,8 @@ # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. - +import cProfile +import pstats import logging import os import sys @@ -50,6 +51,11 @@ my_args, remaining_args = parser.parse_known_args() if os.path.exists(os.path.join(os.path.dirname(os.path.realpath(__file__)), "DEBUG")): debug.enable() +profiler = None +if os.path.exists(os.path.join(os.path.dirname(os.path.realpath(__file__)), "PROFILE")): + profiler = cProfile.Profile() + profiler.enable() + extension_name = my_args.extension # example: foo_bar_baz -> FooBarBaz @@ -58,8 +64,19 @@ extension_class_name = extension_name.title().replace("_", "") extension_class = getattr(extensions, extension_class_name) extension = extension_class() -if hasattr(sys, 'gettrace') and sys.gettrace(): +if (hasattr(sys, 'gettrace') and sys.gettrace()) or profiler is not None: extension.run(args=remaining_args) + if profiler: + path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "profile_stats") + profiler.disable() + profiler.dump_stats(path + ".prof") + + with open(path, 'w') as stats_file: + stats = pstats.Stats(profiler, stream=stats_file) + stats.sort_stats(pstats.SortKey.CUMULATIVE) + stats.print_stats() + + print(f"profiling stats written to {path} and {path}.prof", file=sys.stderr) else: save_stderr() exception = None diff --git a/lib/elements/fill_stitch.py b/lib/elements/fill_stitch.py index 157169ab5..a0ab0d335 100644 --- a/lib/elements/fill_stitch.py +++ b/lib/elements/fill_stitch.py @@ -266,6 +266,10 @@ class FillStitch(EmbroideryElement): valid_shape = make_valid(shape) logger.setLevel(level) + + if isinstance(valid_shape, shgeo.Polygon): + return shgeo.MultiPolygon([valid_shape]) + polygons = [] for polygon in valid_shape.geoms: if isinstance(polygon, shgeo.Polygon): @@ -499,15 +503,18 @@ class FillStitch(EmbroideryElement): return self.get_boolean_param('underlay_underpath', True) def shrink_or_grow_shape(self, shape, amount, validate=False): + new_shape = shape if amount: - shape = shape.buffer(amount) + new_shape = shape.buffer(amount) # changing the size can empty the shape # in this case we want to use the original shape rather than returning an error - if shape.is_empty and not validate: - return shape - if not isinstance(shape, shgeo.MultiPolygon): - shape = shgeo.MultiPolygon([shape]) - return shape + if (new_shape.is_empty and not validate): + new_shape = shape + + if not isinstance(new_shape, shgeo.MultiPolygon): + new_shape = shgeo.MultiPolygon([new_shape]) + + return new_shape def underlay_shape(self, shape): return self.shrink_or_grow_shape(shape, -self.fill_underlay_inset) @@ -532,26 +539,31 @@ class FillStitch(EmbroideryElement): else: return None - def to_stitch_groups(self, last_patch): + def to_stitch_groups(self, last_patch): # noqa: C901 # backwards compatibility: legacy_fill used to be inkstitch:auto_fill == False if not self.auto_fill or self.fill_method == 3: return self.do_legacy_fill() else: stitch_groups = [] - start = self.get_starting_point(last_patch) end = self.get_ending_point() for shape in self.shape.geoms: + start = self.get_starting_point(last_patch) try: if self.fill_underlay: - underlay_stitch_groups, start = self.do_underlay(shape, start) - stitch_groups.extend(underlay_stitch_groups) - if self.fill_method == 0: - stitch_groups.extend(self.do_auto_fill(shape, last_patch, start, end)) - if self.fill_method == 1: - stitch_groups.extend(self.do_contour_fill(self.fill_shape(shape), last_patch, start)) - elif self.fill_method == 2: - stitch_groups.extend(self.do_guided_fill(shape, last_patch, start, end)) + underlay_shapes = self.underlay_shape(shape) + for underlay_shape in underlay_shapes.geoms: + underlay_stitch_groups, start = self.do_underlay(underlay_shape, start) + stitch_groups.extend(underlay_stitch_groups) + + fill_shapes = self.fill_shape(shape) + for fill_shape in fill_shapes.geoms: + if self.fill_method == 0: + stitch_groups.extend(self.do_auto_fill(fill_shape, last_patch, start, end)) + if self.fill_method == 1: + 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 Exception: self.fatal_fill_error() last_patch = stitch_groups[-1] @@ -576,7 +588,7 @@ class FillStitch(EmbroideryElement): color=self.color, tags=("auto_fill", "auto_fill_underlay"), stitches=auto_fill( - self.underlay_shape(shape), + shape, self.fill_underlay_angle[i], self.fill_underlay_row_spacing, self.fill_underlay_row_spacing, @@ -597,7 +609,7 @@ class FillStitch(EmbroideryElement): color=self.color, tags=("auto_fill", "auto_fill_top"), stitches=auto_fill( - self.fill_shape(shape), + shape, self.angle, self.row_spacing, self.end_row_spacing, @@ -663,7 +675,7 @@ class FillStitch(EmbroideryElement): color=self.color, tags=("guided_fill", "auto_fill_top"), stitches=guided_fill( - self.fill_shape(shape), + shape, guide_line.geoms[0], self.angle, self.row_spacing, diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index bf5e1d35b..ce973c0e5 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -150,7 +150,7 @@ class Stroke(EmbroideryElement): return max(self.get_int_param("line_count", 10), 1) def get_line_count(self): - if self.is_closed: + if self.is_closed or self.join_style == 1: return self.line_count + 1 return self.line_count @@ -246,7 +246,7 @@ class Stroke(EmbroideryElement): type='dropdown', default=0, # 0: xy, 1: x, 2: y, 3: none - options=[_("X Y"), _("X"), _("Y"), _("None")], + options=["X Y", "X", "Y", _("None")], select_items=[('stroke_method', 1)], sort_index=12) def scale_axis(self): @@ -286,6 +286,19 @@ class Stroke(EmbroideryElement): def rotate_ripples(self): return self.get_boolean_param("rotate_ripples", True) + @property + @param('join_style', + _('Join style'), + tooltip=_('Join style for non circular ripples.'), + type='dropdown', + default=0, + options=(_("flat"), _("point")), + select_items=[('stroke_method', 1)], + sort_index=16) + @cache + def join_style(self): + return self.get_int_param('join_style', 0) + @property @cache def is_closed(self): diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py index a4c50b60d..0d46b85d9 100644 --- a/lib/stitch_plan/stitch.py +++ b/lib/stitch_plan/stitch.py @@ -4,7 +4,6 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from ..utils.geometry import Point -from copy import deepcopy class Stitch(Point): @@ -12,10 +11,14 @@ class Stitch(Point): def __init__(self, x, y=None, color=None, jump=False, stop=False, trim=False, color_change=False, tie_modus=0, force_lock_stitches=False, no_ties=False, tags=None): + + base_stitch = None if isinstance(x, Stitch): # Allow creating a Stitch from another Stitch. Attributes passed as # arguments will override any existing attributes. - vars(self).update(deepcopy(vars(x))) + base_stitch = x + self.x = base_stitch.x + self.y = base_stitch.y elif isinstance(x, Point): # Allow creating a Stitch from a Point point = x @@ -24,17 +27,19 @@ class Stitch(Point): else: Point.__init__(self, x, y) - self.color = color - self.jump = jump - self.trim = trim - self.stop = stop - self.color_change = color_change - self.force_lock_stitches = force_lock_stitches - self.tie_modus = tie_modus - self.no_ties = no_ties - self.tags = set() + self._set('color', color, base_stitch) + self._set('jump', jump, base_stitch) + self._set('trim', trim, base_stitch) + self._set('stop', stop, base_stitch) + self._set('color_change', color_change, base_stitch) + self._set('force_lock_stitches', force_lock_stitches, base_stitch) + self._set('tie_modus', tie_modus, base_stitch) + self._set('no_ties', no_ties, base_stitch) + self.tags = set() self.add_tags(tags or []) + if base_stitch is not None: + self.add_tags(base_stitch.tags) def __repr__(self): return "Stitch(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" % (self.x, @@ -48,6 +53,14 @@ class Stitch(Point): "NO TIES" if self.no_ties else " ", "COLOR CHANGE" if self.color_change else " ") + def _set(self, attribute, value, base_stitch): + # Set an attribute. If the caller passed a Stitch object, use its value, unless + # they overrode it with arguments. + if base_stitch is not None: + setattr(self, attribute, getattr(base_stitch, attribute)) + if value or base_stitch is None: + setattr(self, attribute, value) + def add_tags(self, tags): for tag in tags: self.add_tag(tag) diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index ac1e477bf..3ff5a24f5 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -59,14 +59,14 @@ def auto_fill(shape, starting_point, ending_point=None, underpath=True): - try: - rows = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) - segments = [segment for row in rows for segment in row] - fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) - except ValueError: - # Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node + rows = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing) + if not rows: + # Small shapes may not intersect with the grating at all. return fallback(shape, running_stitch_length, running_stitch_tolerance) + segments = [segment for row in rows for segment in row] + fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) + if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length): return fallback(shape, running_stitch_length, running_stitch_tolerance) diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 46352d4f5..7c07b5c28 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -132,7 +132,7 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non start -= (start + normal * center) % row_spacing current_row_y = start - + rows = [] while current_row_y < end: p0 = center + normal * current_row_y + direction * half_length p1 = center + normal * current_row_y - direction * half_length @@ -157,13 +157,15 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non runs.reverse() runs = [tuple(reversed(run)) for run in runs] - yield runs + rows.append(runs) if end_row_spacing: current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height) else: current_row_y += row_spacing + return rows + def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers, skip_last): stitches = [] diff --git a/lib/stitches/guided_fill.py b/lib/stitches/guided_fill.py index 7eb49e868..05de14cdd 100644 --- a/lib/stitches/guided_fill.py +++ b/lib/stitches/guided_fill.py @@ -1,15 +1,20 @@ +from math import atan2, copysign +from random import random + import numpy as np +import shapely.prepared from shapely import geometry as shgeo from shapely.affinity import translate -from shapely.ops import linemerge, unary_union +from shapely.ops import linemerge, nearest_points, unary_union -from .auto_fill import (build_fill_stitch_graph, - build_travel_graph, collapse_sequential_outline_edges, fallback, - find_stitch_path, graph_is_valid, travel) from ..debug import debug -from ..i18n import _ from ..stitch_plan import Stitch -from ..utils.geometry import Point as InkstitchPoint, ensure_geometry_collection, ensure_multi_line_string, reverse_line_string +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) def guided_fill(shape, @@ -27,10 +32,15 @@ def guided_fill(shape, 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) + fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point) if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length): - return fallback(shape, running_stitch_length, running_stitch_tolerance) + return fallback(shape, guideline, row_spacing, max_stitch_length, running_stitch_length, running_stitch_tolerance, + num_staggers, skip_last, starting_point, ending_point, underpath) travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath) path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point) @@ -39,6 +49,15 @@ def guided_fill(shape, 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) + + def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, running_stitch_length, running_stitch_tolerance, skip_last): path = collapse_sequential_outline_edges(path) @@ -75,22 +94,23 @@ def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, runni def extend_line(line, shape): (minx, miny, maxx, maxy) = shape.bounds - line = line.simplify(0.01, False) - upper_left = InkstitchPoint(minx, miny) lower_right = InkstitchPoint(maxx, maxy) length = (upper_left - lower_right).length() - point1 = InkstitchPoint(*line.coords[0]) - point2 = InkstitchPoint(*line.coords[1]) - new_starting_point = point1 - (point2 - point1).unit() * length + start_point = InkstitchPoint.from_tuple(line.coords[0]) + end_point = InkstitchPoint.from_tuple(line.coords[-1]) + direction = (end_point - start_point).unit() - point3 = InkstitchPoint(*line.coords[-2]) - point4 = InkstitchPoint(*line.coords[-1]) - new_ending_point = point4 + (point4 - point3).unit() * length + new_start_point = start_point - direction * length + new_end_point = end_point + direction * length - return shgeo.LineString([new_starting_point.as_tuple()] + - line.coords[1:-1] + [new_ending_point.as_tuple()]) + # 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)) def repair_multiple_parallel_offset_curves(multi_line): @@ -114,8 +134,8 @@ def repair_non_simple_line(line): repaired = unary_union(linemerge(line_segments)) counter += 1 if repaired.geom_type != 'LineString': - raise ValueError( - _("Guide line (or offset copy) is self crossing!")) + # They gave us a line with complicated self-intersections. Use a fallback. + return shgeo.LineString((line.coords[0], line.coords[-1])) else: return repaired @@ -158,7 +178,12 @@ def prepare_guide_line(line, shape): if line.geom_type != 'LineString' or not line.is_simple: line = repair_non_simple_line(line) - # extend the line towards the ends to increase probability that all offsetted curves cross the shape + 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 line = extend_line(line, shape) return line @@ -176,24 +201,41 @@ def clean_offset_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) + + def intersect_region_with_grating_guideline(shape, line, row_spacing, num_staggers, max_stitch_length, strategy): - debug.log_line_string(shape.exterior, "guided fill shape") - - if strategy == 0: - translate_direction = InkstitchPoint(*line.coords[-1]) - InkstitchPoint(*line.coords[0]) - translate_direction = translate_direction.unit().rotate_left() - line = prepare_guide_line(line, shape) - row = 0 + 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() + + shape_envelope = shapely.prepared.prep(shape.convex_hull) + + start_row = _get_start_row(line, shape, row_spacing, translate_direction) + row = start_row direction = 1 offset_line = None + rows = [] while True: if strategy == 0: - translate_amount = translate_direction * row * direction * row_spacing + translate_amount = translate_direction * row * row_spacing offset_line = translate(line, xoff=translate_amount.x, yoff=translate_amount.y) elif strategy == 1: - offset_line = line.parallel_offset(row * row_spacing * direction, 'left', join_style=shgeo.JOIN_STYLE.bevel) + offset_line = line.parallel_offset(row * row_spacing, 'left', join_style=shgeo.JOIN_STYLE.round) offset_line = clean_offset_line(offset_line) @@ -201,18 +243,20 @@ def intersect_region_with_grating_guideline(shape, line, row_spacing, num_stagge # 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 * direction}") + debug.log_line_string(offset_line, f"offset {row}") stitched_line = apply_stitches(offset_line, max_stitch_length, num_staggers, row_spacing, row * direction) intersection = shape.intersection(stitched_line) - if intersection.is_empty: + if shape_envelope.intersects(stitched_line): + for segment in take_only_line_strings(intersection).geoms: + rows.append(segment.coords[:]) + row += direction + else: if direction == 1: direction = -1 - row = 1 + row = start_row - 1 else: break - else: - for segment in take_only_line_strings(intersection).geoms: - yield segment.coords[:] - row += 1 + + return rows diff --git a/lib/stitches/ripple_stitch.py b/lib/stitches/ripple_stitch.py index 489666b00..6a0ef7f0b 100644 --- a/lib/stitches/ripple_stitch.py +++ b/lib/stitches/ripple_stitch.py @@ -63,6 +63,8 @@ def _get_helper_lines(stroke): if stroke.is_closed: return False, _get_circular_ripple_helper_lines(stroke, outline) + elif stroke.join_style == 1: + return True, _get_point_style_linear_helper_lines(stroke, outline) else: return True, _get_linear_ripple_helper_lines(stroke, outline) @@ -74,7 +76,7 @@ def _get_satin_ripple_helper_lines(stroke): # use satin column points for satin like build ripple stitches rail_points = SatinColumn(stroke.node).plot_points_on_rails(length, 0) - steps = _get_steps(stroke.line_count, exponent=stroke.exponent, flip=stroke.flip_exponent) + steps = _get_steps(stroke.get_line_count(), exponent=stroke.exponent, flip=stroke.flip_exponent) helper_lines = [] for point0, point1 in zip(*rail_points): @@ -83,24 +85,39 @@ def _get_satin_ripple_helper_lines(stroke): for step in steps: helper_lines[-1].append(InkstitchPoint.from_shapely_point(helper_line.interpolate(step, normalized=True))) + if stroke.join_style == 1: + helper_lines = _converge_helper_line_points(helper_lines, True) + return helper_lines -def _get_circular_ripple_helper_lines(stroke, outline): - helper_lines = _get_linear_ripple_helper_lines(stroke, outline) - - # Now we want to adjust the helper lines to make a spiral. +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): points = [] for j in range(len(line) - 1): - points.append(line[j] * (1 - steps[i]) + line[j + 1] * steps[i]) + if point_edge and j % 2 == 1: + k = num_lines - 1 - i + points.append(line[j] * (1 - steps[k]) + line[j + 1] * steps[k]) + else: + points.append(line[j] * (1 - steps[i]) + line[j + 1] * steps[i]) helper_lines[i] = points return helper_lines +def _get_circular_ripple_helper_lines(stroke, outline): + helper_lines = _get_linear_ripple_helper_lines(stroke, outline) + # Now we want to adjust the helper lines to make a spiral. + return _converge_helper_line_points(helper_lines) + + +def _get_point_style_linear_helper_lines(stroke, outline): + helper_lines = _get_linear_ripple_helper_lines(stroke, outline) + return _converge_helper_line_points(helper_lines, True) + + def _get_linear_ripple_helper_lines(stroke, outline): guide_line = stroke.get_guide_line() max_dist = stroke.grid_size or stroke.running_stitch_length @@ -124,10 +141,25 @@ def _target_point_helper_lines(stroke, outline): return helper_lines +def _adjust_helper_lines_for_grid(stroke, helper_lines): + num_lines = stroke.line_count - stroke.skip_end + if stroke.reverse: + helper_lines = [helper_line[::-1] for helper_line in helper_lines] + num_lines = stroke.skip_start + if (num_lines % 2 != 0 and not stroke.is_closed) or (stroke.is_closed and not stroke.reverse): + helper_lines.reverse() + + return helper_lines + + def _do_grid(stroke, helper_lines): + helper_lines = _adjust_helper_lines_for_grid(stroke, helper_lines) + start = stroke.get_skip_start() + skip_end = stroke.get_skip_end() + if stroke.reverse: + start, skip_end = skip_end, start for i, helper in enumerate(helper_lines): - start = stroke.get_skip_start() - end = len(helper) - stroke.get_skip_end() + end = len(helper) - skip_end points = helper[start:end] if i % 2 == 0: points.reverse() @@ -146,7 +178,7 @@ def _get_guided_helper_lines(stroke, outline, max_distance): def _generate_guided_helper_lines(stroke, outline, max_distance, guide_line): - # helper lines are generated by making copies of the outline alog the guide line + # helper lines are generated by making copies of the outline along the guide line line_point_dict = defaultdict(list) outline = LineString(running_stitch(line_string_to_point_list(outline), max_distance, stroke.running_stitch_tolerance)) diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 63c815fc4..d113bb6d9 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -64,17 +64,7 @@ inkstitch_attribs = [ 'join_style', 'avoid_self_crossing', 'clockwise', - 'line_count', - 'skip_start', - 'skip_end', - 'grid_size', 'reverse', - 'exponent', - 'flip_exponent', - 'scale_axis', - 'scale_start', - 'scale_end', - 'rotate_ripples', 'expand_mm', 'fill_underlay', 'fill_underlay_angle', @@ -97,6 +87,17 @@ inkstitch_attribs = [ 'repeats', 'running_stitch_length_mm', 'running_stitch_tolerance_mm', + # ripples + 'line_count', + 'exponent', + 'flip_exponent', + 'skip_start', + 'skip_end', + 'scale_axis', + 'scale_start', + 'scale_end', + 'rotate_ripples', + 'grid_size', # satin column 'satin_column', 'short_stitch_distance_mm', diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index f5ba4ad8c..304577497 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -129,6 +129,10 @@ class Point: def from_shapely_point(cls, point): return cls(point.x, point.y) + @classmethod + def from_tuple(cls, point): + return cls(point[0], point[1]) + def __json__(self): return vars(self)