kopia lustrzana https://github.com/inkstitch/inkstitch
Convert to satin internally (3874)
rodzic
fdd3dbc956
commit
ffc0db1ddf
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
from .svg import generate_unique_id, get_document
|
||||
from .units import *
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Ładowanie…
Reference in New Issue