From 2e60900b1ae4355762f7b59141c1c47f6ffcbc66 Mon Sep 17 00:00:00 2001 From: Kaalleen <36401965+kaalleen@users.noreply.github.com> Date: Thu, 25 Jan 2024 17:59:27 +0100 Subject: [PATCH] Stroke to Fill: Ignore Small Artifacts (#2678) * Ignore artifacts * insert one centerline group per fill element * prevent error on elements with fill and stroke --- lib/extensions/fill_to_stroke.py | 213 +++++++++++++++++-------------- 1 file changed, 119 insertions(+), 94 deletions(-) diff --git a/lib/extensions/fill_to_stroke.py b/lib/extensions/fill_to_stroke.py index 28c1f6517..c33ede3d7 100644 --- a/lib/extensions/fill_to_stroke.py +++ b/lib/extensions/fill_to_stroke.py @@ -3,16 +3,16 @@ # Copyright (c) 2022 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from inkex import Boolean, Group, PathElement, Transform, errormsg +from inkex import Boolean, Group, Path, PathElement, Transform, errormsg from inkex.units import convert_unit -from shapely.geometry import (LineString, MultiLineString, MultiPolygon, Point, - Polygon) +from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point from shapely.ops import linemerge, nearest_points, split, voronoi_diagram from ..elements import FillStitch, Stroke from ..i18n import _ from ..stitches.running_stitch import running_stitch from ..svg import get_correction_transform +from ..utils import ensure_multi_line_string from ..utils.geometry import Point as InkstitchPoint from ..utils.geometry import line_string_to_point_list from .base import InkstitchExtension @@ -33,10 +33,7 @@ class FillToStroke(InkstitchExtension): errormsg(_("Please select one or more fill objects to render the centerline.")) return - cut_lines = [] - fill_shapes = [] - - fill_shapes, cut_lines = self._get_shapes() + fill_shapes, cut_lines, cut_line_nodes = self._get_shapes() if not fill_shapes: errormsg(_("Please select one or more fill objects to render the centerline.")) @@ -45,67 +42,71 @@ class FillToStroke(InkstitchExtension): # convert user input from mm to px self.threshold = convert_unit(self.options.threshold_mm, 'px', 'mm') - # insert centerline group before the first selected element - first = fill_shapes[0].node - parent = first.getparent() - index = parent.index(first) + 1 - centerline_group = Group.new("Centerline Group", id=self.uniqueId("centerline_group_")) - parent.insert(index, centerline_group) - + # convert to center line elements and insert into svg for element in fill_shapes: - transform = Transform(get_correction_transform(parent, child=True)) - stroke_width = convert_unit(self.options.line_width_mm, 'px', 'mm') - color = element.node.style('fill') - style = f"fill:none;stroke:{ color };stroke-width:{ stroke_width }" + self._convert_to_centerline(element, cut_lines) - multipolygon = element.shape - for cut_line in cut_lines: - split_polygon = split(multipolygon, cut_line) - poly = [polygon for polygon in split_polygon.geoms if isinstance(polygon, Polygon)] - multipolygon = MultiPolygon(poly) - - lines = [] - - for polygon in multipolygon.geoms: - multilinestring = self._get_centerline(polygon) - if multilinestring is None: - continue - lines.extend(multilinestring.geoms) - - if self.options.close_gaps: - lines = self._close_gaps(lines, cut_lines) - - # insert new elements - self._insert_elements(lines, centerline_group, index, transform, style) - - # clean up + # remove cut lines if not self.options.keep_original: - self._remove_elements() + self._remove_cutlines(cut_line_nodes) def _get_shapes(self): fill_shapes = [] cut_lines = [] + cut_line_nodes = [] for element in self.elements: if isinstance(element, FillStitch): fill_shapes.append(element) elif isinstance(element, Stroke): cut_lines.extend(list(element.as_multi_line_string().geoms)) - return fill_shapes, cut_lines + cut_line_nodes.append(element.node) + return fill_shapes, cut_lines, cut_line_nodes - def _remove_elements(self): - parents = [] - for element in self.elements: - # it is possible, that we get one element twice (if it has both, a fill and a stroke) - # just ignore the second time - try: - parents.append(element.node.getparent()) - element.node.getparent().remove(element.node) - except AttributeError: - pass - # remove empty groups - for parent in set(parents): - if parent is not None and not parent.getchildren(): - parent.getparent().remove(parent) + def _convert_to_centerline(self, element, cut_lines): + element_id = element.node.get_id() + element_label = element.node.label + group_name = element_label or element_id + + centerline_group = Group.new(f'{ group_name } { _("center line") }', id=self.uniqueId("centerline_group_")) + parent = element.node.getparent() + index = parent.index(element.node) + 1 + parent.insert(index, centerline_group) + + transform = Transform(get_correction_transform(parent, child=True)) + stroke_width = convert_unit(self.options.line_width_mm, 'px', 'mm') + color = element.node.style('fill') + style = f"fill:none;stroke:{ color };stroke-width:{ stroke_width }" + + multipolygon = element.shape + multipolygon = self._apply_cut_lines(cut_lines, multipolygon) + + lines = self._get_lines(multipolygon) + + if self.options.close_gaps: + lines = self._close_gaps(lines, cut_lines) + + # do not use a group in case there is only one line + if len(lines) <= 1: + parent.remove(centerline_group) + centerline_group = parent + + # clean up + if not self.options.keep_original: + parent.remove(element.node) + + # insert new elements + self._insert_elements(lines, centerline_group, index, element_id, element_label, transform, style) + + def _get_lines(self, multipolygon): + lines = [] + for polygon in multipolygon.geoms: + if polygon.area < 0.5: + continue + multilinestring = self._get_centerline(polygon) + if multilinestring is None: + continue + lines.extend(multilinestring.geoms) + return lines def _get_high_res_polygon(self, polygon): # use running stitch method @@ -121,7 +122,7 @@ class FillToStroke(InkstitchExtension): def _get_centerline(self, polygon): # increase the resolution of the polygon polygon = self._get_high_res_polygon(polygon) - if polygon is isinstance(polygon, MultiPolygon): + if polygon is polygon.geom_type == 'MultiPolygon': return # generate voronoi centerline @@ -139,14 +140,14 @@ class FillToStroke(InkstitchExtension): if multilinestring is None: return # simplify polygon - multilinestring = self._ensure_multilinestring(multilinestring.simplify(0.1)) + multilinestring = ensure_multi_line_string(multilinestring.simplify(0.1)) if multilinestring is None: return return multilinestring def _get_voronoi_centerline(self, polygon): lines = voronoi_diagram(polygon, edges=True).geoms[0] - if not isinstance(lines, MultiLineString): + if not lines.geom_type == 'MultiLineString': return multilinestring = [] for line in lines.geoms: @@ -155,7 +156,14 @@ class FillToStroke(InkstitchExtension): lines = linemerge(multilinestring) if lines.is_empty: return - return self._ensure_multilinestring(lines) + return ensure_multi_line_string(lines) + + def _apply_cut_lines(self, cut_lines, multipolygon): + for cut_line in cut_lines: + split_polygon = split(multipolygon, cut_line) + poly = [polygon for polygon in split_polygon.geoms if polygon.geom_type == 'Polygon'] + multipolygon = MultiPolygon(poly) + return multipolygon def _get_start_and_end_points(self, multilinestring): points = [] @@ -182,43 +190,49 @@ class FillToStroke(InkstitchExtension): if lines.is_empty: lines = None else: - lines = self._ensure_multilinestring(lines) + lines = ensure_multi_line_string(lines) return lines def _repair_splitted_ends(self, polygon, multilinestring, dead_ends): lines = list(multilinestring.geoms) for i, dead_end in enumerate(dead_ends): - coords = dead_end.coords - for j in range(i + 1, len(dead_ends)): - common_point = set([coords[0], coords[-1]]).intersection(dead_ends[j].coords) - if len(common_point) > 0: - # prepare all lines to point to the common point - dead_point1 = coords[0] - if dead_point1 in common_point: - dead_point1 = coords[-1] - dead_point2 = dead_ends[j].coords[0] - if dead_point2 in common_point: - dead_point2 = dead_ends[j].coords[-1] - end_line = LineString([dead_point1, dead_point2]) - if polygon.covers(end_line): - dead_end_center_point = end_line.centroid - else: - continue - lines.append(LineString([dead_end_center_point, list(common_point)[0]])) - if dead_end in lines: - lines.remove(dead_end) - if dead_ends[j] in lines: - lines.remove(dead_ends[j]) + if dead_end.length > self.threshold: + continue + self._join_end(polygon, lines, dead_ends, dead_end, i) + return ensure_multi_line_string(linemerge(lines)) + + def _join_end(self, polygon, lines, dead_ends, dead_end, index): + coords = dead_end.coords + for j in range(index + 1, len(dead_ends)): + if dead_ends[j].length > self.threshold: + continue + common_point = set([coords[0], coords[-1]]).intersection(dead_ends[j].coords) + if len(common_point) > 0: + dead_point1 = coords[0] + if dead_point1 in common_point: + dead_point1 = coords[-1] + dead_point2 = dead_ends[j].coords[0] + if dead_point2 in common_point: + dead_point2 = dead_ends[j].coords[-1] + end_line = LineString([dead_point1, dead_point2]) + if polygon.covers(end_line): + dead_end_center_point = end_line.centroid + else: continue - return self._ensure_multilinestring(linemerge(lines)) + lines.append(LineString([dead_end_center_point, list(common_point)[0]])) + if dead_end in lines: + lines.remove(dead_end) + if dead_ends[j] in lines: + lines.remove(dead_ends[j]) + continue def _close_gaps(self, lines, cut_lines): snaped_lines = [] lines = MultiLineString(lines) for i, line in enumerate(lines.geoms): - # for each cutline check if a the line starts or ends close to it + # for each cutline check if a line starts or ends close to it # if so extend the line at the start/end for the distance of the nearest point and snap it to that other line - # we do not want to snap it to the rest of the lines directly, this could push the connection point into a unwanted direction + # we do not want to snap it to the rest of the lines directly, this could push the connection point into an unwanted direction coords = list(line.coords) start = Point(coords[0]) end = Point(coords[-1]) @@ -243,15 +257,26 @@ class FillToStroke(InkstitchExtension): new_point = start_point - direction * distance return new_point - def _ensure_multilinestring(self, lines): - if not isinstance(lines, MultiLineString): - lines = MultiLineString([lines]) - return lines + def _remove_cutlines(self, cut_line_nodes): + for cut_line in cut_line_nodes: + # it is possible, that we get one element twice (if it has both, a fill and a stroke) + # this means that we already removed it from the svg and we can ignore the error. + try: + cut_line.getparent().remove(cut_line) + except AttributeError: + pass - def _insert_elements(self, lines, parent, index, transform, style): - for line in lines: - d = "M " - for coord in line.coords: - d += "%s,%s " % (coord[0], coord[1]) - centerline_element = PathElement(d=d, style=style, transform=str(transform)) + def _insert_elements(self, lines, parent, index, element_id, element_label, transform, style): + replace = False if len(lines) > 1 or self.options.keep_original else True + for i, line in enumerate(lines): + line_id = element_id if replace else self.uniqueId(f"{ element_id }_") + centerline_element = PathElement( + id=line_id, + d=str(Path(line.coords)), + style=style, + transform=str(transform) + ) + if element_label is not None: + label = element_label if replace else f"{ element_label }_{ i }" + centerline_element.label = label parent.insert(index, centerline_element)