Convert to satin internally (3874)

pull/3882/head
Kaalleen 2025-07-19 22:30:15 +02:00 zatwierdzone przez GitHub
rodzic fdd3dbc956
commit ffc0db1ddf
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
19 zmienionych plików z 507 dodań i 404 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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:

Wyświetl plik

@ -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')

Wyświetl plik

@ -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))

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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 *

Wyświetl plik

@ -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.

31
lib/svg/styles.py 100644
Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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