Fill: make valid (#1697)

pull/1701/head
Kaalleen 2022-06-21 19:59:26 +02:00 zatwierdzone przez GitHub
rodzic eef2ec232e
commit 0fe1d3b426
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
2 zmienionych plików z 141 dodań i 101 usunięć

Wyświetl plik

@ -10,12 +10,13 @@ import sys
import traceback
from shapely import geometry as shgeo
from shapely.validation import explain_validity
from shapely.errors import TopologicalError
from shapely.validation import explain_validity, make_valid
from ..i18n import _
from ..marker import get_marker_elements
from ..stitch_plan import StitchGroup
from ..stitches import contour_fill, auto_fill, legacy_fill, guided_fill
from ..stitches import auto_fill, contour_fill, guided_fill, legacy_fill
from ..svg import PIXELS_PER_MM
from ..svg.tags import INKSCAPE_LABEL
from ..utils import cache, version
@ -68,9 +69,9 @@ class MultipleGuideLineWarning(ValidationWarning):
]
class UnconnectedError(ValidationError):
class UnconnectedWarning(ValidationWarning):
name = _("Unconnected")
description = _("Fill: This object is made up of unconnected shapes. This is not allowed because "
description = _("Fill: This object is made up of unconnected shapes. "
"Ink/Stitch doesn't know what order to stitch them in. Please break this "
"object up into separate shapes.")
steps_to_solve = [
@ -78,9 +79,18 @@ class UnconnectedError(ValidationError):
]
class InvalidShapeError(ValidationError):
class BorderCrossWarning(ValidationWarning):
name = _("Border crosses itself")
description = _("Fill: Shape is not valid. This can happen if the border crosses over itself.")
description = _("Fill: The border crosses over itself. This may lead into unconnected shapes. "
"Please break this object into separate shapes to indicate in which order it should be stitched in.")
steps_to_solve = [
_('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects')
]
class InvalidShapeError(ValidationError):
name = _("This shape is invalid")
description = _('Fill: This shape cannot be stitched out. Please try to repair it with the "Break Apart Fill Objects" extension.')
steps_to_solve = [
_('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects')
]
@ -217,39 +227,69 @@ class FillStitch(EmbroideryElement):
@property
@cache
def shape(self):
def original_shape(self):
# shapely's idea of "holes" are to subtract everything in the second set
# from the first. So let's at least make sure the "first" thing is the
# biggest path.
paths = self.paths
paths.sort(key=lambda point_list: shgeo.Polygon(
point_list).area, reverse=True)
paths.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True)
# Very small holes will cause a shape to be rendered as an outline only
# they are too small to be rendered and only confuse the auto_fill algorithm.
# So let's ignore them
if shgeo.Polygon(paths[0]).area > 5 and shgeo.Polygon(paths[-1]).area < 5:
paths = [path for path in paths if shgeo.Polygon(path).area > 3]
polygon = shgeo.MultiPolygon([(paths[0], paths[1:])])
return shgeo.MultiPolygon([(paths[0], paths[1:])])
# There is a great number of "crossing border" errors on fill shapes
# If the polygon fails, we can try to run buffer(0) on the polygon in the
# hope it will fix at least some of them
if not self.shape_is_valid(polygon):
why = explain_validity(polygon)
message = re.match(r".+?(?=\[)", why)
if message.group(0) == "Self-intersection":
buffered = polygon.buffer(0)
# if we receive a multipolygon, only use the first one of it
if type(buffered) == shgeo.MultiPolygon:
buffered = buffered[0]
# we do not want to break apart into multiple objects (possibly in the future?!)
# best way to distinguish the resulting polygon is to compare the area size of the two
# and make sure users will not experience significantly altered shapes without a warning
if type(buffered) == shgeo.Polygon and math.isclose(polygon.area, buffered.area, abs_tol=0.5):
polygon = shgeo.MultiPolygon([buffered])
@property
@cache
def shape(self):
shape = self._get_clipped_path()
return polygon
if self.shape_is_valid(shape):
return shape
# Repair not valid shapes
logger = logging.getLogger('shapely.geos')
level = logger.level
logger.setLevel(logging.CRITICAL)
valid_shape = make_valid(shape)
logger.setLevel(level)
polygons = []
for polygon in valid_shape.geoms:
if isinstance(polygon, shgeo.Polygon):
polygons.append(polygon)
if isinstance(polygon, shgeo.MultiPolygon):
polygons.extend(polygon.geoms)
return shgeo.MultiPolygon(polygons)
def _get_clipped_path(self):
if self.node.clip is None:
return self.original_shape
from .element import EmbroideryElement
clip_element = EmbroideryElement(self.node.clip)
clip_element.paths.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True)
polygon = shgeo.MultiPolygon([(clip_element.paths[0], clip_element.paths[1:])])
try:
intersection = polygon.intersection(self.original_shape)
except TopologicalError:
return self.original_shape
if isinstance(intersection, shgeo.Polygon):
return shgeo.MultiPolygon([intersection])
if isinstance(intersection, shgeo.MultiPolygon):
return intersection
polygons = []
if isinstance(intersection, shgeo.GeometryCollection):
for geom in intersection.geoms:
if isinstance(geom, shgeo.Polygon):
polygons.append(geom)
return shgeo.MultiPolygon([polygons])
def shape_is_valid(self, shape):
# Shapely will log to stdout to complain about the shape unless we make
@ -268,23 +308,27 @@ class FillStitch(EmbroideryElement):
if not self.shape_is_valid(self.shape):
why = explain_validity(self.shape)
message, x, y = re.findall(r".+?(?=\[)|-?\d+(?:\.\d+)?", why)
yield InvalidShapeError((x, y))
# I Wish this weren't so brittle...
def validation_warnings(self): # noqa: C901
if not self.shape_is_valid(self.original_shape):
why = explain_validity(self.original_shape)
message, x, y = re.findall(r".+?(?=\[)|-?\d+(?:\.\d+)?", why)
if "Hole lies outside shell" in message:
yield UnconnectedError((x, y))
yield UnconnectedWarning((x, y))
else:
yield InvalidShapeError((x, y))
yield BorderCrossWarning((x, y))
def validation_warnings(self):
if self.shape.area < 20:
label = self.node.get(INKSCAPE_LABEL) or self.node.get("id")
yield SmallShapeWarning(self.shape.centroid, label)
for shape in self.shape.geoms:
if self.shape.area < 20:
label = self.node.get(INKSCAPE_LABEL) or self.node.get("id")
yield SmallShapeWarning(shape.centroid, label)
if self.shrink_or_grow_shape(self.expand, True).is_empty:
yield ExpandWarning(self.shape.centroid)
if self.shrink_or_grow_shape(shape, self.expand, True).is_empty:
yield ExpandWarning(shape.centroid)
if self.shrink_or_grow_shape(-self.fill_underlay_inset, True).is_empty:
yield UnderlayInsetWarning(self.shape.centroid)
if self.shrink_or_grow_shape(shape, -self.fill_underlay_inset, True).is_empty:
yield UnderlayInsetWarning(shape.centroid)
# guided fill warnings
if self.fill_method == 2:
@ -432,26 +476,22 @@ class FillStitch(EmbroideryElement):
def underlay_underpath(self):
return self.get_boolean_param('underlay_underpath', True)
def shrink_or_grow_shape(self, amount, validate=False):
def shrink_or_grow_shape(self, shape, amount, validate=False):
if amount:
shape = self.shape.buffer(amount)
shape = shape.buffer(amount)
# changing the size can empty the shape
# in this case we want to use the original shape rather than returning an error
if shape.is_empty and not validate:
return self.shape
return shape
if not isinstance(shape, shgeo.MultiPolygon):
shape = shgeo.MultiPolygon([shape])
return shape
else:
return self.shape
return shape
@property
def underlay_shape(self):
return self.shrink_or_grow_shape(-self.fill_underlay_inset)
def underlay_shape(self, shape):
return self.shrink_or_grow_shape(shape, -self.fill_underlay_inset)
@property
def fill_shape(self):
return self.shrink_or_grow_shape(self.expand)
def fill_shape(self, shape):
return self.shrink_or_grow_shape(shape, self.expand)
def get_starting_point(self, last_patch):
# If there is a "fill_start" Command, then use that; otherwise pick
@ -479,18 +519,20 @@ class FillStitch(EmbroideryElement):
start = self.get_starting_point(last_patch)
end = self.get_ending_point()
try:
if self.fill_underlay:
underlay_stitch_groups, start = self.do_underlay(start)
stitch_groups.extend(underlay_stitch_groups)
if self.fill_method == 0:
stitch_groups.extend(self.do_auto_fill(last_patch, start, end))
if self.fill_method == 1:
stitch_groups.extend(self.do_contour_fill(last_patch, start))
elif self.fill_method == 2:
stitch_groups.extend(self.do_guided_fill(last_patch, start, end))
except Exception:
self.fatal_fill_error()
for shape in self.shape.geoms:
try:
if self.fill_underlay:
underlay_stitch_groups, start = self.do_underlay(shape, start)
stitch_groups.extend(underlay_stitch_groups)
if self.fill_method == 0:
stitch_groups.extend(self.do_auto_fill(shape, last_patch, start, end))
if self.fill_method == 1:
stitch_groups.extend(self.do_contour_fill(self.fill_shape(shape), last_patch, start))
elif self.fill_method == 2:
stitch_groups.extend(self.do_guided_fill(shape, last_patch, start, end))
except Exception:
self.fatal_fill_error()
last_patch = stitch_groups[-1]
return stitch_groups
@ -505,14 +547,14 @@ class FillStitch(EmbroideryElement):
self.skip_last)
return [StitchGroup(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists]
def do_underlay(self, starting_point):
def do_underlay(self, shape, starting_point):
stitch_groups = []
for i in range(len(self.fill_underlay_angle)):
underlay = StitchGroup(
color=self.color,
tags=("auto_fill", "auto_fill_underlay"),
stitches=auto_fill(
self.underlay_shape,
self.underlay_shape(shape),
self.fill_underlay_angle[i],
self.fill_underlay_row_spacing,
self.fill_underlay_row_spacing,
@ -527,12 +569,12 @@ class FillStitch(EmbroideryElement):
starting_point = underlay.stitches[-1]
return [stitch_groups, starting_point]
def do_auto_fill(self, last_patch, starting_point, ending_point):
def do_auto_fill(self, shape, last_patch, starting_point, ending_point):
stitch_group = StitchGroup(
color=self.color,
tags=("auto_fill", "auto_fill_top"),
stitches=auto_fill(
self.fill_shape,
self.fill_shape(shape),
self.angle,
self.row_spacing,
self.end_row_spacing,
@ -545,46 +587,45 @@ class FillStitch(EmbroideryElement):
self.underpath))
return [stitch_group]
def do_contour_fill(self, last_patch, starting_point):
def do_contour_fill(self, polygon, last_patch, starting_point):
if not starting_point:
starting_point = (0, 0)
starting_point = shgeo.Point(starting_point)
stitch_groups = []
for polygon in self.fill_shape.geoms:
tree = contour_fill.offset_polygon(polygon, self.row_spacing, self.join_style + 1, self.clockwise)
tree = contour_fill.offset_polygon(polygon, self.row_spacing, self.join_style + 1, self.clockwise)
stitches = []
if self.contour_strategy == 0:
stitches = contour_fill.inner_to_outer(
tree,
self.row_spacing,
self.max_stitch_length,
starting_point,
self.avoid_self_crossing
)
elif self.contour_strategy == 1:
stitches = contour_fill.single_spiral(
tree,
self.max_stitch_length,
starting_point
)
elif self.contour_strategy == 2:
stitches = contour_fill.double_spiral(
tree,
self.max_stitch_length,
starting_point
)
stitches = []
if self.contour_strategy == 0:
stitches = contour_fill.inner_to_outer(
tree,
self.row_spacing,
self.max_stitch_length,
starting_point,
self.avoid_self_crossing
)
elif self.contour_strategy == 1:
stitches = contour_fill.single_spiral(
tree,
self.max_stitch_length,
starting_point
)
elif self.contour_strategy == 2:
stitches = contour_fill.double_spiral(
tree,
self.max_stitch_length,
starting_point
)
stitch_group = StitchGroup(
color=self.color,
tags=("auto_fill", "auto_fill_top"),
stitches=stitches)
stitch_groups.append(stitch_group)
stitch_group = StitchGroup(
color=self.color,
tags=("auto_fill", "auto_fill_top"),
stitches=stitches)
stitch_groups.append(stitch_group)
return stitch_groups
def do_guided_fill(self, last_patch, starting_point, ending_point):
def do_guided_fill(self, shape, last_patch, starting_point, ending_point):
guide_line = self._get_guide_lines()
# No guide line: fallback to normal autofill
@ -595,7 +636,7 @@ class FillStitch(EmbroideryElement):
color=self.color,
tags=("guided_fill", "auto_fill_top"),
stitches=guided_fill(
self.fill_shape,
self.fill_shape(shape),
guide_line.geoms[0],
self.angle,
self.row_spacing,

Wyświetl plik

@ -42,6 +42,7 @@ def set_marker(node, position, marker):
def get_marker_elements(node, marker, get_fills=True, get_strokes=True, get_satins=False):
from .elements import EmbroideryElement
from .elements.fill_stitch import FillStitch
from .elements.satin_column import SatinColumn
from .elements.stroke import Stroke
@ -61,10 +62,8 @@ def get_marker_elements(node, marker, get_fills=True, get_strokes=True, get_sati
stroke = element.get_style('stroke')
if get_fills and fill is not None:
fill = Stroke(marker).paths
linear_rings = [shgeo.LinearRing(path) for path in fill]
for ring in linear_rings:
fills.append(shgeo.Polygon(ring))
fill = FillStitch(marker).shape
fills.append(fill)
if get_strokes and stroke is not None:
stroke = Stroke(marker).paths