Skip to content

Commit ffc0db1

Browse files
authored
Convert to satin internally (3874)
1 parent fdd3dbc commit ffc0db1

File tree

19 files changed

+507
-404
lines changed

19 files changed

+507
-404
lines changed

lib/elements/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
from .satin_column import SatinColumn
1212
from .stroke import Stroke
1313
from .text import TextObject
14-
from .utils import node_to_elements, nodes_to_elements
14+
from .utils.nodes import iterate_nodes, node_to_elements, nodes_to_elements

lib/elements/clone.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def get_cache_key_data(self, previous_stitch: Any, next_element: EmbroideryEleme
7676

7777
def clone_to_elements(self, node: BaseElement) -> List[EmbroideryElement]:
7878
# Only used in get_cache_key_data, actual embroidery uses nodes_to_elements+iterate_nodes
79-
from .utils import node_to_elements
79+
from .utils.nodes import node_to_elements
8080
elements = []
8181
if node.tag in EMBROIDERABLE_TAGS:
8282
elements = node_to_elements(node, True)
@@ -141,7 +141,7 @@ def clone_elements(self) -> Generator[List[EmbroideryElement], None, None]:
141141
Could possibly be refactored into just a generator - being a context manager is mainly to control the lifecycle of the elements
142142
that are cloned (again, for testing convenience primarily)
143143
"""
144-
from .utils import iterate_nodes, nodes_to_elements
144+
from .utils.nodes import iterate_nodes, nodes_to_elements
145145

146146
cloned_nodes = self.resolve_clone()
147147
try:

lib/elements/satin_column.py

Lines changed: 79 additions & 78 deletions
Large diffs are not rendered by default.
Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,28 @@
33
# Copyright (c) 2010 Authors
44
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
55

6-
from typing import List, Optional, Iterable
6+
from typing import Iterable, List, Optional
77

88
from inkex import BaseElement
99
from lxml.etree import Comment
1010

11-
from ..commands import is_command, layer_commands
12-
from ..debug.debug import sew_stack_enabled
13-
from ..marker import has_marker
14-
from ..svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS, INKSCAPE_GROUPMODE,
15-
NOT_EMBROIDERABLE_TAGS, SVG_CLIPPATH_TAG, SVG_DEFS_TAG,
16-
SVG_GROUP_TAG, SVG_IMAGE_TAG, SVG_MASK_TAG,
17-
SVG_TEXT_TAG)
18-
from .clone import Clone, is_clone
19-
from .element import EmbroideryElement
20-
from .empty_d_object import EmptyDObject
21-
from .fill_stitch import FillStitch
22-
from .image import ImageObject
23-
from .marker import MarkerObject
24-
from .satin_column import SatinColumn
25-
from .stroke import Stroke
26-
from .text import TextObject
11+
from ...commands import is_command, layer_commands
12+
from ...debug.debug import sew_stack_enabled
13+
from ...marker import has_marker
14+
from ...svg import PIXELS_PER_MM
15+
from ...svg.tags import (CONNECTOR_TYPE, EMBROIDERABLE_TAGS,
16+
INKSCAPE_GROUPMODE, NOT_EMBROIDERABLE_TAGS,
17+
SVG_CLIPPATH_TAG, SVG_DEFS_TAG, SVG_GROUP_TAG,
18+
SVG_IMAGE_TAG, SVG_MASK_TAG, SVG_TEXT_TAG)
19+
from ..clone import Clone, is_clone
20+
from ..element import EmbroideryElement
21+
from ..empty_d_object import EmptyDObject
22+
from ..fill_stitch import FillStitch
23+
from ..image import ImageObject
24+
from ..marker import MarkerObject
25+
from ..satin_column import SatinColumn
26+
from ..stroke import Stroke
27+
from ..text import TextObject
2728

2829

2930
def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]: # noqa: C901
@@ -42,15 +43,15 @@ def node_to_elements(node, clone_to_element=False) -> List[EmbroideryElement]:
4243
elif node.tag in EMBROIDERABLE_TAGS or is_clone(node):
4344
elements: List[EmbroideryElement] = []
4445

45-
from ..sew_stack import SewStack
46+
from ...sew_stack import SewStack
4647
sew_stack = SewStack(node)
4748

4849
if not sew_stack.sew_stack_only:
4950
element = EmbroideryElement(node)
5051
if element.fill_color is not None and not element.get_style('fill-opacity', 1) == "0":
5152
elements.append(FillStitch(node))
5253
if element.stroke_color is not None:
53-
if element.get_boolean_param("satin_column") and len(element.path) > 1:
54+
if element.get_boolean_param("satin_column") and (len(element.path) > 1 or element.stroke_width >= 0.3 / PIXELS_PER_MM):
5455
elements.append(SatinColumn(node))
5556
elif not is_command(element.node):
5657
elements.append(Stroke(node))
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
# Authors: see git history
2+
#
3+
# Copyright (c) 2025 Authors
4+
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
5+
6+
from numpy import zeros, convolve, int32, diff, setdiff1d, sign
7+
from math import degrees, acos
8+
from ...svg import PIXELS_PER_MM
9+
10+
from ...utils import Point
11+
from shapely import geometry as shgeo
12+
from inkex import errormsg
13+
from ...utils.geometry import remove_duplicate_points
14+
from shapely.ops import substring
15+
from shapely.affinity import scale
16+
from ...i18n import _
17+
import sys
18+
19+
20+
class SelfIntersectionError(Exception):
21+
pass
22+
23+
24+
def convert_path_to_satin(path, stroke_width, style_args):
25+
path = remove_duplicate_points(fix_loop(path))
26+
27+
if len(path) < 2:
28+
# ignore paths with just one point -- they're not visible to the user anyway
29+
return None
30+
31+
sections = list(convert_path_to_satins(path, stroke_width, style_args))
32+
33+
if sections:
34+
joined_satin = list(sections)[0]
35+
for satin in sections[1:]:
36+
joined_satin = merge(joined_satin, satin)
37+
return joined_satin
38+
return None
39+
40+
41+
def convert_path_to_satins(path, stroke_width, style_args, depth=0):
42+
try:
43+
rails, rungs = path_to_satin(path, stroke_width, style_args)
44+
yield (rails, rungs)
45+
except SelfIntersectionError:
46+
# The path intersects itself. Split it in two and try doing the halves
47+
# individually.
48+
49+
if depth >= 20:
50+
# At this point we're slicing the path way too small and still
51+
# getting nowhere. Just give up on this section of the path.
52+
return
53+
54+
halves = split_path(path)
55+
56+
for path in halves:
57+
for section in convert_path_to_satins(path, stroke_width, style_args, depth=depth + 1):
58+
yield section
59+
60+
61+
def split_path(path):
62+
linestring = shgeo.LineString(path)
63+
halves = [
64+
list(substring(linestring, 0, 0.5, normalized=True).coords),
65+
list(substring(linestring, 0.5, 1, normalized=True).coords),
66+
]
67+
68+
return halves
69+
70+
71+
def fix_loop(path):
72+
if path[0] == path[-1] and len(path) > 1:
73+
first = Point.from_tuple(path[0])
74+
second = Point.from_tuple(path[1])
75+
midpoint = (first + second) / 2
76+
midpoint = midpoint.as_tuple()
77+
78+
return [midpoint] + path[1:] + [path[0], midpoint]
79+
else:
80+
return path
81+
82+
83+
def path_to_satin(path, stroke_width, style_args):
84+
if Point(*path[0]).distance(Point(*path[-1])) < 1:
85+
raise SelfIntersectionError()
86+
87+
path = shgeo.LineString(path)
88+
distance = stroke_width / 2.0
89+
90+
try:
91+
left_rail = path.offset_curve(-distance, **style_args)
92+
right_rail = path.offset_curve(distance, **style_args)
93+
except ValueError:
94+
# TODO: fix this error automatically
95+
# Error reference: https://github.com/inkstitch/inkstitch/issues/964
96+
errormsg(_("Ink/Stitch cannot convert your stroke into a satin column. "
97+
"Please break up your path and try again.") + '\n')
98+
sys.exit(1)
99+
100+
if left_rail.geom_type != 'LineString' or right_rail.geom_type != 'LineString':
101+
# If the offset curve come out as anything but a LineString, that means the
102+
# path intersects itself, when taking its stroke width into consideration.
103+
raise SelfIntersectionError()
104+
105+
rungs = generate_rungs(path, stroke_width, left_rail, right_rail)
106+
107+
left_rail = list(left_rail.coords)
108+
right_rail = list(right_rail.coords)
109+
110+
return (left_rail, right_rail), rungs
111+
112+
113+
def get_scores(path):
114+
"""Generate an array of "scores" of the sharpness of corners in a path
115+
116+
A higher score means that there are sharper corners in that section of
117+
the path. We'll divide the path into boxes, with the score in each
118+
box indicating the sharpness of corners at around that percentage of
119+
the way through the path. For example, if scores[40] is 100 and
120+
scores[45] is 200, then the path has sharper corners at a spot 45%
121+
along its length than at a spot 40% along its length.
122+
"""
123+
124+
# need 101 boxes in order to encompass percentages from 0% to 100%
125+
scores = zeros(101, int32)
126+
path_length = path.length
127+
128+
prev_point = None
129+
prev_direction = None
130+
length_so_far = 0
131+
for point in path.coords:
132+
point = Point(*point)
133+
134+
if prev_point is None:
135+
prev_point = point
136+
continue
137+
138+
direction = (point - prev_point).unit()
139+
140+
if prev_direction is not None:
141+
# The dot product of two vectors is |v1| * |v2| * cos(angle).
142+
# These are unit vectors, so their magnitudes are 1.
143+
cos_angle_between = prev_direction * direction
144+
145+
# Clamp to the valid range for a cosine. The above _should_
146+
# already be in this range, but floating point inaccuracy can
147+
# push it outside the range causing math.acos to throw
148+
# ValueError ("math domain error").
149+
cos_angle_between = max(-1.0, min(1.0, cos_angle_between))
150+
151+
angle = abs(degrees(acos(cos_angle_between)))
152+
153+
# Use the square of the angle, measured in degrees.
154+
#
155+
# Why the square? This penalizes bigger angles more than
156+
# smaller ones.
157+
#
158+
# Why degrees? This is kind of arbitrary but allows us to
159+
# use integer math effectively and avoid taking the square
160+
# of a fraction between 0 and 1.
161+
scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2
162+
163+
length_so_far += (point - prev_point).length()
164+
prev_direction = direction
165+
prev_point = point
166+
167+
return scores
168+
169+
170+
def local_minima(array):
171+
# from: https://stackoverflow.com/a/9667121/4249120
172+
# This finds spots where the curvature (second derivative) is > 0.
173+
#
174+
# This method has the convenient benefit of choosing points around
175+
# 5% before and after a sharp corner such as in a square.
176+
return (diff(sign(diff(array))) > 0).nonzero()[0] + 1
177+
178+
179+
def generate_rungs(path, stroke_width, left_rail, right_rail):
180+
"""Create rungs for a satin column.
181+
182+
Where should we put the rungs along a path? We want to ensure that the
183+
resulting satin matches the original path as closely as possible. We
184+
want to avoid having a ton of rungs that will annoy the user. We want
185+
to ensure that the rungs we choose actually intersect both rails.
186+
187+
We'll place a few rungs perpendicular to the tangent of the path.
188+
Things get pretty tricky at sharp corners. If we naively place a rung
189+
perpendicular to the path just on either side of a sharp corner, the
190+
rung may not intersect both paths:
191+
| |
192+
_______________| |
193+
______|_
194+
____________________|
195+
196+
It'd be best to place rungs in the straight sections before and after
197+
the sharp corner and allow the satin column to bend the stitches around
198+
the corner automatically.
199+
200+
How can we find those spots?
201+
202+
The general algorithm below is:
203+
204+
* assign a "score" to each section of the path based on how sharp its
205+
corners are (higher means a sharper corner)
206+
* pick spots with lower scores
207+
"""
208+
209+
scores = get_scores(path)
210+
211+
# This is kind of like a 1-dimensional gaussian blur filter. We want to
212+
# avoid the area near a sharp corner, so we spread out its effect for
213+
# 5 buckets in either direction.
214+
scores = convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same')
215+
216+
# Now we'll find the spots that aren't near corners, whose scores are
217+
# low -- the local minima.
218+
rung_locations = local_minima(scores)
219+
220+
# Remove the start and end, because we can't stick a rung there.
221+
rung_locations = setdiff1d(rung_locations, [0, 100])
222+
223+
if len(rung_locations) == 0:
224+
# Straight lines won't have local minima, so add a rung in the center.
225+
rung_locations = [50]
226+
227+
rungs = []
228+
last_rung_center = None
229+
230+
for location in rung_locations:
231+
# Convert percentage to a fraction so that we can use interpolate's
232+
# normalized parameter.
233+
location = location / 100.0
234+
235+
rung_center = path.interpolate(location, normalized=True)
236+
rung_center = Point(rung_center.x, rung_center.y)
237+
238+
# Avoid placing rungs too close together. This somewhat
239+
# arbitrarily rejects the rung if there was one less than 2
240+
# millimeters before this one.
241+
if last_rung_center is not None and \
242+
(rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM:
243+
continue
244+
else:
245+
last_rung_center = rung_center
246+
247+
# We need to know the tangent of the path's curve at this point.
248+
# Pick another point just after this one and subtract them to
249+
# approximate a tangent vector.
250+
tangent_end = path.interpolate(location + 0.001, normalized=True)
251+
tangent_end = Point(tangent_end.x, tangent_end.y)
252+
tangent = (tangent_end - rung_center).unit()
253+
254+
# Rotate 90 degrees left to make a normal vector.
255+
normal = tangent.rotate_left()
256+
257+
# Extend the rungs by an offset value to make sure they will cross the rails
258+
offset = normal * (stroke_width / 2) * 1.2
259+
rung_start = rung_center + offset
260+
rung_end = rung_center - offset
261+
262+
rung_tuple = (rung_start.as_tuple(), rung_end.as_tuple())
263+
rung_linestring = shgeo.LineString(rung_tuple)
264+
if (isinstance(rung_linestring.intersection(left_rail), shgeo.Point) and
265+
isinstance(rung_linestring.intersection(right_rail), shgeo.Point)):
266+
rungs.append(rung_tuple)
267+
268+
return rungs
269+
270+
271+
def merge(section, other_section):
272+
"""Merge this satin with another satin
273+
274+
This method expects that the provided satin continues on directly after
275+
this one, as would be the case, for example, if the two satins were the
276+
result of the split() method.
277+
278+
Returns a new SatinColumn instance that combines the rails and rungs of
279+
this satin and the provided satin. A rung is added at the end of this
280+
satin.
281+
282+
The returned SatinColumn will not be in the SVG document and will have
283+
its transforms applied.
284+
"""
285+
rails, rungs = section
286+
other_rails, other_rungs = other_section
287+
288+
if len(rails) != 2 or len(other_rails) != 2:
289+
# weird non-satin things, give up and don't merge
290+
return section
291+
292+
# remove first node of each other rail before merging (avoid duplicated nodes)
293+
rails[0].extend(other_rails[0][1:])
294+
rails[1].extend(other_rails[1][1:])
295+
296+
# add a rung in between the two satins and extend it just a litte to ensure it is crossing the rails
297+
new_rung = shgeo.LineString([other_rails[0][0], other_rails[1][0]])
298+
rungs.append(list(scale(new_rung, 1.2, 1.2).coords))
299+
300+
# add on the other satin's rungs
301+
rungs.extend(other_rungs)
302+
303+
return (rails, rungs)

0 commit comments

Comments
 (0)