diff --git a/icons/inx/fill_to_satin.svg b/icons/inx/fill_to_satin.svg
new file mode 100644
index 000000000..95c9b166c
--- /dev/null
+++ b/icons/inx/fill_to_satin.svg
@@ -0,0 +1,69 @@
+
+
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index beafcc567..94e0b4bb6 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -19,6 +19,7 @@ from .density_map import DensityMap
from .display_stacking_order import DisplayStackingOrder
from .duplicate_params import DuplicateParams
from .element_info import ElementInfo
+from .fill_to_satin import FillToSatin
from .fill_to_stroke import FillToStroke
from .flip import Flip
from .generate_palette import GeneratePalette
@@ -33,10 +34,10 @@ from .layer_commands import LayerCommands
from .lettering import Lettering
from .lettering_along_path import LetteringAlongPath
from .lettering_custom_font_dir import LetteringCustomFontDir
+from .lettering_edit_json import LetteringEditJson
from .lettering_font_sample import LetteringFontSample
from .lettering_force_lock_stitches import LetteringForceLockStitches
from .lettering_generate_json import LetteringGenerateJson
-from .lettering_edit_json import LetteringEditJson
from .lettering_remove_kerning import LetteringRemoveKerning
from .lettering_set_color_sort_index import LetteringSetColorSortIndex
from .letters_to_font import LettersToFont
@@ -88,6 +89,7 @@ __all__ = extensions = [About,
DisplayStackingOrder,
DuplicateParams,
ElementInfo,
+ FillToSatin,
FillToStroke,
Flip,
GeneratePalette,
diff --git a/lib/extensions/fill_to_satin.py b/lib/extensions/fill_to_satin.py
new file mode 100644
index 000000000..4a9f2904c
--- /dev/null
+++ b/lib/extensions/fill_to_satin.py
@@ -0,0 +1,405 @@
+# 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 collections import defaultdict
+
+from inkex import Boolean, Group, Path, PathElement
+from shapely.geometry import LineString, MultiLineString, Point
+from shapely.ops import linemerge, snap, split, substring
+
+from ..elements import FillStitch, Stroke
+from ..gui.abort_message import AbortMessageApp
+from ..i18n import _
+from ..svg import get_correction_transform
+from ..utils import ensure_multi_line_string
+from .base import InkstitchExtension
+
+
+class FillToSatin(InkstitchExtension):
+ def __init__(self, *args, **kwargs):
+ InkstitchExtension.__init__(self, *args, **kwargs)
+ self.arg_parser.add_argument("--notebook")
+ self.arg_parser.add_argument("--skip_end_section", dest="skip_end_section", type=Boolean, default=False)
+ self.arg_parser.add_argument("--center", dest="center", type=Boolean, default=False)
+ self.arg_parser.add_argument("--contour", dest="contour", type=Boolean, default=False)
+ self.arg_parser.add_argument("--zigzag", dest="zigzag", type=Boolean, default=False)
+ self.arg_parser.add_argument("--keep_originals", dest="keep_originals", type=Boolean, default=False)
+
+ # geometries
+ self.line_sections = []
+ self.selected_rungs = []
+ self.rungs = [] # selection of valid rungs for the specific fill
+
+ # relations
+ self.rung_sections = defaultdict(list)
+ self.section_rungs = defaultdict(list)
+ self.bridged_sections = []
+ self.rung_segments = {}
+
+ self.satin_index = 1
+
+ def effect(self):
+ if not self.svg.selected or not self.get_elements():
+ self.print_error()
+ return
+
+ fill_elements = self._get_shapes()
+ if not fill_elements or not self.selected_rungs:
+ self.print_error()
+ return
+
+ for fill_element in fill_elements:
+ fill_shape = fill_element.shape
+
+ fill_linestrings = self._fill_to_linestrings(fill_shape)
+ for linestrings in fill_linestrings:
+ # Reset variables
+ self.rungs = []
+ self.line_sections = []
+ self.rung_sections = defaultdict(list)
+ self.section_rungs = defaultdict(list)
+ self.bridged_sections = []
+ self.rung_segments = {}
+
+ intersection_points, bridges = self._validate_rungs(linestrings)
+
+ self._generate_line_sections(linestrings)
+ self._define_relations(bridges)
+
+ if len(self.line_sections) == 2 and self.line_sections[0].distance(self.line_sections[1]) > 0:
+ # there is only one segment, add it directly
+ rails = [MultiLineString([self.line_sections[0], self.line_sections[1]])]
+ rungs = [ensure_multi_line_string(self.rungs[0])]
+ self._insert_satins(fill_element, [rails + rungs])
+ continue
+ else:
+ rung_segments, satin_segments = self._get_segments(intersection_points)
+
+ if len(self.rung_sections) == 2 and self.rung_sections[0] == self.rung_sections[1]:
+ combined_satins = self._get_two_rung_circle_geoms(rung_segments, satin_segments)
+ else:
+ combined_satins = self._get_satin_geoms(rung_segments, satin_segments)
+
+ self._insert_satins(fill_element, combined_satins)
+
+ self._remove_originals()
+
+ def _insert_satins(self, fill_element, combined_satins):
+ '''Insert satin elements into the document'''
+ if not combined_satins:
+ return
+ group = fill_element.node.getparent()
+ index = group.index(fill_element.node) + 1
+ transform = get_correction_transform(fill_element.node)
+ style = f'stroke: {fill_element.color}; fill: none; stroke-width: {self.svg.viewport_to_unit("1px")};'
+ if len(combined_satins) > 1:
+ new_group = Group()
+ group.insert(index, new_group)
+ group = new_group
+ group.label = _("Satin Group")
+ index = 0
+ for i, satin in enumerate(combined_satins):
+ node = PathElement()
+ d = ""
+ for segment in satin:
+ for geom in segment.geoms:
+ d += str(Path(list(geom.coords)))
+ node.set('d', d)
+ node.set('style', style)
+ node.set('inkstitch:satin_column', True)
+ if self.options.center:
+ node.set('inkstitch:center_walk_underlay', True)
+ if self.options.contour:
+ node.set('inkstitch:contour_underlay', True)
+ if self.options.zigzag:
+ node.set('inkstitch:zigzag_underlay', True)
+ node.transform = transform
+ node.apply_transform()
+ node.label = _("Satin") + f" {self.satin_index}"
+ group.insert(index, node)
+ self.satin_index += 1
+
+ def _remove_originals(self):
+ '''Remove original elements - if requested'''
+ if not self.options.keep_originals:
+ for element in self.elements:
+ element.node.getparent().remove(element.node)
+
+ def _get_two_rung_circle_geoms(self, rung_segments, satin_segments):
+ '''Imagine a donut with two rungs: this is a special case where all segments connect to the very same two rungs'''
+ combined = defaultdict(list)
+ combined_rungs = defaultdict(list)
+
+ combined[0] = [0, 1]
+ combined_rungs[0] = [0, 1]
+
+ return self._combined_segments_to_satin_geoms(combined, combined_rungs, satin_segments)
+
+ def _get_satin_geoms(self, rung_segments, satin_segments):
+ '''Combine segments and return satin geometries'''
+ self.rung_segments = {rung: segments for rung, segments in rung_segments.items() if len(segments) == 2}
+ finished_rungs = []
+ finished_segments = []
+ combined_rails = defaultdict(list)
+ combined_rungs = defaultdict(list)
+
+ for rung, segments in self.rung_segments.items():
+ self._find_connected(rung, segments, rung, finished_rungs, finished_segments, combined_rails, combined_rungs)
+
+ unfinished = {i for i, segment in enumerate(satin_segments) if i not in finished_segments}
+ segment_count = len(satin_segments)
+ for i, segment in enumerate(unfinished):
+ index = segment_count + i + 1
+ combined_rails[index] = [segment]
+
+ return self._combined_segments_to_satin_geoms(combined_rails, combined_rungs, satin_segments)
+
+ def _combined_segments_to_satin_geoms(self, combined_rails, combined_rungs, satin_segments):
+ combined_satins = []
+ for i, segments in combined_rails.items():
+ segment_geoms = []
+ for segment_index in set(segments):
+ segment_geoms.extend(list(satin_segments[segment_index].geoms))
+ satin_rails = ensure_multi_line_string(linemerge(segment_geoms))
+ satin_rails = [self._adjust_rail_direction(satin_rails)]
+
+ segment_geoms = []
+ for rung_index in set(combined_rungs[i]):
+ rung = self.rungs[rung_index]
+ # satin behaves bad if a rung is positioned directly at the beginning/end section
+ if rung.distance(Point(satin_rails[0].geoms[0].coords[0])) > 1:
+ segment_geoms.append(ensure_multi_line_string(rung))
+ combined_satins.append(satin_rails + segment_geoms)
+ return combined_satins
+
+ def _get_segments(self, intersection_points): # noqa: C901
+ '''Combine line sections to satin segments (find the rails that belong together)'''
+ line_section_multi = MultiLineString(self.line_sections)
+ rung_segments = defaultdict(list)
+ satin_segments = []
+
+ segment_index = 0
+ finished_sections = []
+ for i, section in enumerate(self.line_sections):
+ if i in finished_sections:
+ continue
+ s_rungs = self.section_rungs[i]
+ if len(s_rungs) == 1:
+ if self.options.skip_end_section and len(self.rungs) > 1:
+ continue
+ segment = self._get_end_segment(section)
+ satin_segments.append(segment)
+ finished_sections.append(i)
+ for rung in s_rungs:
+ rung_segments[rung].append(segment_index)
+ segment_index += 1
+
+ elif len(s_rungs) == 2:
+ connected_section = self._get_connected_section(i, s_rungs)
+ if connected_section:
+ connect_index, segment = self._get_standard_segment(connected_section, s_rungs, section, finished_sections)
+ if segment is None:
+ continue
+ satin_segments.append(segment)
+ for rung in s_rungs:
+ rung_segments[rung].append(segment_index)
+ segment_index += 1
+ finished_sections.extend([i, connect_index])
+
+ elif i in self.bridged_sections:
+ segment = self._get_bridged_segment(section, s_rungs, intersection_points, line_section_multi)
+ if segment:
+ satin_segments.append(segment)
+ for rung in s_rungs:
+ rung_segments[rung].append(segment_index)
+ segment_index += 1
+ finished_sections.append(i)
+ else:
+ # sections with multiple rungs, open ends, not bridged
+ # IF users define their rungs well, they won't have a problem if we just ignore these sections
+ # otherwise they will see some sort of gap, they can close it manually if they want
+ pass
+ return rung_segments, satin_segments
+
+ def _get_end_segment(self, section):
+ section = section.simplify(0.5)
+ rail1 = substring(section, 0, 0.40009, True).coords
+ rail2 = substring(section, 0.50001, 1, True).coords
+ if len(rail1) > 2:
+ rail1 = rail1[:-1]
+ if len(rail2) > 2:
+ rail2 = rail2[1:]
+
+ segment = MultiLineString([LineString(rail1), LineString(rail2)])
+ return segment
+
+ def _get_standard_segment(self, connected_section, s_rungs, section, finished_sections):
+ section2 = None
+ segment = None
+ connect_index = None
+ if len(connected_section) == 1:
+ section2 = self.line_sections[connected_section[0]]
+ connect_index = connected_section[0]
+ else:
+ for connect in connected_section:
+ if connect in finished_sections:
+ continue
+ offset_rung = self.rungs[s_rungs[0]].offset_curve(0.01)
+ section_candidate = self.line_sections[connect]
+ if offset_rung.intersects(section) == offset_rung.intersects(section_candidate):
+ section2 = section_candidate
+ connect_index = connect
+ break
+ if section2 is not None:
+ segment = MultiLineString([section, section2])
+ return connect_index, segment
+
+ def _get_bridged_segment(self, section, s_rungs, intersection_points, line_section_multi):
+ segment = None
+ bridge_points = []
+ # create bridge
+ for rung in s_rungs:
+ rung_points = intersection_points[rung].geoms
+ for point in rung_points:
+ if point.distance(section) > 0.01:
+ bridge_points.append(point)
+ if len(bridge_points) == 2:
+ rung = self.rungs[s_rungs[0]]
+ bridge = LineString(bridge_points)
+ bridge = snap(bridge, line_section_multi, 0.0001)
+ segment = MultiLineString([section, bridge])
+ return segment
+
+ def _get_connected_section(self, index, s_rungs):
+ rung_section_list = []
+ for rung in s_rungs:
+ connections = self.rung_sections[rung]
+ rung_section_list.append(connections)
+ connected_section = list(set(rung_section_list[0]) & set(rung_section_list[1]))
+ connected_section.remove(index)
+ return connected_section
+
+ def _adjust_rail_direction(self, satin_rails):
+ # See also elements/satin_column.py (_get_rails_to_reverse)
+ rails = list(satin_rails.geoms)
+ lengths = []
+ lengths_reverse = []
+
+ for i in range(10):
+ distance = i / 10
+ point0 = rails[0].interpolate(distance, normalized=True)
+ point1 = rails[1].interpolate(distance, normalized=True)
+ point1_reverse = rails[1].interpolate(1 - distance, normalized=True)
+
+ lengths.append(point0.distance(point1))
+ lengths_reverse.append(point0.distance(point1_reverse))
+
+ if sum(lengths) > sum(lengths_reverse):
+ rails[0] = rails[0].reverse()
+
+ return MultiLineString(rails)
+
+ def _find_connected(self, rung, segments, first_rung, finished_rungs, finished_segments, combined_rails, combined_rungs):
+ '''Group combinable segments'''
+ if rung in finished_rungs:
+ return
+ finished_rungs.append(rung)
+ combined_rails[first_rung].extend(segments)
+ combined_rungs[first_rung].append(rung)
+ finished_segments.extend(segments)
+ for segment in segments:
+ connected = self._get_combinable_segments(segment, segments)
+ if not connected:
+ continue
+ for connected_rung, connected_segments in connected.items():
+ self._find_connected(
+ connected_rung,
+ connected_segments,
+ first_rung, finished_rungs,
+ finished_segments,
+ combined_rails,
+ combined_rungs
+ )
+
+ def _get_combinable_segments(self, segment, segments_in):
+ '''Finds the segments which are neighboring this segment'''
+ return {rung: segments for rung, segments in self.rung_segments.items() if segment in segments and segments_in != segments}
+
+ def _generate_line_sections(self, fill_linestrings):
+ '''Splits the fill outline into sections. Splitter is a MultiLineString with all available rungs'''
+ rungs = MultiLineString(self.rungs)
+ for line in fill_linestrings:
+ sections = list(ensure_multi_line_string(split(line, rungs)).geoms)
+ if len(sections) > 1:
+ # merge end and start section
+ sections[0] = linemerge(MultiLineString([sections[0], sections[-1]]))
+ del sections[-1]
+ self.line_sections.extend(sections)
+
+ def _define_relations(self, bridges):
+ ''' Defines information about the relations between line_sections and rungs
+ rung_sections: dictionary with rung_index: neighboring sections
+ section_rungs: dictionary with section_id: neighboring rungs
+ bridged_sections: list of sections which the user marked for bridging
+ '''
+ for i, section in enumerate(self.line_sections):
+ if not section.intersection(bridges).is_empty:
+ self.bridged_sections.append(i)
+ for j, rung in enumerate(self.rungs):
+ if section.distance(rung) < 0.01:
+ self.section_rungs[i].append(j)
+ self.rung_sections[j].append(i)
+
+ def _validate_rungs(self, linestrings):
+ ''' Returns only valid rungs and bridge section markers'''
+ multi_line_string = MultiLineString(linestrings)
+ valid_rungs = []
+ bridge_indicators = []
+ intersection_points = []
+ for rung in self.selected_rungs:
+ intersection = multi_line_string.intersection(rung)
+ if intersection.geom_type == 'MultiPoint' and len(intersection.geoms) == 2:
+ valid_rungs.append(rung)
+ intersection_points.append(intersection)
+ elif intersection.geom_type == 'Point':
+ # these rungs help to indicate how the satin section should be connected
+ bridge_indicators.append(rung)
+ self.rungs = valid_rungs
+ return intersection_points, MultiLineString(bridge_indicators)
+
+ def _fill_to_linestrings(self, fill_shape):
+ '''Takes a fill shape (Multipolygon) and returns the shape as a list of linestrings'''
+ fill_linestrings = []
+ for polygon in fill_shape.geoms:
+ linestrings = ensure_multi_line_string(polygon.boundary, 1)
+ fill_linestrings.append(list(linestrings.geoms))
+ return fill_linestrings
+
+ def _get_shapes(self):
+ '''Filter selected elements. Take rungs and fills.'''
+ fill_elements = []
+ nodes = []
+ warned = False
+ for element in self.elements:
+ if element.node in nodes and not warned:
+ self.print_error(
+ (f'{element.node.label} ({element.node.get_id()}): ' + _("This element has a fill and a stroke.\n\n"
+ "Rungs only have a stroke color and fill elements a fill color."))
+ )
+ warned = True
+ nodes.append(element.node)
+ if isinstance(element, FillStitch):
+ fill_elements.append(element)
+ elif isinstance(element, Stroke):
+ self.selected_rungs.extend(list(element.as_multi_line_string().geoms))
+ return fill_elements
+
+ def print_error(self, message=_("Please select a fill object and rungs.")):
+ '''We did not receive the rigth elements, inform user'''
+ app = AbortMessageApp(
+ message,
+ _("https://inkstitch.org/satin-tools#fill-to-satin")
+ )
+ app.MainLoop()
diff --git a/lib/extensions/fill_to_stroke.py b/lib/extensions/fill_to_stroke.py
index db5719a6c..9101eca22 100644
--- a/lib/extensions/fill_to_stroke.py
+++ b/lib/extensions/fill_to_stroke.py
@@ -227,7 +227,7 @@ class FillToStroke(InkstitchExtension):
continue
def _close_gaps(self, lines, cut_lines):
- snaped_lines = []
+ snapped_lines = []
lines = MultiLineString(lines)
for i, line in enumerate(lines.geoms):
# for each cutline check if a line starts or ends close to it
@@ -239,16 +239,16 @@ class FillToStroke(InkstitchExtension):
l_l = lines.difference(line)
for cut_line in cut_lines:
distance = start.distance(l_l)
- if cut_line.distance(start) < 0.6:
+ if cut_line.distance(start) < 1:
distance = start.distance(l_l)
new_start_point = self._extend_line(line.coords[0], line.coords[1], distance)
coords[0] = nearest_points(Point(list(new_start_point)), l_l)[1]
- if cut_line.distance(end) < 0.6:
+ if cut_line.distance(end) < 1:
distance = end.distance(l_l)
new_end_point = self._extend_line(line.coords[-1], line.coords[-2], distance)
coords[-1] = nearest_points(Point(list(new_end_point)), l_l)[1]
- snaped_lines.append(LineString(coords))
- return snaped_lines
+ snapped_lines.append(LineString(coords))
+ return snapped_lines
def _extend_line(self, p1, p2, distance):
start_point = InkstitchPoint.from_tuple(p1)
diff --git a/lib/lettering/font.py b/lib/lettering/font.py
index 6127a84c7..93e550d81 100644
--- a/lib/lettering/font.py
+++ b/lib/lettering/font.py
@@ -577,7 +577,7 @@ class Font(object):
group.append(color_group)
- def _get_color_sorted_elements(self, group, transform_key): # noqa: 901
+ def _get_color_sorted_elements(self, group, transform_key): # noqa: C901
elements_by_color = defaultdict(list)
last_parent = None
diff --git a/templates/fill_to_satin.xml b/templates/fill_to_satin.xml
new file mode 100644
index 000000000..5547bfcdd
--- /dev/null
+++ b/templates/fill_to_satin.xml
@@ -0,0 +1,43 @@
+
+
+ Fill to satin
+ org.{{ id_inkstitch }}.fill_to_satin
+ fill_to_satin
+
+
+
+ false
+
+
+
+ false
+ false
+ false
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+ all
+ {{ icon_path }}inx/fill_to_satin.svg
+ Convert fill elements to satin
+
+
+
+
+
+
+
+