diff options
Diffstat (limited to 'lib/elements')
| -rw-r--r-- | lib/elements/__init__.py | 2 | ||||
| -rw-r--r-- | lib/elements/clone.py | 4 | ||||
| -rw-r--r-- | lib/elements/satin_column.py | 157 | ||||
| -rw-r--r-- | lib/elements/utils/nodes.py (renamed from lib/elements/utils.py) | 39 | ||||
| -rw-r--r-- | lib/elements/utils/stroke_to_satin.py | 303 |
5 files changed, 405 insertions, 100 deletions
diff --git a/lib/elements/__init__.py b/lib/elements/__init__.py index bcb341e1..e1c35482 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 5d0ae7fa..bc668bf6 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 51a10cad..42b650ed 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 index 43c2387d..0ad765ab 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 00000000..ab14ab18 --- /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) |
