kopia lustrzana https://github.com/inkstitch/inkstitch
406 wiersze
18 KiB
Python
406 wiersze
18 KiB
Python
![]() |
# 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()
|