inkstitch/lib/elements/fill.py

197 wiersze
7.2 KiB
Python

import logging
import math
import re
from shapely import geometry as shgeo
from shapely.validation import explain_validity
from ..i18n import _
from ..stitches import legacy_fill
from ..svg import PIXELS_PER_MM
from ..utils import cache
from .element import EmbroideryElement, Patch, param
from .validation import ValidationError
class UnconnectedError(ValidationError):
name = _("Unconnected")
description = _("Fill: This object is made up of unconnected shapes. This is not allowed because "
"Ink/Stitch doesn't know what order to stitch them in. Please break this "
"object up into separate shapes.")
steps_to_solve = [
_('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects'),
]
class InvalidShapeError(ValidationError):
name = _("Border crosses itself")
description = _("Fill: Shape is not valid. This can happen if the border crosses over itself.")
steps_to_solve = [
_('* Extensions > Ink/Stitch > Fill Tools > Break Apart Fill Objects')
]
class Fill(EmbroideryElement):
element_name = _("Fill")
def __init__(self, *args, **kwargs):
super(Fill, self).__init__(*args, **kwargs)
@property
@param('auto_fill',
_('Manually routed fill stitching'),
tooltip=_('AutoFill is the default method for generating fill stitching.'),
type='toggle',
inverse=True,
default=True)
def auto_fill(self):
return self.get_boolean_param('auto_fill', True)
@property
@param('angle',
_('Angle of lines of stitches'),
tooltip=_('The angle increases in a counter-clockwise direction. 0 is horizontal. Negative angles are allowed.'),
unit='deg',
type='float',
default=0)
@cache
def angle(self):
return math.radians(self.get_float_param('angle', 0))
@property
def color(self):
# SVG spec says the default fill is black
return self.get_style("fill", "#000000")
@property
@param(
'skip_last',
_('Skip last stitch in each row'),
tooltip=_('The last stitch in each row is quite close to the first stitch in the next row. '
'Skipping it decreases stitch count and density.'),
type='boolean',
default=False)
def skip_last(self):
return self.get_boolean_param("skip_last", False)
@property
@param(
'flip',
_('Flip fill (start right-to-left)'),
tooltip=_('The flip option can help you with routing your stitch path. '
'When you enable flip, stitching goes from right-to-left instead of left-to-right.'),
type='boolean',
default=False)
def flip(self):
return self.get_boolean_param("flip", False)
@property
@param('row_spacing_mm',
_('Spacing between rows'),
tooltip=_('Distance between rows of stitches.'),
unit='mm',
type='float',
default=0.25)
def row_spacing(self):
return max(self.get_float_param("row_spacing_mm", 0.25), 0.1 * PIXELS_PER_MM)
@property
def end_row_spacing(self):
return self.get_float_param("end_row_spacing_mm")
@property
@param('max_stitch_length_mm',
_('Maximum fill stitch length'),
tooltip=_('The length of each stitch in a row. Shorter stitch may be used at the start or end of a row.'),
unit='mm',
type='float',
default=3.0)
def max_stitch_length(self):
return max(self.get_float_param("max_stitch_length_mm", 3.0), 0.1 * PIXELS_PER_MM)
@property
@param('staggers',
_('Stagger rows this many times before repeating'),
tooltip=_('Setting this dictates how many rows apart the stitches will be before they fall in the same column position.'),
type='int',
default=4)
def staggers(self):
return max(self.get_int_param("staggers", 4), 1)
@property
@cache
def paths(self):
paths = self.flatten(self.parse_path())
# ensure path length
for i, path in enumerate(paths):
if len(path) < 3:
paths[i] = [(path[0][0], path[0][1]), (path[0][0]+1.0, path[0][1]), (path[0][0], path[0][1]+1.0)]
return paths
@property
@cache
def 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)
# 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:])])
# 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)
# 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 math.isclose(polygon.area, buffered.area):
polygon = shgeo.MultiPolygon([buffered])
return polygon
def shape_is_valid(self, shape):
# Shapely will log to stdout to complain about the shape unless we make
# it shut up.
logger = logging.getLogger('shapely.geos')
level = logger.level
logger.setLevel(logging.CRITICAL)
valid = shape.is_valid
logger.setLevel(level)
return valid
def validation_errors(self):
if not self.shape_is_valid(self.shape):
why = explain_validity(self.shape)
message, x, y = re.findall(r".+?(?=\[)|-?\d+(?:\.\d+)?", why)
# I Wish this weren't so brittle...
if "Hole lies outside shell" in message:
yield UnconnectedError((x, y))
else:
yield InvalidShapeError((x, y))
def to_patches(self, last_patch):
stitch_lists = legacy_fill(self.shape,
self.angle,
self.row_spacing,
self.end_row_spacing,
self.max_stitch_length,
self.flip,
self.staggers,
self.skip_last)
return [Patch(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists]