diff --git a/lib/elements/__init__.py b/lib/elements/__init__.py index bcb341e11..e1c354825 100644 --- a/lib/elements/__init__.py +++ b/lib/elements/__init__.py @@ -11,4 +11,4 @@ from .image import ImageObject from .satin_column import SatinColumn from .stroke import Stroke from .text import TextObject -from .utils import node_to_elements, nodes_to_elements +from .utils.nodes import iterate_nodes, node_to_elements, nodes_to_elements diff --git a/lib/elements/clone.py b/lib/elements/clone.py index 5d0ae7fa1..bc668bf6a 100644 --- a/lib/elements/clone.py +++ b/lib/elements/clone.py @@ -76,7 +76,7 @@ class Clone(EmbroideryElement): def clone_to_elements(self, node: BaseElement) -> List[EmbroideryElement]: # Only used in get_cache_key_data, actual embroidery uses nodes_to_elements+iterate_nodes - from .utils import node_to_elements + from .utils.nodes import node_to_elements elements = [] if node.tag in EMBROIDERABLE_TAGS: elements = node_to_elements(node, True) @@ -141,7 +141,7 @@ class Clone(EmbroideryElement): Could possibly be refactored into just a generator - being a context manager is mainly to control the lifecycle of the elements that are cloned (again, for testing convenience primarily) """ - from .utils import iterate_nodes, nodes_to_elements + from .utils.nodes import iterate_nodes, nodes_to_elements cloned_nodes = self.resolve_clone() try: diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index 51a10cad3..42b650ed3 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -9,7 +9,7 @@ from copy import deepcopy from itertools import chain import numpy as np -from inkex import paths +from inkex import Path from shapely import affinity as shaffinity from shapely import geometry as shgeo from shapely import set_precision @@ -20,23 +20,16 @@ from ..i18n import _ from ..metadata import InkStitchMetadata from ..stitch_plan import Stitch, StitchGroup from ..stitches import running_stitch -from ..svg import line_strings_to_csp, point_lists_to_csp +from ..svg import line_strings_to_coordinate_lists +from ..svg.styles import get_join_style_args from ..utils import Point, cache, cut, cut_multiple, offset_points, prng from ..utils.param import ParamOption from ..utils.threading import check_stop_flag from .element import PIXELS_PER_MM, EmbroideryElement, param +from .utils.stroke_to_satin import convert_path_to_satin from .validation import ValidationError, ValidationWarning -class TooFewPathsError(ValidationError): - name = _("Too few subpaths") - description = _("Satin column: Object has too few subpaths. A satin column should have at least two subpaths (the rails).") - steps_to_solve = [ - _("* Add another subpath (select two rails and do Path > Combine)"), - _("* Convert to running stitch or simple satin (Params extension)") - ] - - class NotStitchableError(ValidationError): name = _("Not stitchable satin column") description = _("A satin column consists out of two rails and one or more rungs. This satin column may have a different setup.") @@ -77,6 +70,15 @@ class TooManyIntersectionsWarning(ValidationWarning): description = _("Satin column: A rung intersects a rail more than once.") + " " + rung_message +class StrokeSatinWarning(ValidationWarning): + name = _("Simple Satin") + description = ("If you need more control over the stitch directions within this satin column, convert it to a real satin path") + steps_to_solve = [ + _('* Select the satin path'), + _('* Run Extensions > Ink/Stitch > Tools: Satin > Stroke to Satin') + ] + + class TwoRungsWarning(ValidationWarning): name = _("Satin has exactly two rungs") description = _("There are exactly two rungs. This may lead to false rail/rung detection.") @@ -320,7 +322,7 @@ class SatinColumn(EmbroideryElement): elif choice == 'both': return True, True elif choice == 'automatic': - rails = [shgeo.LineString(self.flatten_subpath(rail)) for rail in self.rails] + rails = [shgeo.LineString(rail) for rail in self.rails] if len(rails) == 2: # Sample ten points along the rails. Compare the distance # between corresponding points on both rails with and without @@ -598,7 +600,7 @@ class SatinColumn(EmbroideryElement): # This isn't used for satins at all, but other parts of the code # may need to know the general shape of a satin column. - return shgeo.MultiLineString(self.flattened_rails) + return shgeo.MultiLineString(self.line_string_rails) @property @cache @@ -615,17 +617,21 @@ class SatinColumn(EmbroideryElement): @property @cache - def csp(self): - paths = self.parse_path() - # exclude subpaths which are just a point - paths = [path for path in paths if len(self.flatten_subpath(path)) > 1] + def filtered_subpaths(self): + paths = [path for path in self.paths if len(path) > 1] + if len(paths) == 1: + style_args = get_join_style_args(self) + new_satin = convert_path_to_satin(paths[0], self.stroke_width, style_args) + if new_satin: + rails, rungs = new_satin + paths = list(rails) + list(rungs) return paths @property @cache def rails(self): """The rails in order, as point lists""" - rails = [subpath for i, subpath in enumerate(self.csp) if i in self.rail_indices] + rails = [subpath for i, subpath in enumerate(self.filtered_subpaths) if i in self.rail_indices] if len(rails) == 2 and self.swap_rails: return [rails[1], rails[0]] else: @@ -633,9 +639,9 @@ class SatinColumn(EmbroideryElement): @property @cache - def flattened_rails(self): + def line_string_rails(self): """The rails, as LineStrings.""" - paths = [set_precision(shgeo.LineString(self.flatten_subpath(rail)), 0.00001) for rail in self.rails] + paths = [set_precision(shgeo.LineString(rail), 0.00001) for rail in self.rails] rails_to_reverse = self._get_rails_to_reverse() if paths and rails_to_reverse is not None: @@ -651,8 +657,9 @@ class SatinColumn(EmbroideryElement): @property @cache - def flattened_rungs(self): - return tuple(shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs) + def line_string_rungs(self): + """The rungs as LineStrings""" + return tuple(shgeo.LineString(rung) for rung in self.rungs) @property @cache @@ -663,12 +670,12 @@ class SatinColumn(EmbroideryElement): rails are expected to have the same number of path nodes. The path nodes, taken in sequential pairs, act in the same way as rungs would. """ - if len(self.csp) == 2: + if len(self.filtered_subpaths) == 2: # It's an old-style satin column. To make things easier we'll # actually create the implied rungs. return self._synthesize_rungs() else: - return [subpath for i, subpath in enumerate(self.csp) if i not in self.rail_indices] + return [subpath for i, subpath in enumerate(self.filtered_subpaths) if i not in self.rail_indices] @cache def _synthesize_rungs(self): @@ -677,8 +684,7 @@ class SatinColumn(EmbroideryElement): equal_length = len(self.rails[0]) == len(self.rails[1]) rails_to_reverse = self._get_rails_to_reverse() - for i, rail in enumerate(self.rails): - points = self.strip_control_points(rail) + for i, points in enumerate(self.rails): if rails_to_reverse[i]: points = points[::-1] @@ -699,15 +705,14 @@ class SatinColumn(EmbroideryElement): rung = shgeo.LineString((start, end)) # make it a bit bigger so that it definitely intersects rung = shaffinity.scale(rung, 1.1, 1.1).coords - rungs.append([[rung[0]] * 3, [rung[1]] * 3]) + rungs.append(rung) return rungs @property @cache def rail_indices(self): - paths = [self.flatten_subpath(subpath) for subpath in self.csp] - paths = [shgeo.LineString(path) for path in paths if len(path) > 1] + paths = [shgeo.LineString(path) for path in self.filtered_subpaths if len(path) > 1] num_paths = len(paths) # Imagine a satin column as a curvy ladder. @@ -762,8 +767,8 @@ class SatinColumn(EmbroideryElement): def flattened_sections(self): """Flatten the rails, cut with the rungs, and return the sections in pairs.""" - rails = list(self.flattened_rails) - rungs = self.flattened_rungs + rails = list(self.line_string_rails) + rungs = list(self.line_string_rungs) cut_points = [[], []] for rung in rungs: intersections = rung.intersection(shgeo.MultiLineString(rails)) @@ -798,27 +803,30 @@ class SatinColumn(EmbroideryElement): return sections - def validation_warnings(self): - if len(self.csp) == 4: - yield TwoRungsWarning(self.flattened_rails[0].interpolate(0.5, normalized=True)) - elif len(self.csp) == 2: - yield NoRungWarning(self.flattened_rails[1].representative_point()) + def validation_warnings(self): # noqa: C901 + paths = self.node.get_path() + if any([path.letter == 'Z' for path in paths]): + yield ClosedPathWarning(self.line_string_rails[0].coords[0]) + + if len(self.paths) == 1: + yield StrokeSatinWarning(self.center_line.interpolate(0.5, normalized=True)) + elif len(self.filtered_subpaths) == 4: + yield TwoRungsWarning(self.line_string_rails[0].interpolate(0.5, normalized=True)) + elif len(self.filtered_subpaths) == 2: + yield NoRungWarning(self.line_string_rails[1].representative_point()) if len(self.rails[0]) != len(self.rails[1]): - yield UnequalPointsWarning(self.flattened_rails[0].interpolate(0.5, normalized=True)) - elif len(self.csp) > 2: - for rung in self.flattened_rungs: - for rail in self.flattened_rails: + yield UnequalPointsWarning(self.line_string_rails[0].interpolate(0.5, normalized=True)) + elif len(self.filtered_subpaths) > 2: + for rung in self.line_string_rungs: + for rail in self.line_string_rails: intersection = rung.intersection(rail) if intersection.is_empty: yield DanglingRungWarning(rung.interpolate(0.5, normalized=True)) elif not isinstance(intersection, shgeo.Point): yield TooManyIntersectionsWarning(rung.interpolate(0.5, normalized=True)) - paths = self.node.get_path() - if any([path.letter == 'Z' for path in paths]): - yield ClosedPathWarning(self.flattened_rails[0].coords[0]) def validation_errors(self): - if len(self.flattened_rails) == 0: + if len(self.line_string_rails) == 0: # Non existing rails can happen due to insane transforms which reduce the size of the # satin to zero. The path should still be pointable. try: @@ -826,16 +834,9 @@ class SatinColumn(EmbroideryElement): except IndexError: point = (0, 0) yield NotStitchableError(point) - else: - # The node should have exactly two paths with the same number of points - or it should - # have two rails and at least one rung - if len(self.csp) < 2: - yield TooFewPathsError((0, 0)) - elif len(self.rails) < 2: - yield TooFewPathsError(self.flattened_rails[0].representative_point()) if not self.to_stitch_groups(): - yield NotStitchableError(self.flattened_rails[0].representative_point()) + yield NotStitchableError(self.line_string_rails[0].representative_point()) def _center_walk_is_odd(self): return self.center_walk_underlay and self.center_walk_underlay_repeats % 2 == 1 @@ -843,23 +844,21 @@ class SatinColumn(EmbroideryElement): def reverse(self): """Return a new SatinColumn like this one but in the opposite direction. - The path will be flattened and the new satin will contain a new XML - node that is not yet in the SVG. + The new satin will contain a new XML node that is not yet in the SVG. """ - # flatten the path because you can't just reverse a CSP subpath's elements (I think) point_lists = [] for rail in self.rails: - point_lists.append(list(reversed(self.flatten_subpath(rail)))) + point_lists.append(list(reversed(rail))) for rung in self.rungs: - point_lists.append(self.flatten_subpath(rung)) + point_lists.append(rung) # If originally there were only two subpaths (no rungs) with same number of points, the rails may now # have two rails with different number of points, and still no rungs, let's add one. if not self.rungs: - rails = [shgeo.LineString(reversed(self.flatten_subpath(rail))) for rail in self.rails] + rails = [shgeo.LineString(reversed(rail)) for rail in self.rails] rails.reverse() path_list = rails @@ -871,21 +870,21 @@ class SatinColumn(EmbroideryElement): path_list.append(rung) return (self._path_list_to_satins(path_list)) - return self._csp_to_satin(point_lists_to_csp(point_lists)) + return self._coordinates_to_satin(point_lists) def flip(self): """Return a new SatinColumn like this one but with flipped rails. - The path will be flattened and the new satin will contain a new XML + The new satin will contain a new XML node that is not yet in the SVG. """ - csp = self.path + path = self.filtered_subpaths - if len(csp) > 1: + if len(path) > 1: first, second = self.rail_indices - csp[first], csp[second] = csp[second], csp[first] + path[first], path[second] = path[second], path[first] - return self._csp_to_satin(csp) + return self._coordinates_to_satin(path) def apply_transform(self): """Return a new SatinColumn like this one but with transforms applied. @@ -894,7 +893,7 @@ class SatinColumn(EmbroideryElement): new SatinColumn's node will not be in the SVG document. """ - return self._csp_to_satin(self.csp) + return self._coordinates_to_satin(self.filtered_subpaths) def split(self, split_point, cut_points=None): """Split a satin into two satins at the specified point @@ -976,7 +975,7 @@ class SatinColumn(EmbroideryElement): rails. Each element is a list of two rails of type LineString. """ - rails = [shgeo.LineString(self.flatten_subpath(rail)) for rail in self.rails] + rails = [shgeo.LineString(rail) for rail in self.rails] path_lists = [[], []] @@ -1009,7 +1008,7 @@ class SatinColumn(EmbroideryElement): Each rung is appended to the correct one of the two new satin columns. """ - rungs = [shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs] + rungs = [shgeo.LineString(rung) for rung in self.rungs] for path_list in split_rails: if path_list is not None: path_list.extend(rung for rung in rungs if path_list[0].intersects(rung) and path_list[1].intersects(rung)) @@ -1059,14 +1058,16 @@ class SatinColumn(EmbroideryElement): path_list.append(rung) def _path_list_to_satins(self, path_list): - linestrings = line_strings_to_csp(path_list) - if not linestrings: + coordinates = line_strings_to_coordinate_lists(path_list) + if not coordinates: return None - return self._csp_to_satin(linestrings) + return self._coordinates_to_satin(coordinates) - def _csp_to_satin(self, csp): + def _coordinates_to_satin(self, paths): node = deepcopy(self.node) - d = paths.CubicSuperPath(csp).to_path() + d = "" + for path in paths: + d += str(Path(path)) node.set("d", d) # we've already applied the transform, so get rid of it @@ -1089,8 +1090,8 @@ class SatinColumn(EmbroideryElement): The returned SatinColumn will not be in the SVG document and will have its transforms applied. """ - rails = [self.flatten_subpath(rail) for rail in self.rails] - other_rails = [satin.flatten_subpath(rail) for rail in satin.rails] + rails = self.rails + other_rails = satin.rails if len(rails) != 2 or len(other_rails) != 2: # weird non-satin things, give up and don't merge @@ -1100,8 +1101,8 @@ class SatinColumn(EmbroideryElement): rails[0].extend(other_rails[0][1:]) rails[1].extend(other_rails[1][1:]) - rungs = [self.flatten_subpath(rung) for rung in self.rungs] - other_rungs = [satin.flatten_subpath(rung) for rung in satin.rungs] + rungs = self.rungs + other_rungs = satin.rungs # add a rung in between the two satins and extend it just a litte to ensure it is crossing the rails new_rung = shgeo.LineString([other_rails[0][0], other_rails[1][0]]) @@ -1112,7 +1113,7 @@ class SatinColumn(EmbroideryElement): rungs = self._get_filtered_rungs(rails, rungs) - return self._csp_to_satin(point_lists_to_csp(rails + rungs)) + return self._coordinates_to_satin(line_strings_to_coordinate_lists(rails + rungs)) def _get_filtered_rungs(self, rails, rungs): # returns a filtered list of rungs which do intersect the rails exactly twice @@ -1832,7 +1833,7 @@ class SatinColumn(EmbroideryElement): def first_stitch(self): if self.start_at_nearest_point: return None - return shgeo.Point(self.flattened_rails[0].coords[0]) + return shgeo.Point(self.line_string_rails[0].coords[0]) def start_point(self, last_stitch_group): start_point = self._get_command_point('starting_point') diff --git a/lib/elements/utils.py b/lib/elements/utils/nodes.py similarity index 81% rename from lib/elements/utils.py rename to lib/elements/utils/nodes.py index 43c2387d6..0ad765ab4 100644 --- a/lib/elements/utils.py +++ b/lib/elements/utils/nodes.py @@ -3,27 +3,28 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from typing import List, Optional, Iterable +from typing import Iterable, List, Optional from inkex import BaseElement from lxml.etree import Comment -from ..commands import is_command, layer_commands -from ..debug.debug import sew_stack_enabled -from ..marker import has_marker -from ..svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS, INKSCAPE_GROUPMODE, - NOT_EMBROIDERABLE_TAGS, SVG_CLIPPATH_TAG, SVG_DEFS_TAG, - SVG_GROUP_TAG, SVG_IMAGE_TAG, SVG_MASK_TAG, - SVG_TEXT_TAG) -from .clone import Clone, is_clone -from .element import EmbroideryElement -from .empty_d_object import EmptyDObject -from .fill_stitch import FillStitch -from .image import ImageObject -from .marker import MarkerObject -from .satin_column import SatinColumn -from .stroke import Stroke -from .text import TextObject +from ...commands import is_command, layer_commands +from ...debug.debug import sew_stack_enabled +from ...marker import has_marker +from ...svg import PIXELS_PER_MM +from ...svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS, + INKSCAPE_GROUPMODE, NOT_EMBROIDERABLE_TAGS, + SVG_CLIPPATH_TAG, SVG_DEFS_TAG, SVG_GROUP_TAG, + SVG_IMAGE_TAG, SVG_MASK_TAG, SVG_TEXT_TAG) +from ..clone import Clone, is_clone +from ..element import EmbroideryElement +from ..empty_d_object import EmptyDObject +from ..fill_stitch import FillStitch +from ..image import ImageObject +from ..marker import MarkerObject +from ..satin_column import SatinColumn +from ..stroke import Stroke +from ..text import TextObject def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: # noqa: C901 @@ -42,7 +43,7 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: elif node.tag in EMBROIDERABLE_TAGS or is_clone(node): elements: List[EmbroideryElement] = [] - from ..sew_stack import SewStack + from ...sew_stack import SewStack sew_stack = SewStack(node) if not sew_stack.sew_stack_only: @@ -50,7 +51,7 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: if element.fill_color is not None and not element.get_style('fill-opacity', 1) == "0": elements.append(FillStitch(node)) if element.stroke_color is not None: - if element.get_boolean_param("satin_column") and len(element.path) > 1: + if element.get_boolean_param("satin_column") and (len(element.path) > 1 or element.stroke_width >= 0.3 / PIXELS_PER_MM): elements.append(SatinColumn(node)) elif not is_command(element.node): elements.append(Stroke(node)) diff --git a/lib/elements/utils/stroke_to_satin.py b/lib/elements/utils/stroke_to_satin.py new file mode 100644 index 000000000..ab14ab188 --- /dev/null +++ b/lib/elements/utils/stroke_to_satin.py @@ -0,0 +1,303 @@ +# Authors: see git history +# +# Copyright (c) 2025 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +from numpy import zeros, convolve, int32, diff, setdiff1d, sign +from math import degrees, acos +from ...svg import PIXELS_PER_MM + +from ...utils import Point +from shapely import geometry as shgeo +from inkex import errormsg +from ...utils.geometry import remove_duplicate_points +from shapely.ops import substring +from shapely.affinity import scale +from ...i18n import _ +import sys + + +class SelfIntersectionError(Exception): + pass + + +def convert_path_to_satin(path, stroke_width, style_args): + path = remove_duplicate_points(fix_loop(path)) + + if len(path) < 2: + # ignore paths with just one point -- they're not visible to the user anyway + return None + + sections = list(convert_path_to_satins(path, stroke_width, style_args)) + + if sections: + joined_satin = list(sections)[0] + for satin in sections[1:]: + joined_satin = merge(joined_satin, satin) + return joined_satin + return None + + +def convert_path_to_satins(path, stroke_width, style_args, depth=0): + try: + rails, rungs = path_to_satin(path, stroke_width, style_args) + yield (rails, rungs) + except SelfIntersectionError: + # The path intersects itself. Split it in two and try doing the halves + # individually. + + if depth >= 20: + # At this point we're slicing the path way too small and still + # getting nowhere. Just give up on this section of the path. + return + + halves = split_path(path) + + for path in halves: + for section in convert_path_to_satins(path, stroke_width, style_args, depth=depth + 1): + yield section + + +def split_path(path): + linestring = shgeo.LineString(path) + halves = [ + list(substring(linestring, 0, 0.5, normalized=True).coords), + list(substring(linestring, 0.5, 1, normalized=True).coords), + ] + + return halves + + +def fix_loop(path): + if path[0] == path[-1] and len(path) > 1: + first = Point.from_tuple(path[0]) + second = Point.from_tuple(path[1]) + midpoint = (first + second) / 2 + midpoint = midpoint.as_tuple() + + return [midpoint] + path[1:] + [path[0], midpoint] + else: + return path + + +def path_to_satin(path, stroke_width, style_args): + if Point(*path[0]).distance(Point(*path[-1])) < 1: + raise SelfIntersectionError() + + path = shgeo.LineString(path) + distance = stroke_width / 2.0 + + try: + left_rail = path.offset_curve(-distance, **style_args) + right_rail = path.offset_curve(distance, **style_args) + except ValueError: + # TODO: fix this error automatically + # Error reference: https://github.com/inkstitch/inkstitch/issues/964 + errormsg(_("Ink/Stitch cannot convert your stroke into a satin column. " + "Please break up your path and try again.") + '\n') + sys.exit(1) + + if left_rail.geom_type != 'LineString' or right_rail.geom_type != 'LineString': + # If the offset curve come out as anything but a LineString, that means the + # path intersects itself, when taking its stroke width into consideration. + raise SelfIntersectionError() + + rungs = generate_rungs(path, stroke_width, left_rail, right_rail) + + left_rail = list(left_rail.coords) + right_rail = list(right_rail.coords) + + return (left_rail, right_rail), rungs + + +def get_scores(path): + """Generate an array of "scores" of the sharpness of corners in a path + + A higher score means that there are sharper corners in that section of + the path. We'll divide the path into boxes, with the score in each + box indicating the sharpness of corners at around that percentage of + the way through the path. For example, if scores[40] is 100 and + scores[45] is 200, then the path has sharper corners at a spot 45% + along its length than at a spot 40% along its length. + """ + + # need 101 boxes in order to encompass percentages from 0% to 100% + scores = zeros(101, int32) + path_length = path.length + + prev_point = None + prev_direction = None + length_so_far = 0 + for point in path.coords: + point = Point(*point) + + if prev_point is None: + prev_point = point + continue + + direction = (point - prev_point).unit() + + if prev_direction is not None: + # The dot product of two vectors is |v1| * |v2| * cos(angle). + # These are unit vectors, so their magnitudes are 1. + cos_angle_between = prev_direction * direction + + # Clamp to the valid range for a cosine. The above _should_ + # already be in this range, but floating point inaccuracy can + # push it outside the range causing math.acos to throw + # ValueError ("math domain error"). + cos_angle_between = max(-1.0, min(1.0, cos_angle_between)) + + angle = abs(degrees(acos(cos_angle_between))) + + # Use the square of the angle, measured in degrees. + # + # Why the square? This penalizes bigger angles more than + # smaller ones. + # + # Why degrees? This is kind of arbitrary but allows us to + # use integer math effectively and avoid taking the square + # of a fraction between 0 and 1. + scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2 + + length_so_far += (point - prev_point).length() + prev_direction = direction + prev_point = point + + return scores + + +def local_minima(array): + # from: https://stackoverflow.com/a/9667121/4249120 + # This finds spots where the curvature (second derivative) is > 0. + # + # This method has the convenient benefit of choosing points around + # 5% before and after a sharp corner such as in a square. + return (diff(sign(diff(array))) > 0).nonzero()[0] + 1 + + +def generate_rungs(path, stroke_width, left_rail, right_rail): + """Create rungs for a satin column. + + Where should we put the rungs along a path? We want to ensure that the + resulting satin matches the original path as closely as possible. We + want to avoid having a ton of rungs that will annoy the user. We want + to ensure that the rungs we choose actually intersect both rails. + + We'll place a few rungs perpendicular to the tangent of the path. + Things get pretty tricky at sharp corners. If we naively place a rung + perpendicular to the path just on either side of a sharp corner, the + rung may not intersect both paths: + | | + _______________| | + ______|_ + ____________________| + + It'd be best to place rungs in the straight sections before and after + the sharp corner and allow the satin column to bend the stitches around + the corner automatically. + + How can we find those spots? + + The general algorithm below is: + + * assign a "score" to each section of the path based on how sharp its + corners are (higher means a sharper corner) + * pick spots with lower scores + """ + + scores = get_scores(path) + + # This is kind of like a 1-dimensional gaussian blur filter. We want to + # avoid the area near a sharp corner, so we spread out its effect for + # 5 buckets in either direction. + scores = convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same') + + # Now we'll find the spots that aren't near corners, whose scores are + # low -- the local minima. + rung_locations = local_minima(scores) + + # Remove the start and end, because we can't stick a rung there. + rung_locations = setdiff1d(rung_locations, [0, 100]) + + if len(rung_locations) == 0: + # Straight lines won't have local minima, so add a rung in the center. + rung_locations = [50] + + rungs = [] + last_rung_center = None + + for location in rung_locations: + # Convert percentage to a fraction so that we can use interpolate's + # normalized parameter. + location = location / 100.0 + + rung_center = path.interpolate(location, normalized=True) + rung_center = Point(rung_center.x, rung_center.y) + + # Avoid placing rungs too close together. This somewhat + # arbitrarily rejects the rung if there was one less than 2 + # millimeters before this one. + if last_rung_center is not None and \ + (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM: + continue + else: + last_rung_center = rung_center + + # We need to know the tangent of the path's curve at this point. + # Pick another point just after this one and subtract them to + # approximate a tangent vector. + tangent_end = path.interpolate(location + 0.001, normalized=True) + tangent_end = Point(tangent_end.x, tangent_end.y) + tangent = (tangent_end - rung_center).unit() + + # Rotate 90 degrees left to make a normal vector. + normal = tangent.rotate_left() + + # Extend the rungs by an offset value to make sure they will cross the rails + offset = normal * (stroke_width / 2) * 1.2 + rung_start = rung_center + offset + rung_end = rung_center - offset + + rung_tuple = (rung_start.as_tuple(), rung_end.as_tuple()) + rung_linestring = shgeo.LineString(rung_tuple) + if (isinstance(rung_linestring.intersection(left_rail), shgeo.Point) and + isinstance(rung_linestring.intersection(right_rail), shgeo.Point)): + rungs.append(rung_tuple) + + return rungs + + +def merge(section, other_section): + """Merge this satin with another satin + + This method expects that the provided satin continues on directly after + this one, as would be the case, for example, if the two satins were the + result of the split() method. + + Returns a new SatinColumn instance that combines the rails and rungs of + this satin and the provided satin. A rung is added at the end of this + satin. + + The returned SatinColumn will not be in the SVG document and will have + its transforms applied. + """ + rails, rungs = section + other_rails, other_rungs = other_section + + if len(rails) != 2 or len(other_rails) != 2: + # weird non-satin things, give up and don't merge + return section + + # remove first node of each other rail before merging (avoid duplicated nodes) + rails[0].extend(other_rails[0][1:]) + rails[1].extend(other_rails[1][1:]) + + # add a rung in between the two satins and extend it just a litte to ensure it is crossing the rails + new_rung = shgeo.LineString([other_rails[0][0], other_rails[1][0]]) + rungs.append(list(scale(new_rung, 1.2, 1.2).coords)) + + # add on the other satin's rungs + rungs.extend(other_rungs) + + return (rails, rungs) diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 91afbc389..7ec987350 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -7,7 +7,7 @@ import os import inkex -from ..elements.utils import iterate_nodes, nodes_to_elements +from ..elements import iterate_nodes, nodes_to_elements from ..i18n import _ from ..metadata import InkStitchMetadata from ..svg import generate_unique_id diff --git a/lib/extensions/lettering_force_lock_stitches.py b/lib/extensions/lettering_force_lock_stitches.py index 16f812273..a04fac79b 100644 --- a/lib/extensions/lettering_force_lock_stitches.py +++ b/lib/extensions/lettering_force_lock_stitches.py @@ -6,7 +6,7 @@ import inkex from shapely.geometry import Point -from ..elements.utils import iterate_nodes, nodes_to_elements +from ..elements import iterate_nodes, nodes_to_elements from ..i18n import _ from ..marker import has_marker from ..svg import PIXELS_PER_MM diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 0bba13fec..fd4af28f6 100755 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -24,6 +24,7 @@ from ..gui import PresetsPanel, PreviewRenderer, WarningPanel from ..gui.simulator import SplitSimulatorWindow from ..i18n import _ from ..stitch_plan import stitch_groups_to_stitch_plan +from ..svg import PIXELS_PER_MM from ..svg.tags import EMBROIDERABLE_TAGS from ..utils import get_resource_dir from ..utils.param import ParamOption @@ -724,9 +725,9 @@ class Params(InkstitchExtension): if element.fill_color is not None and not element.get_style("fill-opacity", 1) == "0": classes.append(FillStitch) if element.stroke_color is not None: - classes.append(Stroke) - if len(element.path) > 1: + if len(element.path) > 1 or element.stroke_width >= 0.3 * PIXELS_PER_MM: classes.append(SatinColumn) + classes.append(Stroke) return classes def get_nodes_by_class(self): diff --git a/lib/extensions/stroke_to_satin.py b/lib/extensions/stroke_to_satin.py index d9bf2ff0d..fa3605486 100644 --- a/lib/extensions/stroke_to_satin.py +++ b/lib/extensions/stroke_to_satin.py @@ -3,28 +3,19 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -import math -import sys -from itertools import chain, groupby +from itertools import chain import inkex -import numpy -from numpy import diff, setdiff1d, sign from shapely import geometry as shgeo -from shapely.ops import substring from ..elements import SatinColumn, Stroke +from ..elements.utils.stroke_to_satin import convert_path_to_satin from ..i18n import _ -from ..svg import PIXELS_PER_MM, get_correction_transform -from ..svg.tags import INKSTITCH_ATTRIBS -from ..utils import Point +from ..svg import get_correction_transform +from ..svg.styles import get_join_style_args from .base import InkstitchExtension -class SelfIntersectionError(Exception): - pass - - class StrokeToSatin(InkstitchExtension): """Convert a line to a satin column of the same width.""" @@ -36,296 +27,51 @@ class StrokeToSatin(InkstitchExtension): inkex.errormsg(_("Please select at least one line to convert to a satin column.")) return - if not any(isinstance(item, Stroke) for item in self.elements): - # L10N: Convert To Satin extension, user selected one or more objects that were not lines. - inkex.errormsg(_("Only simple lines may be converted to satin columns.")) - return - + satin_converted = False for element in self.elements: - if not isinstance(element, Stroke): + if not isinstance(element, Stroke) and not (isinstance(element, SatinColumn) and len(element.paths) == 1): continue parent = element.node.getparent() index = parent.index(element.node) correction_transform = get_correction_transform(element.node) - style_args = self.join_style_args(element) + style_args = get_join_style_args(element) path_style = self.path_style(element) for path in element.paths: - path = self.remove_duplicate_points(self.fix_loop(path)) + satin_paths = convert_path_to_satin(path, element.stroke_width, style_args) - if len(path) < 2: - # ignore paths with just one point -- they're not visible to the user anyway - continue + if satin_paths is not None: + rails, rungs = list(satin_paths) + rungs = self.filtered_rungs(rails, rungs) - satins = list(self.convert_path_to_satins(path, element.stroke_width, style_args, path_style)) - - if satins: - joined_satin = satins[0] - for satin in satins[1:]: - joined_satin = joined_satin.merge(satin) - - joined_satin.node.set('transform', correction_transform) - parent.insert(index, joined_satin.node) + path_element = self.satin_to_svg_node(rails, rungs) + path_element.set('id', self.uniqueId("path")) + path_element.set('transform', correction_transform) + path_element.set('style', path_style) + parent.insert(index, path_element) element.node.delete() + satin_converted = True - def convert_path_to_satins(self, path, stroke_width, style_args, path_style, depth=0): - try: - rails, rungs = self.path_to_satin(path, stroke_width, style_args) - yield SatinColumn(self.satin_to_svg_node(rails, rungs, path_style)) - except SelfIntersectionError: - # The path intersects itself. Split it in two and try doing the halves - # individually. + if not satin_converted: + # L10N: Convert To Satin extension, user selected only objects that were not lines. + inkex.errormsg(_("Only simple lines may be converted to satin columns.")) - if depth >= 20: - # At this point we're slicing the path way too small and still - # getting nowhere. Just give up on this section of the path. - return - - halves = self.split_path(path) - - for path in halves: - for satin in self.convert_path_to_satins(path, stroke_width, style_args, path_style, depth=depth + 1): - yield satin - - def split_path(self, path): - linestring = shgeo.LineString(path) - halves = [ - list(substring(linestring, 0, 0.5, normalized=True).coords), - list(substring(linestring, 0.5, 1, normalized=True).coords), - ] - - return halves - - def fix_loop(self, path): - if path[0] == path[-1] and len(path) > 1: - first = Point.from_tuple(path[0]) - second = Point.from_tuple(path[1]) - midpoint = (first + second) / 2 - midpoint = midpoint.as_tuple() - - return [midpoint] + path[1:] + [path[0], midpoint] - else: - return path - - def remove_duplicate_points(self, path): - path = [[round(coord, 4) for coord in point] for point in path] - return [point for point, repeats in groupby(path)] - - def join_style_args(self, element): - """Convert svg line join style to shapely offset_curve arguments.""" - - args = { - # mitre is the default per SVG spec - 'join_style': shgeo.JOIN_STYLE.mitre - } - - element_join_style = element.get_style('stroke-linejoin') - - if element_join_style is not None: - if element_join_style == "miter": - args['join_style'] = shgeo.JOIN_STYLE.mitre - - # 4 is the default per SVG spec - miter_limit = float(element.get_style('stroke-miterlimit', 4)) - args['mitre_limit'] = miter_limit - elif element_join_style == "bevel": - args['join_style'] = shgeo.JOIN_STYLE.bevel - elif element_join_style == "round": - args['join_style'] = shgeo.JOIN_STYLE.round - - return args - - def path_to_satin(self, path, stroke_width, style_args): - if Point(*path[0]).distance(Point(*path[-1])) < 1: - raise SelfIntersectionError() - - path = shgeo.LineString(path) - distance = stroke_width / 2.0 - - try: - left_rail = path.offset_curve(-distance, **style_args) - right_rail = path.offset_curve(distance, **style_args) - except ValueError: - # TODO: fix this error automatically - # Error reference: https://github.com/inkstitch/inkstitch/issues/964 - inkex.errormsg(_("Ink/Stitch cannot convert your stroke into a satin column. " - "Please break up your path and try again.") + '\n') - sys.exit(1) - - if left_rail.geom_type != 'LineString' or right_rail.geom_type != 'LineString': - # If the offset curve come out as anything but a LineString, that means the - # path intersects itself, when taking its stroke width into consideration. - raise SelfIntersectionError() - - rungs = self.generate_rungs(path, stroke_width, left_rail, right_rail) - - left_rail = list(left_rail.coords) - right_rail = list(right_rail.coords) - - return (left_rail, right_rail), rungs - - def get_scores(self, path): - """Generate an array of "scores" of the sharpness of corners in a path - - A higher score means that there are sharper corners in that section of - the path. We'll divide the path into boxes, with the score in each - box indicating the sharpness of corners at around that percentage of - the way through the path. For example, if scores[40] is 100 and - scores[45] is 200, then the path has sharper corners at a spot 45% - along its length than at a spot 40% along its length. - """ - - # need 101 boxes in order to encompass percentages from 0% to 100% - scores = numpy.zeros(101, numpy.int32) - path_length = path.length - - prev_point = None - prev_direction = None - length_so_far = 0 - for point in path.coords: - point = Point(*point) - - if prev_point is None: - prev_point = point - continue - - direction = (point - prev_point).unit() - - if prev_direction is not None: - # The dot product of two vectors is |v1| * |v2| * cos(angle). - # These are unit vectors, so their magnitudes are 1. - cos_angle_between = prev_direction * direction - - # Clamp to the valid range for a cosine. The above _should_ - # already be in this range, but floating point inaccuracy can - # push it outside the range causing math.acos to throw - # ValueError ("math domain error"). - cos_angle_between = max(-1.0, min(1.0, cos_angle_between)) - - angle = abs(math.degrees(math.acos(cos_angle_between))) - - # Use the square of the angle, measured in degrees. - # - # Why the square? This penalizes bigger angles more than - # smaller ones. - # - # Why degrees? This is kind of arbitrary but allows us to - # use integer math effectively and avoid taking the square - # of a fraction between 0 and 1. - scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2 - - length_so_far += (point - prev_point).length() - prev_direction = direction - prev_point = point - - return scores - - def local_minima(self, array): - # from: https://stackoverflow.com/a/9667121/4249120 - # This finds spots where the curvature (second derivative) is > 0. - # - # This method has the convenient benefit of choosing points around - # 5% before and after a sharp corner such as in a square. - return (diff(sign(diff(array))) > 0).nonzero()[0] + 1 - - def generate_rungs(self, path, stroke_width, left_rail, right_rail): - """Create rungs for a satin column. - - Where should we put the rungs along a path? We want to ensure that the - resulting satin matches the original path as closely as possible. We - want to avoid having a ton of rungs that will annoy the user. We want - to ensure that the rungs we choose actually intersect both rails. - - We'll place a few rungs perpendicular to the tangent of the path. - Things get pretty tricky at sharp corners. If we naively place a rung - perpendicular to the path just on either side of a sharp corner, the - rung may not intersect both paths: - | | - _______________| | - ______|_ - ____________________| - - It'd be best to place rungs in the straight sections before and after - the sharp corner and allow the satin column to bend the stitches around - the corner automatically. - - How can we find those spots? - - The general algorithm below is: - - * assign a "score" to each section of the path based on how sharp its - corners are (higher means a sharper corner) - * pick spots with lower scores - """ - - scores = self.get_scores(path) - - # This is kind of like a 1-dimensional gaussian blur filter. We want to - # avoid the area near a sharp corner, so we spread out its effect for - # 5 buckets in either direction. - scores = numpy.convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same') - - # Now we'll find the spots that aren't near corners, whose scores are - # low -- the local minima. - rung_locations = self.local_minima(scores) - - # Remove the start and end, because we can't stick a rung there. - rung_locations = setdiff1d(rung_locations, [0, 100]) - - if len(rung_locations) == 0: - # Straight lines won't have local minima, so add a rung in the center. - rung_locations = [50] - - rungs = [] - last_rung_center = None - - for location in rung_locations: - # Convert percentage to a fraction so that we can use interpolate's - # normalized parameter. - location = location / 100.0 - - rung_center = path.interpolate(location, normalized=True) - rung_center = Point(rung_center.x, rung_center.y) - - # Avoid placing rungs too close together. This somewhat - # arbitrarily rejects the rung if there was one less than 2 - # millimeters before this one. - if last_rung_center is not None and \ - (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM: - continue - else: - last_rung_center = rung_center - - # We need to know the tangent of the path's curve at this point. - # Pick another point just after this one and subtract them to - # approximate a tangent vector. - tangent_end = path.interpolate(location + 0.001, normalized=True) - tangent_end = Point(tangent_end.x, tangent_end.y) - tangent = (tangent_end - rung_center).unit() - - # Rotate 90 degrees left to make a normal vector. - normal = tangent.rotate_left() - - # Extend the rungs by an offset value to make sure they will cross the rails - offset = normal * (stroke_width / 2) * 1.2 - rung_start = rung_center + offset - rung_end = rung_center - offset - - rung_tuple = (rung_start.as_tuple(), rung_end.as_tuple()) - rung_linestring = shgeo.LineString(rung_tuple) - if (isinstance(rung_linestring.intersection(left_rail), shgeo.Point) and - isinstance(rung_linestring.intersection(right_rail), shgeo.Point)): - rungs.append(rung_tuple) - - return rungs + def filtered_rungs(self, rails, rungs): + rails = shgeo.MultiLineString(rails) + filtered_rungs = [] + for rung in shgeo.MultiLineString(rungs).geoms: + intersection = rung.intersection(rails) + if intersection.geom_type == "MultiPoint" and len(intersection.geoms) == 2: + filtered_rungs.append(list(rung.coords)) + return filtered_rungs def path_style(self, element): color = element.get_style('stroke', '#000000') return "stroke:%s;stroke-width:1px;fill:none" % (color) - def satin_to_svg_node(self, rails, rungs, path_style): + def satin_to_svg_node(self, rails, rungs): d = "" for path in chain(rails, rungs): d += "M" @@ -333,9 +79,8 @@ class StrokeToSatin(InkstitchExtension): d += "%s,%s " % (x, y) d += " " - return inkex.PathElement(attrib={ - "id": self.uniqueId("path"), - "style": path_style, + path_element = inkex.PathElement(attrib={ "d": d, - INKSTITCH_ATTRIBS['satin_column']: "true", }) + path_element.set("inkstitch:satin_column", True) + return path_element diff --git a/lib/gui/lettering/main_panel.py b/lib/gui/lettering/main_panel.py index fc6d16adf..1766516ec 100644 --- a/lib/gui/lettering/main_panel.py +++ b/lib/gui/lettering/main_panel.py @@ -10,7 +10,7 @@ import inkex import wx import wx.adv -from ...elements.utils import iterate_nodes, nodes_to_elements +from ...elements import iterate_nodes, nodes_to_elements from ...i18n import _ from ...lettering import FontError, get_font_list from ...lettering.categories import FONT_CATEGORIES diff --git a/lib/gui/satin_multicolor/main_panel.py b/lib/gui/satin_multicolor/main_panel.py index 8c4bbaf96..6ecb30c44 100644 --- a/lib/gui/satin_multicolor/main_panel.py +++ b/lib/gui/satin_multicolor/main_panel.py @@ -9,7 +9,7 @@ import inkex import wx import wx.adv -from ...elements.utils import nodes_to_elements +from ...elements import nodes_to_elements from ...exceptions import InkstitchException, format_uncaught_exception from ...i18n import _ from ...stitch_plan import stitch_groups_to_stitch_plan diff --git a/lib/marker.py b/lib/marker.py index cd7006749..bf74c02e0 100644 --- a/lib/marker.py +++ b/lib/marker.py @@ -97,7 +97,7 @@ def get_marker_elements_cache_key_data(node, marker): marker_elements['fill'] = [shape.wkt for shape in marker_elements['fill']] marker_elements['stroke'] = [shape.wkt for shape in marker_elements['stroke']] - marker_elements['satin'] = [satin.csp for satin in marker_elements['satin']] + marker_elements['satin'] = [satin.filtered_subpaths for satin in marker_elements['satin']] return marker_elements diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py index b17a37f0c..6694d5346 100644 --- a/lib/svg/__init__.py +++ b/lib/svg/__init__.py @@ -4,8 +4,10 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from .guides import get_guides -from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp, point_lists_to_csp, line_strings_to_path -from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp, point_lists_to_csp +from .path import (apply_transforms, get_correction_transform, + get_node_transform, line_strings_to_coordinate_lists, + line_strings_to_csp, line_strings_to_path, + point_lists_to_csp) from .rendering import color_block_to_point_lists, render_stitch_plan -from .svg import get_document, generate_unique_id -from .units import * \ No newline at end of file +from .svg import generate_unique_id, get_document +from .units import * diff --git a/lib/svg/path.py b/lib/svg/path.py index 5e6e7007f..a7c7b1ca6 100644 --- a/lib/svg/path.py +++ b/lib/svg/path.py @@ -85,6 +85,19 @@ def get_correction_transform(node: inkex.BaseElement, child=False) -> str: return str(transform) +def line_strings_to_coordinate_lists(line_strings): + try: + # This lets us accept a MultiLineString or a list. + line_strings = line_strings.geoms + except AttributeError: + pass + + if line_strings is None: + return None + + return [list(ls.coords) for ls in line_strings] + + def line_strings_to_csp(line_strings): try: # This lets us accept a MultiLineString or a list. diff --git a/lib/svg/styles.py b/lib/svg/styles.py new file mode 100644 index 000000000..c3daaf194 --- /dev/null +++ b/lib/svg/styles.py @@ -0,0 +1,31 @@ +# Authors: see git history +# +# Copyright (c) 2025 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +from shapely.geometry import JOIN_STYLE + + +def get_join_style_args(element): + """Convert svg line join style to shapely offset_curve arguments.""" + + args = { + # mitre is the default per SVG spec + 'join_style': JOIN_STYLE.mitre + } + + element_join_style = element.get_style('stroke-linejoin') + + if element_join_style is not None: + if element_join_style == "miter": + args['join_style'] = JOIN_STYLE.mitre + + # 4 is the default per SVG spec + miter_limit = float(element.get_style('stroke-miterlimit', 4)) + args['mitre_limit'] = miter_limit + elif element_join_style == "bevel": + args['join_style'] = JOIN_STYLE.bevel + elif element_join_style == "round": + args['join_style'] = JOIN_STYLE.round + + return args diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 39bbd963b..39a1f3fb3 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -5,6 +5,7 @@ import math import typing +from itertools import groupby import numpy from shapely.geometry import (GeometryCollection, LinearRing, LineString, @@ -242,6 +243,11 @@ def offset_points(pos1, pos2, offset_px, offset_proportional): return out1, out2 +def remove_duplicate_points(path): + path = [[round(coord, 4) for coord in point] for point in path] + return [point for point, repeats in groupby(path)] + + class Point: def __init__(self, x: typing.Union[float, numpy.float64], y: typing.Union[float, numpy.float64]): self.x = float(x) diff --git a/tests/test_elements_utils.py b/tests/test_elements_utils.py index 2c0a64eb5..0acc20a87 100644 --- a/tests/test_elements_utils.py +++ b/tests/test_elements_utils.py @@ -2,7 +2,7 @@ from inkex import Group, Rectangle, Style from inkex.tester import TestCase from inkex.tester.svg import svg -from lib.elements import FillStitch, utils +from lib.elements import FillStitch, nodes_to_elements, iterate_nodes from .utils import element_count @@ -30,7 +30,7 @@ class ElementsUtilsTest(TestCase): "height": "10", })) - elements = utils.nodes_to_elements(utils.iterate_nodes(g)) + elements = nodes_to_elements(iterate_nodes(g)) self.assertEqual(len(elements), element_count()) self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node, rect) @@ -43,7 +43,7 @@ class ElementsUtilsTest(TestCase): "height": "10" })) - elements = utils.nodes_to_elements(utils.iterate_nodes(rect)) + elements = nodes_to_elements(iterate_nodes(rect)) self.assertEqual(len(elements), element_count()) self.assertEqual(type(elements[0]), FillStitch) self.assertEqual(elements[0].node, rect) @@ -51,5 +51,5 @@ class ElementsUtilsTest(TestCase): # Now make the element hidden: It shouldn't return an element rect.style = rect.style + Style({"display": "none"}) - elements = utils.nodes_to_elements(utils.iterate_nodes(rect)) + elements = nodes_to_elements(iterate_nodes(rect)) self.assertEqual(len(elements), 0) diff --git a/tests/test_output.py b/tests/test_output.py index afccca7e1..9ed63602a 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,7 +3,7 @@ from inkex.tester import TestCase from inkex.tester.svg import svg from lib import output -from lib.elements.utils import node_to_elements +from lib.elements import node_to_elements from lib.stitch_plan.stitch_plan import stitch_groups_to_stitch_plan from lib.svg.tags import INKSTITCH_ATTRIBS diff --git a/tests/test_threads_color.py b/tests/test_threads_color.py index 075312e69..3e84e8c41 100644 --- a/tests/test_threads_color.py +++ b/tests/test_threads_color.py @@ -2,7 +2,7 @@ from inkex import LinearGradient, Rectangle, Stop, SvgDocumentElement from inkex.tester.svg import svg from lib.elements import EmbroideryElement -from lib.elements.utils import node_to_elements +from lib.elements import node_to_elements from lib.threads.color import ThreadColor