kopia lustrzana https://github.com/inkstitch/inkstitch
529 wiersze
24 KiB
Python
529 wiersze
24 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("--pull_compensation_mm", dest="pull_compensation_mm", type=float, default=0)
|
|
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)
|
|
|
|
self.satin_index = 0
|
|
|
|
def effect(self):
|
|
if not self.svg.selected or not self.get_elements():
|
|
self.print_error()
|
|
return
|
|
|
|
fill_elements, selected_rungs = self._get_shapes()
|
|
if not fill_elements or not selected_rungs:
|
|
self.print_error()
|
|
return
|
|
|
|
settings = {
|
|
'skip_end_section': self.options.skip_end_section
|
|
}
|
|
|
|
for fill_element in fill_elements:
|
|
fill_shape = fill_element.shape
|
|
|
|
fill_linestrings = self._fill_to_linestrings(fill_shape)
|
|
for linestrings in fill_linestrings:
|
|
fill_to_satin = FillElementToSatin(self.svg, settings, fill_element, fill_shape, linestrings, selected_rungs)
|
|
satins = fill_to_satin.convert_to_satin()
|
|
self._insert_satins(fill_element, satins)
|
|
|
|
self._remove_originals()
|
|
|
|
def _get_shapes(self):
|
|
'''Filter selected elements. Take rungs and fills.'''
|
|
fill_elements = []
|
|
selected_rungs = []
|
|
nodes = []
|
|
fill_and_stroke_elements = []
|
|
for element in self.elements:
|
|
if element.node in nodes:
|
|
fill_and_stroke_elements.append(element)
|
|
if isinstance(element, FillStitch) and element.shape.area > 0.1:
|
|
fill_elements.append(element)
|
|
elif isinstance(element, Stroke):
|
|
selected_rungs.extend(list(element.as_multi_line_string().geoms))
|
|
else:
|
|
continue
|
|
nodes.append(element.node)
|
|
|
|
if fill_and_stroke_elements:
|
|
elements = [f'{element.node.label} ({element.node.get_id()})' for element in fill_and_stroke_elements]
|
|
if len(elements) > 15:
|
|
elements = elements[:14]
|
|
elements.append('...')
|
|
self.print_error(
|
|
(_("The selection contains elements with both, a fill and a stroke.\n\n"
|
|
"Rungs only have a stroke color and fill elements a fill color.") +
|
|
"\n\n- " + '\n- '.join(elements))
|
|
)
|
|
|
|
return fill_elements, selected_rungs
|
|
|
|
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 _insert_satins(self, fill_element, satins):
|
|
'''Insert satin elements into the document'''
|
|
if not 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(satins) > 1:
|
|
new_group = Group()
|
|
group.insert(index, new_group)
|
|
group = new_group
|
|
group.label = _("Satin Group")
|
|
index = 0
|
|
for i, satin in enumerate(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)
|
|
if self.options.pull_compensation_mm != 0:
|
|
node.set('inkstitch:pull_compensation_mm', str(self.options.pull_compensation_mm))
|
|
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'''
|
|
for element in self.elements:
|
|
if not self.options.keep_originals or element.name == "Stroke":
|
|
try:
|
|
element.node.delete()
|
|
except AttributeError:
|
|
pass
|
|
|
|
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/docs/satin-tools/#fill-to-satin")
|
|
)
|
|
app.MainLoop()
|
|
|
|
|
|
class FillElementToSatin:
|
|
def __init__(self, svg, settings, fill_element, fill_shape, linestrings, selected_rungs):
|
|
self.svg = svg
|
|
self.settings = settings
|
|
self.fill_element = fill_element
|
|
self.fill_shape = fill_shape
|
|
self.linestrings = linestrings
|
|
self.selected_rungs = selected_rungs
|
|
|
|
self.rungs = [] # rung geometries
|
|
self.half_rungs = [] # index half rungs in self.rungs
|
|
self.line_sections = [] # sections of the outline. LineStrings between the rungs
|
|
self.rung_segments = {} # assembled satin segments
|
|
self.rung_sections = defaultdict(list) # rung_index: section indices
|
|
self.section_rungs = defaultdict(list) # section index: rung indices
|
|
self.bridged_rungs = defaultdict(list) # bridge index: rung indices
|
|
self.rung_bridges = defaultdict(list) # rung index: bridge indices
|
|
|
|
def convert_to_satin(self):
|
|
intersection_points, bridges = self._validate_rungs()
|
|
|
|
self._generate_line_sections()
|
|
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])]
|
|
return ([rails + rungs])
|
|
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)
|
|
|
|
return combined_satins
|
|
|
|
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))
|
|
if len(satin_rails.geoms) != 2:
|
|
continue
|
|
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 = []
|
|
used_bridges = []
|
|
|
|
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:
|
|
# end section
|
|
if self.settings['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])
|
|
else:
|
|
for bridge, rung_list in self.bridged_rungs.items():
|
|
if len(rung_list) != 2:
|
|
continue
|
|
for rung in s_rungs:
|
|
if rung in rung_list:
|
|
if bridge in used_bridges:
|
|
continue
|
|
rung1 = rung_list[0]
|
|
rung2 = rung_list[1]
|
|
segment = self._get_segment(rung1, rung2, intersection_points, line_section_multi)
|
|
satin_segments.append(segment)
|
|
rung_segments[rung_list[0]].append(segment_index)
|
|
rung_segments[rung_list[1]].append(segment_index)
|
|
segment_index += 1
|
|
finished_sections.append(i)
|
|
used_bridges.append(bridge)
|
|
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_segment(self, rung1, rung2, intersection_points, line_section_multi):
|
|
rung_sections1 = self.rung_sections[rung1]
|
|
rung_sections2 = self.rung_sections[rung2]
|
|
points1 = self._get_rung_points(rung1, intersection_points)
|
|
points2 = self._get_rung_points(rung2, intersection_points)
|
|
|
|
connected_section = list(set(rung_sections1) & set(rung_sections2))
|
|
if len(connected_section) == 1:
|
|
# do not bridge a segment side if there is an actual section we could use
|
|
segment1 = self.line_sections[connected_section[0]]
|
|
|
|
points1 = sorted(points1, key=lambda point: segment1.distance(point), reverse=True)
|
|
points2 = sorted(points2, key=lambda point: segment1.distance(point), reverse=True)
|
|
segment2 = LineString([points1[0], points2[0]])
|
|
|
|
segment1 = snap(segment1, line_section_multi, 0.0001)
|
|
segment2 = snap(segment2, line_section_multi, 0.0001)
|
|
|
|
segment = MultiLineString([segment1, segment2])
|
|
return segment
|
|
|
|
segment1 = LineString([points1[0], points2[0]])
|
|
segment2 = LineString([points1[1], points2[1]])
|
|
if segment1.intersects(segment2):
|
|
segment1 = LineString([points1[0], points2[1]])
|
|
segment2 = LineString([points1[1], points2[0]])
|
|
segment1 = snap(segment1, line_section_multi, 0.0001)
|
|
segment2 = snap(segment2, line_section_multi, 0.0001)
|
|
segment = MultiLineString([segment1, segment2])
|
|
return segment
|
|
|
|
def _get_rung_points(self, rung, intersection_points):
|
|
rung_geom = self.rungs[rung]
|
|
intersections = intersection_points[rung]
|
|
if not intersections:
|
|
return [rung_geom.interpolate(0.3), rung_geom.interpolate(rung_geom.length - 0.3)]
|
|
if intersections.geom_type == 'MultiPoint':
|
|
return intersections.geoms
|
|
if rung_geom.project(intersections, normalized=True) > 0.5:
|
|
point1 = rung_geom.interpolate(0.3)
|
|
else: # Point
|
|
point1 = rung_geom.interpolate(rung_geom.length - 0.3)
|
|
return [intersections, point1]
|
|
|
|
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_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):
|
|
'''Splits the fill outline into sections. Splitter is a MultiLineString with all available rungs'''
|
|
rungs = MultiLineString(self.rungs)
|
|
|
|
for line in self.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_rungs: lines which define which segments are to be bridged at intersection points
|
|
'''
|
|
for i, section in enumerate(self.line_sections):
|
|
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)
|
|
for i, bridge in enumerate(bridges):
|
|
for j, rung in enumerate(self.rungs):
|
|
if bridge.intersects(rung):
|
|
self.bridged_rungs[i].append(j)
|
|
self.rung_bridges[j].append(i)
|
|
|
|
def _validate_rungs(self):
|
|
''' Returns only valid rungs and bridge section markers'''
|
|
multi_line_string = MultiLineString(self.linestrings)
|
|
bridges = []
|
|
intersection_points = []
|
|
rungs = []
|
|
half_rungs = []
|
|
for rung in self.selected_rungs:
|
|
intersection = multi_line_string.intersection(rung)
|
|
if intersection.geom_type == 'MultiPoint' and len(intersection.geoms) == 2:
|
|
rungs.append(rung)
|
|
# intersection_points.append(intersection)
|
|
elif intersection.is_empty and rung.within(self.fill_shape):
|
|
# these rungs (possibly) connect two rungs
|
|
bridges.append(rung)
|
|
elif intersection.geom_type == 'Point':
|
|
# half rungs will can mark a bridge endpoint at an open end within the shape
|
|
# intersection_points.append(intersection)
|
|
half_rungs.append(rung)
|
|
# filter rungs when they are crossing other rungs. They could possibly produce bad line sections
|
|
for i, rung in enumerate(rungs):
|
|
multi_rung = MultiLineString([r for j, r in enumerate(rungs) if j != i])
|
|
intersection = rung.intersection(multi_rung)
|
|
if not rung.intersects(multi_rung) or not rung.intersection(multi_rung).intersects(self.fill_shape):
|
|
self.rungs.append(rung)
|
|
intersection_points.append(multi_line_string.intersection(rung))
|
|
# filter half rungs if they are not bridged
|
|
bridges_linestring = MultiLineString(bridges)
|
|
for rung in half_rungs:
|
|
if rung.intersects(bridges_linestring):
|
|
self.half_rungs.append(len(self.rungs))
|
|
self.rungs.append(rung)
|
|
intersection_points.append(multi_line_string.intersection(rung))
|
|
# filter bridges
|
|
bridges = self._validate_bridges(bridges, intersection_points)
|
|
return intersection_points, bridges
|
|
|
|
def _validate_bridges(self, bridges, intersection_points):
|
|
validated_bridges = []
|
|
multi_rung = MultiLineString(self.rungs)
|
|
# find elements marked as bridges, but don't intersect with any other rung.
|
|
# they may be rungs drawn inside of a shape, so let's add them to the rungs and see if they are helpful
|
|
for bridge in bridges:
|
|
rung_intersections = bridge.intersection(multi_rung)
|
|
if rung_intersections.is_empty:
|
|
# doesn't intersect with any rungs, so it is a rung itself (when bridged)
|
|
self.half_rungs.append(len(self.rungs))
|
|
self.rungs.append(bridge)
|
|
intersection_points.append(Point())
|
|
|
|
# now validate bridges and split them up if necessary
|
|
multi_rung = MultiLineString(self.rungs)
|
|
for bridge in bridges:
|
|
rung_intersections = bridge.intersection(multi_rung)
|
|
if rung_intersections.geom_type == "MultiPoint":
|
|
if len(rung_intersections.geoms) == 2:
|
|
validated_bridges.append(bridge)
|
|
elif len(rung_intersections.geoms) > 2:
|
|
# bridges multiple rungs
|
|
points = list(rung_intersections.geoms)
|
|
points = sorted(points, key=lambda point: bridge.project(point))
|
|
for point1, point2 in zip(points[:-1], points[1:]):
|
|
distance1 = bridge.project(point1) - 0.1
|
|
distance2 = bridge.project(point2) + 0.1
|
|
validated_bridges.append(substring(bridge, distance1, distance2))
|
|
validated_bridges.append(bridge)
|
|
elif rung_intersections.geom_type == "Point":
|
|
# bridges a rung within the shape
|
|
validated_bridges.append(bridge)
|
|
return validated_bridges
|