kopia lustrzana https://github.com/inkstitch/inkstitch
commit
8ab4abf190
|
@ -143,7 +143,7 @@ jobs:
|
|||
submodules: recursive
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.9.x'
|
||||
python-version: '3.8.x'
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '16.x'
|
||||
|
|
2
Makefile
2
Makefile
|
@ -49,4 +49,4 @@ version:
|
|||
|
||||
.PHONY: style
|
||||
style:
|
||||
flake8 . --count --max-complexity=10 --max-line-length=150 --statistics --exclude=pyembroidery,__init__.py,electron,build,src,dist
|
||||
bash -x bin/style-check
|
||||
|
|
|
@ -118,10 +118,11 @@ if [ "$BUILD" = "linux" ]; then
|
|||
%_signature gpg
|
||||
EOF
|
||||
|
||||
deb_version="$(sed -E 's/[^a-zA-Z0-9.+]/./g' <<< "$VERSION")"
|
||||
fpm -s dir \
|
||||
-t deb \
|
||||
-n inkstitch \
|
||||
-v "$VERSION" \
|
||||
-v "$deb_version" \
|
||||
-d "inkscape >= 1.0.0" \
|
||||
--license "GPL-3.0" \
|
||||
--description "An open-source machine embroidery design platform based on Inkscape" \
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
# copy (DO NOT SYMLINK) this file to .git/hooks/pre-commit
|
||||
# to check style on all modified files before allowing the commit to complete
|
||||
#
|
||||
# DO NOT SYMLINK
|
||||
# DO NOT SYMLINK
|
||||
# DO NOT SYMLINK (why? security risk)
|
||||
|
||||
cd $(dirname "$0")/../..
|
||||
|
||||
errors=$(git diff --cached | bin/style-check --diff 2>&1)
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "$errors"
|
||||
exit 1
|
||||
fi
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Checks Python coding style based on our project's preferences. Checks the
|
||||
# files passed on the command-line or everything if no files are passed.
|
||||
# Instead of files, "--diff" may be passed to check only the lines changed
|
||||
# by a diff piped to standard input.
|
||||
|
||||
flake8 --count --max-complexity=10 --max-line-length=150 --statistics --exclude=pyembroidery,__init__.py,electron,build,src,dist "${@:-.}"
|
|
@ -3,11 +3,10 @@
|
|||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
from .auto_fill import AutoFill
|
||||
from .clone import Clone
|
||||
from .element import EmbroideryElement
|
||||
from .empty_d_object import EmptyDObject
|
||||
from .fill import Fill
|
||||
from .fill_stitch import FillStitch
|
||||
from .image import ImageObject
|
||||
from .polyline import Polyline
|
||||
from .satin_column import SatinColumn
|
||||
|
|
|
@ -1,291 +0,0 @@
|
|||
# Authors: see git history
|
||||
#
|
||||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
import math
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from shapely import geometry as shgeo
|
||||
|
||||
from .element import param
|
||||
from .fill import Fill
|
||||
from .validation import ValidationWarning
|
||||
from ..i18n import _
|
||||
from ..stitch_plan import StitchGroup
|
||||
from ..stitches import auto_fill
|
||||
from ..svg.tags import INKSCAPE_LABEL
|
||||
from ..utils import cache, version
|
||||
|
||||
|
||||
class SmallShapeWarning(ValidationWarning):
|
||||
name = _("Small Fill")
|
||||
description = _("This fill object is so small that it would probably look better as running stitch or satin column. "
|
||||
"For very small shapes, fill stitch is not possible, and Ink/Stitch will use running stitch around "
|
||||
"the outline instead.")
|
||||
|
||||
|
||||
class ExpandWarning(ValidationWarning):
|
||||
name = _("Expand")
|
||||
description = _("The expand parameter for this fill object cannot be applied. "
|
||||
"Ink/Stitch will ignore it and will use original size instead.")
|
||||
|
||||
|
||||
class UnderlayInsetWarning(ValidationWarning):
|
||||
name = _("Inset")
|
||||
description = _("The underlay inset parameter for this fill object cannot be applied. "
|
||||
"Ink/Stitch will ignore it and will use the original size instead.")
|
||||
|
||||
|
||||
class AutoFill(Fill):
|
||||
element_name = _("AutoFill")
|
||||
|
||||
@property
|
||||
@param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True)
|
||||
def auto_fill(self):
|
||||
return self.get_boolean_param('auto_fill', True)
|
||||
|
||||
@property
|
||||
@cache
|
||||
def outline(self):
|
||||
return self.shape.boundary[0]
|
||||
|
||||
@property
|
||||
@cache
|
||||
def outline_length(self):
|
||||
return self.outline.length
|
||||
|
||||
@property
|
||||
def flip(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
@param('running_stitch_length_mm',
|
||||
_('Running stitch length (traversal between sections)'),
|
||||
tooltip=_('Length of stitches around the outline of the fill region used when moving from section to section.'),
|
||||
unit='mm',
|
||||
type='float',
|
||||
default=1.5)
|
||||
def running_stitch_length(self):
|
||||
return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
|
||||
|
||||
@property
|
||||
@param('fill_underlay', _('Underlay'), type='toggle', group=_('AutoFill Underlay'), default=True)
|
||||
def fill_underlay(self):
|
||||
return self.get_boolean_param("fill_underlay", default=True)
|
||||
|
||||
@property
|
||||
@param('fill_underlay_angle',
|
||||
_('Fill angle'),
|
||||
tooltip=_('Default: fill angle + 90 deg. Insert comma-seperated list for multiple layers.'),
|
||||
unit='deg',
|
||||
group=_('AutoFill Underlay'),
|
||||
type='float')
|
||||
@cache
|
||||
def fill_underlay_angle(self):
|
||||
underlay_angles = self.get_param('fill_underlay_angle', None)
|
||||
default_value = [self.angle + math.pi / 2.0]
|
||||
if underlay_angles is not None:
|
||||
underlay_angles = underlay_angles.strip().split(',')
|
||||
try:
|
||||
underlay_angles = [math.radians(float(angle)) for angle in underlay_angles]
|
||||
except (TypeError, ValueError):
|
||||
return default_value
|
||||
else:
|
||||
underlay_angles = default_value
|
||||
|
||||
return underlay_angles
|
||||
|
||||
@property
|
||||
@param('fill_underlay_row_spacing_mm',
|
||||
_('Row spacing'),
|
||||
tooltip=_('default: 3x fill row spacing'),
|
||||
unit='mm',
|
||||
group=_('AutoFill Underlay'),
|
||||
type='float')
|
||||
@cache
|
||||
def fill_underlay_row_spacing(self):
|
||||
return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3
|
||||
|
||||
@property
|
||||
@param('fill_underlay_max_stitch_length_mm',
|
||||
_('Max stitch length'),
|
||||
tooltip=_('default: equal to fill max stitch length'),
|
||||
unit='mm',
|
||||
group=_('AutoFill Underlay'), type='float')
|
||||
@cache
|
||||
def fill_underlay_max_stitch_length(self):
|
||||
return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length
|
||||
|
||||
@property
|
||||
@param('fill_underlay_inset_mm',
|
||||
_('Inset'),
|
||||
tooltip=_('Shrink the shape before doing underlay, to prevent underlay from showing around the outside of the fill.'),
|
||||
unit='mm',
|
||||
group=_('AutoFill Underlay'),
|
||||
type='float',
|
||||
default=0)
|
||||
def fill_underlay_inset(self):
|
||||
return self.get_float_param('fill_underlay_inset_mm', 0)
|
||||
|
||||
@property
|
||||
@param(
|
||||
'fill_underlay_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.'),
|
||||
group=_('AutoFill Underlay'),
|
||||
type='boolean',
|
||||
default=False)
|
||||
def fill_underlay_skip_last(self):
|
||||
return self.get_boolean_param("fill_underlay_skip_last", False)
|
||||
|
||||
@property
|
||||
@param('expand_mm',
|
||||
_('Expand'),
|
||||
tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes.'),
|
||||
unit='mm',
|
||||
type='float',
|
||||
default=0)
|
||||
def expand(self):
|
||||
return self.get_float_param('expand_mm', 0)
|
||||
|
||||
@property
|
||||
@param('underpath',
|
||||
_('Underpath'),
|
||||
tooltip=_('Travel inside the shape when moving from section to section. Underpath '
|
||||
'stitches avoid traveling in the direction of the row angle so that they '
|
||||
'are not visible. This gives them a jagged appearance.'),
|
||||
type='boolean',
|
||||
default=True)
|
||||
def underpath(self):
|
||||
return self.get_boolean_param('underpath', True)
|
||||
|
||||
@property
|
||||
@param(
|
||||
'underlay_underpath',
|
||||
_('Underpath'),
|
||||
tooltip=_('Travel inside the shape when moving from section to section. Underpath '
|
||||
'stitches avoid traveling in the direction of the row angle so that they '
|
||||
'are not visible. This gives them a jagged appearance.'),
|
||||
group=_('AutoFill Underlay'),
|
||||
type='boolean',
|
||||
default=True)
|
||||
def underlay_underpath(self):
|
||||
return self.get_boolean_param('underlay_underpath', True)
|
||||
|
||||
def shrink_or_grow_shape(self, amount, validate=False):
|
||||
if amount:
|
||||
shape = self.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
|
||||
if not isinstance(shape, shgeo.MultiPolygon):
|
||||
shape = shgeo.MultiPolygon([shape])
|
||||
return shape
|
||||
else:
|
||||
return self.shape
|
||||
|
||||
@property
|
||||
def underlay_shape(self):
|
||||
return self.shrink_or_grow_shape(-self.fill_underlay_inset)
|
||||
|
||||
@property
|
||||
def fill_shape(self):
|
||||
return self.shrink_or_grow_shape(self.expand)
|
||||
|
||||
def get_starting_point(self, last_patch):
|
||||
# If there is a "fill_start" Command, then use that; otherwise pick
|
||||
# the point closest to the end of the last patch.
|
||||
|
||||
if self.get_command('fill_start'):
|
||||
return self.get_command('fill_start').target_point
|
||||
elif last_patch:
|
||||
return last_patch.stitches[-1]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_ending_point(self):
|
||||
if self.get_command('fill_end'):
|
||||
return self.get_command('fill_end').target_point
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_stitch_groups(self, last_patch):
|
||||
stitch_groups = []
|
||||
|
||||
starting_point = self.get_starting_point(last_patch)
|
||||
ending_point = self.get_ending_point()
|
||||
|
||||
try:
|
||||
if self.fill_underlay:
|
||||
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.fill_underlay_angle[i],
|
||||
self.fill_underlay_row_spacing,
|
||||
self.fill_underlay_row_spacing,
|
||||
self.fill_underlay_max_stitch_length,
|
||||
self.running_stitch_length,
|
||||
self.staggers,
|
||||
self.fill_underlay_skip_last,
|
||||
starting_point,
|
||||
underpath=self.underlay_underpath))
|
||||
stitch_groups.append(underlay)
|
||||
|
||||
starting_point = underlay.stitches[-1]
|
||||
|
||||
stitch_group = StitchGroup(
|
||||
color=self.color,
|
||||
tags=("auto_fill", "auto_fill_top"),
|
||||
stitches=auto_fill(
|
||||
self.fill_shape,
|
||||
self.angle,
|
||||
self.row_spacing,
|
||||
self.end_row_spacing,
|
||||
self.max_stitch_length,
|
||||
self.running_stitch_length,
|
||||
self.staggers,
|
||||
self.skip_last,
|
||||
starting_point,
|
||||
ending_point,
|
||||
self.underpath))
|
||||
stitch_groups.append(stitch_group)
|
||||
except Exception:
|
||||
if hasattr(sys, 'gettrace') and sys.gettrace():
|
||||
# if we're debugging, let the exception bubble up
|
||||
raise
|
||||
|
||||
# for an uncaught exception, give a little more info so that they can create a bug report
|
||||
message = ""
|
||||
message += _("Error during autofill! This means that there is a problem with Ink/Stitch.")
|
||||
message += "\n\n"
|
||||
# L10N this message is followed by a URL: https://github.com/inkstitch/inkstitch/issues/new
|
||||
message += _("If you'd like to help us make Ink/Stitch better, please paste this whole message into a new issue at: ")
|
||||
message += "https://github.com/inkstitch/inkstitch/issues/new\n\n"
|
||||
message += version.get_inkstitch_version() + "\n\n"
|
||||
message += traceback.format_exc()
|
||||
|
||||
self.fatal(message)
|
||||
|
||||
return stitch_groups
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if self.shrink_or_grow_shape(self.expand, True).is_empty:
|
||||
yield ExpandWarning(self.shape.centroid)
|
||||
|
||||
if self.shrink_or_grow_shape(-self.fill_underlay_inset, True).is_empty:
|
||||
yield UnderlayInsetWarning(self.shape.centroid)
|
||||
|
||||
for warning in super(AutoFill, self).validation_warnings():
|
||||
yield warning
|
|
@ -5,19 +5,14 @@
|
|||
|
||||
from math import atan, degrees
|
||||
|
||||
from ..commands import is_command, is_command_symbol
|
||||
from ..commands import is_command_symbol
|
||||
from ..i18n import _
|
||||
from ..svg.path import get_node_transform
|
||||
from ..svg.svg import find_elements
|
||||
from ..svg.tags import (EMBROIDERABLE_TAGS, INKSTITCH_ATTRIBS,
|
||||
SVG_POLYLINE_TAG, SVG_USE_TAG, XLINK_HREF)
|
||||
from ..svg.tags import (EMBROIDERABLE_TAGS, INKSTITCH_ATTRIBS, SVG_USE_TAG,
|
||||
XLINK_HREF)
|
||||
from ..utils import cache
|
||||
from .auto_fill import AutoFill
|
||||
from .element import EmbroideryElement, param
|
||||
from .fill import Fill
|
||||
from .polyline import Polyline
|
||||
from .satin_column import SatinColumn
|
||||
from .stroke import Stroke
|
||||
from .validation import ObjectTypeWarning, ValidationWarning
|
||||
|
||||
|
||||
|
@ -68,28 +63,8 @@ class Clone(EmbroideryElement):
|
|||
return self.get_float_param('angle', 0)
|
||||
|
||||
def clone_to_element(self, node):
|
||||
# we need to determine if the source element is polyline, stroke, fill or satin
|
||||
element = EmbroideryElement(node)
|
||||
|
||||
if node.tag == SVG_POLYLINE_TAG:
|
||||
return [Polyline(node)]
|
||||
|
||||
elif element.get_boolean_param("satin_column") and self.get_clone_style("stroke", self.node):
|
||||
return [SatinColumn(node)]
|
||||
else:
|
||||
elements = []
|
||||
if element.get_style("fill", "black") and not element.get_style("stroke", 1) == "0":
|
||||
if element.get_boolean_param("auto_fill", True):
|
||||
elements.append(AutoFill(node))
|
||||
else:
|
||||
elements.append(Fill(node))
|
||||
if element.get_style("stroke", self.node) is not None:
|
||||
if not is_command(element.node):
|
||||
elements.append(Stroke(node))
|
||||
if element.get_boolean_param("stroke_first", False):
|
||||
elements.reverse()
|
||||
|
||||
return elements
|
||||
from .utils import node_to_elements
|
||||
return node_to_elements(node, True)
|
||||
|
||||
def to_stitch_groups(self, last_patch=None):
|
||||
patches = []
|
||||
|
|
|
@ -20,7 +20,7 @@ from ..utils import Point, cache
|
|||
|
||||
class Param(object):
|
||||
def __init__(self, name, description, unit=None, values=[], type=None, group=None, inverse=False,
|
||||
options=[], default=None, tooltip=None, sort_index=0):
|
||||
options=[], default=None, tooltip=None, sort_index=0, select_items=None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.unit = unit
|
||||
|
@ -32,6 +32,7 @@ class Param(object):
|
|||
self.default = default
|
||||
self.tooltip = tooltip
|
||||
self.sort_index = sort_index
|
||||
self.select_items = select_items
|
||||
|
||||
def __repr__(self):
|
||||
return "Param(%s)" % vars(self)
|
||||
|
@ -86,8 +87,11 @@ class EmbroideryElement(object):
|
|||
return params
|
||||
|
||||
def replace_legacy_param(self, param):
|
||||
value = self.node.get(param, "").strip()
|
||||
self.set_param(param[10:], value)
|
||||
# remove "embroider_" prefix
|
||||
new_param = param[10:]
|
||||
if new_param in INKSTITCH_ATTRIBS:
|
||||
value = self.node.get(param, "").strip()
|
||||
self.set_param(param[10:], value)
|
||||
del self.node.attrib[param]
|
||||
|
||||
@cache
|
||||
|
@ -202,7 +206,7 @@ class EmbroideryElement(object):
|
|||
# L10N options to allow lock stitch before and after objects
|
||||
options=[_("Both"), _("Before"), _("After"), _("Neither")],
|
||||
default=0,
|
||||
sort_index=4)
|
||||
sort_index=10)
|
||||
@cache
|
||||
def ties(self):
|
||||
return self.get_int_param("ties", 0)
|
||||
|
@ -214,7 +218,7 @@ class EmbroideryElement(object):
|
|||
'even if the distance to the next object is shorter than defined by the collapse length value in the Ink/Stitch preferences.'),
|
||||
type='boolean',
|
||||
default=False,
|
||||
sort_index=5)
|
||||
sort_index=10)
|
||||
@cache
|
||||
def force_lock_stitches(self):
|
||||
return self.get_boolean_param('force_lock_stitches', False)
|
||||
|
@ -262,6 +266,11 @@ class EmbroideryElement(object):
|
|||
def parse_path(self):
|
||||
return apply_transforms(self.path, self.node)
|
||||
|
||||
@property
|
||||
@cache
|
||||
def paths(self):
|
||||
return self.flatten(self.parse_path())
|
||||
|
||||
@property
|
||||
def shape(self):
|
||||
raise NotImplementedError("INTERNAL ERROR: %s must implement shape()", self.__class__)
|
||||
|
|
|
@ -1,205 +0,0 @@
|
|||
# Authors: see git history
|
||||
#
|
||||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
from shapely import geometry as shgeo
|
||||
from shapely.validation import explain_validity
|
||||
|
||||
from .element import EmbroideryElement, param
|
||||
from .validation import ValidationError
|
||||
from ..i18n import _
|
||||
from ..stitch_plan import StitchGroup
|
||||
from ..stitches import legacy_fill
|
||||
from ..svg import PIXELS_PER_MM
|
||||
from ..utils import cache
|
||||
|
||||
|
||||
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)
|
||||
# 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])
|
||||
|
||||
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_stitch_groups(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 [StitchGroup(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists]
|
|
@ -0,0 +1,637 @@
|
|||
# Authors: see git history
|
||||
#
|
||||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from shapely import geometry as shgeo
|
||||
from shapely.validation import explain_validity
|
||||
|
||||
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 ..svg import PIXELS_PER_MM
|
||||
from ..svg.tags import INKSCAPE_LABEL
|
||||
from ..utils import cache, version
|
||||
from .element import EmbroideryElement, param
|
||||
from .validation import ValidationError, ValidationWarning
|
||||
|
||||
|
||||
class SmallShapeWarning(ValidationWarning):
|
||||
name = _("Small Fill")
|
||||
description = _("This fill object is so small that it would probably look better as running stitch or satin column. "
|
||||
"For very small shapes, fill stitch is not possible, and Ink/Stitch will use running stitch around "
|
||||
"the outline instead.")
|
||||
|
||||
|
||||
class ExpandWarning(ValidationWarning):
|
||||
name = _("Expand")
|
||||
description = _("The expand parameter for this fill object cannot be applied. "
|
||||
"Ink/Stitch will ignore it and will use original size instead.")
|
||||
|
||||
|
||||
class UnderlayInsetWarning(ValidationWarning):
|
||||
name = _("Inset")
|
||||
description = _("The underlay inset parameter for this fill object cannot be applied. "
|
||||
"Ink/Stitch will ignore it and will use the original size instead.")
|
||||
|
||||
|
||||
class MissingGuideLineWarning(ValidationWarning):
|
||||
name = _("Missing Guideline")
|
||||
description = _('This object is set to "Guided Fill", but has no guide line.')
|
||||
steps_to_solve = [
|
||||
_('* Create a stroke object'),
|
||||
_('* Select this object and run Extensions > Ink/Stitch > Edit > Selection to guide line')
|
||||
]
|
||||
|
||||
|
||||
class DisjointGuideLineWarning(ValidationWarning):
|
||||
name = _("Disjointed Guide Line")
|
||||
description = _("The guide line of this object isn't within the object borders. "
|
||||
"The guide line works best, if it is within the target element.")
|
||||
steps_to_solve = [
|
||||
_('* Move the guide line into the element')
|
||||
]
|
||||
|
||||
|
||||
class MultipleGuideLineWarning(ValidationWarning):
|
||||
name = _("Multiple Guide Lines")
|
||||
description = _("This object has multiple guide lines, but only the first one will be used.")
|
||||
steps_to_solve = [
|
||||
_("* Remove all guide lines, except for one.")
|
||||
]
|
||||
|
||||
|
||||
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 FillStitch(EmbroideryElement):
|
||||
element_name = _("FillStitch")
|
||||
|
||||
@property
|
||||
@param('auto_fill', _('Automatically routed fill stitching'), type='toggle', default=True, sort_index=1)
|
||||
def auto_fill(self):
|
||||
return self.get_boolean_param('auto_fill', True)
|
||||
|
||||
@property
|
||||
@param('fill_method', _('Fill method'), type='dropdown', default=0,
|
||||
options=[_("Auto Fill"), _("Contour Fill"), _("Guided Fill"), _("Legacy Fill")], sort_index=2)
|
||||
def fill_method(self):
|
||||
return self.get_int_param('fill_method', 0)
|
||||
|
||||
@property
|
||||
@param('contour_strategy', _('Contour Fill Strategy'), type='dropdown', default=0,
|
||||
options=[_("Inner to Outer"), _("Single spiral"), _("Double spiral")], select_items=[('fill_method', 1)], sort_index=3)
|
||||
def contour_strategy(self):
|
||||
return self.get_int_param('contour_strategy', 0)
|
||||
|
||||
@property
|
||||
@param('join_style', _('Join Style'), type='dropdown', default=0,
|
||||
options=[_("Round"), _("Mitered"), _("Beveled")], select_items=[('fill_method', 1)], sort_index=4)
|
||||
def join_style(self):
|
||||
return self.get_int_param('join_style', 0)
|
||||
|
||||
@property
|
||||
@param('avoid_self_crossing', _('Avoid self-crossing'), type='boolean', default=False, select_items=[('fill_method', 1)], sort_index=5)
|
||||
def avoid_self_crossing(self):
|
||||
return self.get_boolean_param('avoid_self_crossing', False)
|
||||
|
||||
@property
|
||||
@param('clockwise', _('Clockwise'), type='boolean', default=True, select_items=[('fill_method', 1)], sort_index=5)
|
||||
def clockwise(self):
|
||||
return self.get_boolean_param('clockwise', 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',
|
||||
sort_index=6,
|
||||
select_items=[('fill_method', 0), ('fill_method', 3)],
|
||||
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',
|
||||
sort_index=6,
|
||||
select_items=[('fill_method', 0), ('fill_method', 2),
|
||||
('fill_method', 3)],
|
||||
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',
|
||||
sort_index=7,
|
||||
select_items=[('fill_method', 3)],
|
||||
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',
|
||||
sort_index=6,
|
||||
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',
|
||||
sort_index=6,
|
||||
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',
|
||||
sort_index=6,
|
||||
select_items=[('fill_method', 0), ('fill_method', 3)],
|
||||
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)
|
||||
# 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])
|
||||
|
||||
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 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)
|
||||
|
||||
if self.shrink_or_grow_shape(self.expand, True).is_empty:
|
||||
yield ExpandWarning(self.shape.centroid)
|
||||
|
||||
if self.shrink_or_grow_shape(-self.fill_underlay_inset, True).is_empty:
|
||||
yield UnderlayInsetWarning(self.shape.centroid)
|
||||
|
||||
# guided fill warnings
|
||||
if self.fill_method == 2:
|
||||
guide_lines = self._get_guide_lines(True)
|
||||
if not guide_lines or guide_lines[0].is_empty:
|
||||
yield MissingGuideLineWarning(self.shape.centroid)
|
||||
elif len(guide_lines) > 1:
|
||||
yield MultipleGuideLineWarning(self.shape.centroid)
|
||||
elif guide_lines[0].disjoint(self.shape):
|
||||
yield DisjointGuideLineWarning(self.shape.centroid)
|
||||
return None
|
||||
|
||||
for warning in super(FillStitch, self).validation_warnings():
|
||||
yield warning
|
||||
|
||||
@property
|
||||
@cache
|
||||
def outline(self):
|
||||
return self.shape.boundary[0]
|
||||
|
||||
@property
|
||||
@cache
|
||||
def outline_length(self):
|
||||
return self.outline.length
|
||||
|
||||
@property
|
||||
@param('running_stitch_length_mm',
|
||||
_('Running stitch length (traversal between sections)'),
|
||||
tooltip=_('Length of stitches around the outline of the fill region used when moving from section to section.'),
|
||||
unit='mm',
|
||||
type='float',
|
||||
default=1.5,
|
||||
select_items=[('fill_method', 0), ('fill_method', 2)],
|
||||
sort_index=6)
|
||||
def running_stitch_length(self):
|
||||
return max(self.get_float_param("running_stitch_length_mm", 1.5), 0.01)
|
||||
|
||||
@property
|
||||
@param('fill_underlay', _('Underlay'), type='toggle', group=_('Fill Underlay'), default=True)
|
||||
def fill_underlay(self):
|
||||
return self.get_boolean_param("fill_underlay", default=True)
|
||||
|
||||
@property
|
||||
@param('fill_underlay_angle',
|
||||
_('Fill angle'),
|
||||
tooltip=_('Default: fill angle + 90 deg. Insert comma-seperated list for multiple layers.'),
|
||||
unit='deg',
|
||||
group=_('Fill Underlay'),
|
||||
type='float')
|
||||
@cache
|
||||
def fill_underlay_angle(self):
|
||||
underlay_angles = self.get_param('fill_underlay_angle', None)
|
||||
default_value = [self.angle + math.pi / 2.0]
|
||||
if underlay_angles is not None:
|
||||
underlay_angles = underlay_angles.strip().split(',')
|
||||
try:
|
||||
underlay_angles = [math.radians(
|
||||
float(angle)) for angle in underlay_angles]
|
||||
except (TypeError, ValueError):
|
||||
return default_value
|
||||
else:
|
||||
underlay_angles = default_value
|
||||
|
||||
return underlay_angles
|
||||
|
||||
@property
|
||||
@param('fill_underlay_row_spacing_mm',
|
||||
_('Row spacing'),
|
||||
tooltip=_('default: 3x fill row spacing'),
|
||||
unit='mm',
|
||||
group=_('Fill Underlay'),
|
||||
type='float')
|
||||
@cache
|
||||
def fill_underlay_row_spacing(self):
|
||||
return self.get_float_param("fill_underlay_row_spacing_mm") or self.row_spacing * 3
|
||||
|
||||
@property
|
||||
@param('fill_underlay_max_stitch_length_mm',
|
||||
_('Max stitch length'),
|
||||
tooltip=_('default: equal to fill max stitch length'),
|
||||
unit='mm',
|
||||
group=_('Fill Underlay'), type='float')
|
||||
@cache
|
||||
def fill_underlay_max_stitch_length(self):
|
||||
return self.get_float_param("fill_underlay_max_stitch_length_mm") or self.max_stitch_length
|
||||
|
||||
@property
|
||||
@param('fill_underlay_inset_mm',
|
||||
_('Inset'),
|
||||
tooltip=_('Shrink the shape before doing underlay, to prevent underlay from showing around the outside of the fill.'),
|
||||
unit='mm',
|
||||
group=_('Fill Underlay'),
|
||||
type='float',
|
||||
default=0)
|
||||
def fill_underlay_inset(self):
|
||||
return self.get_float_param('fill_underlay_inset_mm', 0)
|
||||
|
||||
@property
|
||||
@param(
|
||||
'fill_underlay_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.'),
|
||||
group=_('Fill Underlay'),
|
||||
type='boolean',
|
||||
default=False)
|
||||
def fill_underlay_skip_last(self):
|
||||
return self.get_boolean_param("fill_underlay_skip_last", False)
|
||||
|
||||
@property
|
||||
@param('expand_mm',
|
||||
_('Expand'),
|
||||
tooltip=_('Expand the shape before fill stitching, to compensate for gaps between shapes.'),
|
||||
unit='mm',
|
||||
type='float',
|
||||
default=0,
|
||||
sort_index=5,
|
||||
select_items=[('fill_method', 0), ('fill_method', 2)])
|
||||
def expand(self):
|
||||
return self.get_float_param('expand_mm', 0)
|
||||
|
||||
@property
|
||||
@param('underpath',
|
||||
_('Underpath'),
|
||||
tooltip=_('Travel inside the shape when moving from section to section. Underpath '
|
||||
'stitches avoid traveling in the direction of the row angle so that they '
|
||||
'are not visible. This gives them a jagged appearance.'),
|
||||
type='boolean',
|
||||
default=True,
|
||||
select_items=[('fill_method', 0), ('fill_method', 2)],
|
||||
sort_index=6)
|
||||
def underpath(self):
|
||||
return self.get_boolean_param('underpath', True)
|
||||
|
||||
@property
|
||||
@param(
|
||||
'underlay_underpath',
|
||||
_('Underpath'),
|
||||
tooltip=_('Travel inside the shape when moving from section to section. Underpath '
|
||||
'stitches avoid traveling in the direction of the row angle so that they '
|
||||
'are not visible. This gives them a jagged appearance.'),
|
||||
group=_('Fill Underlay'),
|
||||
type='boolean',
|
||||
default=True)
|
||||
def underlay_underpath(self):
|
||||
return self.get_boolean_param('underlay_underpath', True)
|
||||
|
||||
def shrink_or_grow_shape(self, amount, validate=False):
|
||||
if amount:
|
||||
shape = self.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
|
||||
if not isinstance(shape, shgeo.MultiPolygon):
|
||||
shape = shgeo.MultiPolygon([shape])
|
||||
return shape
|
||||
else:
|
||||
return self.shape
|
||||
|
||||
@property
|
||||
def underlay_shape(self):
|
||||
return self.shrink_or_grow_shape(-self.fill_underlay_inset)
|
||||
|
||||
@property
|
||||
def fill_shape(self):
|
||||
return self.shrink_or_grow_shape(self.expand)
|
||||
|
||||
def get_starting_point(self, last_patch):
|
||||
# If there is a "fill_start" Command, then use that; otherwise pick
|
||||
# the point closest to the end of the last patch.
|
||||
|
||||
if self.get_command('fill_start'):
|
||||
return self.get_command('fill_start').target_point
|
||||
elif last_patch:
|
||||
return last_patch.stitches[-1]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_ending_point(self):
|
||||
if self.get_command('fill_end'):
|
||||
return self.get_command('fill_end').target_point
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_stitch_groups(self, last_patch):
|
||||
# backwards compatibility: legacy_fill used to be inkstitch:auto_fill == False
|
||||
if not self.auto_fill or self.fill_method == 3:
|
||||
return self.do_legacy_fill()
|
||||
else:
|
||||
stitch_groups = []
|
||||
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()
|
||||
|
||||
return stitch_groups
|
||||
|
||||
def do_legacy_fill(self):
|
||||
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 [StitchGroup(stitches=stitch_list, color=self.color) for stitch_list in stitch_lists]
|
||||
|
||||
def do_underlay(self, 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.fill_underlay_angle[i],
|
||||
self.fill_underlay_row_spacing,
|
||||
self.fill_underlay_row_spacing,
|
||||
self.fill_underlay_max_stitch_length,
|
||||
self.running_stitch_length,
|
||||
self.staggers,
|
||||
self.fill_underlay_skip_last,
|
||||
starting_point,
|
||||
underpath=self.underlay_underpath))
|
||||
stitch_groups.append(underlay)
|
||||
|
||||
starting_point = underlay.stitches[-1]
|
||||
return [stitch_groups, starting_point]
|
||||
|
||||
def do_auto_fill(self, 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.angle,
|
||||
self.row_spacing,
|
||||
self.end_row_spacing,
|
||||
self.max_stitch_length,
|
||||
self.running_stitch_length,
|
||||
self.staggers,
|
||||
self.skip_last,
|
||||
starting_point,
|
||||
ending_point,
|
||||
self.underpath))
|
||||
return [stitch_group]
|
||||
|
||||
def do_contour_fill(self, 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)
|
||||
|
||||
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)
|
||||
|
||||
return stitch_groups
|
||||
|
||||
def do_guided_fill(self, last_patch, starting_point, ending_point):
|
||||
guide_line = self._get_guide_lines()
|
||||
|
||||
# No guide line: fallback to normal autofill
|
||||
if not guide_line:
|
||||
return self.do_auto_fill(last_patch, starting_point, ending_point)
|
||||
|
||||
stitch_group = StitchGroup(
|
||||
color=self.color,
|
||||
tags=("guided_fill", "auto_fill_top"),
|
||||
stitches=guided_fill(
|
||||
self.fill_shape,
|
||||
guide_line.geoms[0],
|
||||
self.angle,
|
||||
self.row_spacing,
|
||||
self.max_stitch_length,
|
||||
self.running_stitch_length,
|
||||
self.skip_last,
|
||||
starting_point,
|
||||
ending_point,
|
||||
self.underpath))
|
||||
return [stitch_group]
|
||||
|
||||
@cache
|
||||
def _get_guide_lines(self, multiple=False):
|
||||
guide_lines = get_marker_elements(self.node, "guide-line", False, True)
|
||||
# No or empty guide line
|
||||
if not guide_lines or not guide_lines['stroke']:
|
||||
return None
|
||||
|
||||
if multiple:
|
||||
return guide_lines['stroke']
|
||||
else:
|
||||
return guide_lines['stroke'][0]
|
||||
|
||||
def fatal_fill_error(self):
|
||||
if hasattr(sys, 'gettrace') and sys.gettrace():
|
||||
# if we're debugging, let the exception bubble up
|
||||
raise
|
||||
|
||||
# for an uncaught exception, give a little more info so that they can create a bug report
|
||||
message = ""
|
||||
message += _("Error during autofill! This means that there is a problem with Ink/Stitch.")
|
||||
message += "\n\n"
|
||||
# L10N this message is followed by a URL: https://github.com/inkstitch/inkstitch/issues/new
|
||||
message += _("If you'd like to help us make Ink/Stitch better, please paste this whole message into a new issue at: ")
|
||||
message += "https://github.com/inkstitch/inkstitch/issues/new\n\n"
|
||||
message += version.get_inkstitch_version() + "\n\n"
|
||||
message += traceback.format_exc()
|
||||
|
||||
self.fatal(message)
|
|
@ -168,6 +168,10 @@ class Stroke(EmbroideryElement):
|
|||
for i in range(len(patch) - 1):
|
||||
start = patch.stitches[i]
|
||||
end = patch.stitches[i + 1]
|
||||
# sometimes the stitch results into zero length which cause a division by zero error
|
||||
# ignoring this leads to a slightly bad result, but that is better than no output
|
||||
if (end - start).length() == 0:
|
||||
continue
|
||||
segment_direction = (end - start).unit()
|
||||
zigzag_direction = segment_direction.rotate_left()
|
||||
|
||||
|
|
|
@ -7,11 +7,10 @@ from ..commands import is_command
|
|||
from ..marker import has_marker
|
||||
from ..svg.tags import (EMBROIDERABLE_TAGS, SVG_IMAGE_TAG, SVG_PATH_TAG,
|
||||
SVG_POLYLINE_TAG, SVG_TEXT_TAG)
|
||||
from .auto_fill import AutoFill
|
||||
from .fill_stitch import FillStitch
|
||||
from .clone import Clone, is_clone
|
||||
from .element import EmbroideryElement
|
||||
from .empty_d_object import EmptyDObject
|
||||
from .fill import Fill
|
||||
from .image import ImageObject
|
||||
from .marker import MarkerObject
|
||||
from .polyline import Polyline
|
||||
|
@ -20,11 +19,12 @@ from .stroke import Stroke
|
|||
from .text import TextObject
|
||||
|
||||
|
||||
def node_to_elements(node): # noqa: C901
|
||||
def node_to_elements(node, clone_to_element=False): # noqa: C901
|
||||
if node.tag == SVG_POLYLINE_TAG:
|
||||
return [Polyline(node)]
|
||||
|
||||
elif is_clone(node):
|
||||
elif is_clone(node) and not clone_to_element:
|
||||
# clone_to_element: get an actual embroiderable element once a clone has been defined as a clone
|
||||
return [Clone(node)]
|
||||
|
||||
elif node.tag == SVG_PATH_TAG and not node.get('d', ''):
|
||||
|
@ -33,7 +33,7 @@ def node_to_elements(node): # noqa: C901
|
|||
elif has_marker(node):
|
||||
return [MarkerObject(node)]
|
||||
|
||||
elif node.tag in EMBROIDERABLE_TAGS:
|
||||
elif node.tag in EMBROIDERABLE_TAGS or is_clone(node):
|
||||
element = EmbroideryElement(node)
|
||||
|
||||
if element.get_boolean_param("satin_column") and element.get_style("stroke"):
|
||||
|
@ -41,10 +41,7 @@ def node_to_elements(node): # noqa: C901
|
|||
else:
|
||||
elements = []
|
||||
if element.get_style("fill", "black") and not element.get_style('fill-opacity', 1) == "0":
|
||||
if element.get_boolean_param("auto_fill", True):
|
||||
elements.append(AutoFill(node))
|
||||
else:
|
||||
elements.append(Fill(node))
|
||||
elements.append(FillStitch(node))
|
||||
if element.get_style("stroke"):
|
||||
if not is_command(element.node):
|
||||
elements.append(Stroke(node))
|
||||
|
|
|
@ -40,6 +40,7 @@ from .print_pdf import Print
|
|||
from .remove_embroidery_settings import RemoveEmbroiderySettings
|
||||
from .reorder import Reorder
|
||||
from .selection_to_pattern import SelectionToPattern
|
||||
from .selection_to_guide_line import SelectionToGuideLine
|
||||
from .simulator import Simulator
|
||||
from .stitch_plan_preview import StitchPlanPreview
|
||||
from .zip import Zip
|
||||
|
@ -53,6 +54,7 @@ __all__ = extensions = [StitchPlanPreview,
|
|||
Zip,
|
||||
Flip,
|
||||
SelectionToPattern,
|
||||
SelectionToGuideLine,
|
||||
ObjectCommands,
|
||||
ObjectCommandsToggleVisibility,
|
||||
LayerCommands,
|
||||
|
|
|
@ -8,11 +8,12 @@ import os
|
|||
import re
|
||||
from collections.abc import MutableMapping
|
||||
|
||||
import inkex
|
||||
from lxml import etree
|
||||
from lxml.etree import Comment
|
||||
from stringcase import snakecase
|
||||
|
||||
import inkex
|
||||
|
||||
from ..commands import is_command, layer_commands
|
||||
from ..elements import EmbroideryElement, nodes_to_elements
|
||||
from ..elements.clone import is_clone
|
||||
|
|
|
@ -83,7 +83,7 @@ class BreakApart(InkstitchExtension):
|
|||
if diff.geom_type == 'MultiPolygon':
|
||||
polygons.remove(other)
|
||||
polygons.remove(polygon)
|
||||
for p in diff:
|
||||
for p in diff.geoms:
|
||||
polygons.append(p)
|
||||
# it is possible, that a polygons overlap with multiple
|
||||
# polygons, this means, we need to start all over again
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
from inkex import NSS, Boolean, errormsg
|
||||
|
||||
from ..elements import Fill, Stroke
|
||||
from ..elements import FillStitch, Stroke
|
||||
from ..i18n import _
|
||||
from .base import InkstitchExtension
|
||||
|
||||
|
@ -38,7 +38,7 @@ class Cleanup(InkstitchExtension):
|
|||
return
|
||||
|
||||
for element in self.elements:
|
||||
if (isinstance(element, Fill) and self.rm_fill and element.shape.area < self.fill_threshold):
|
||||
if (isinstance(element, FillStitch) and self.rm_fill and element.shape.area < self.fill_threshold):
|
||||
element.node.getparent().remove(element.node)
|
||||
count += 1
|
||||
if (isinstance(element, Stroke) and self.rm_stroke and
|
||||
|
|
|
@ -9,13 +9,13 @@ import os
|
|||
import sys
|
||||
from collections import defaultdict
|
||||
from copy import copy
|
||||
from itertools import groupby
|
||||
from itertools import groupby, zip_longest
|
||||
|
||||
import wx
|
||||
from wx.lib.scrolledpanel import ScrolledPanel
|
||||
|
||||
from ..commands import is_command, is_command_symbol
|
||||
from ..elements import (AutoFill, Clone, EmbroideryElement, Fill, Polyline,
|
||||
from ..elements import (FillStitch, Clone, EmbroideryElement, Polyline,
|
||||
SatinColumn, Stroke)
|
||||
from ..elements.clone import is_clone
|
||||
from ..gui import PresetsPanel, SimulatorPreview, WarningPanel
|
||||
|
@ -25,6 +25,11 @@ from ..utils import get_resource_dir
|
|||
from .base import InkstitchExtension
|
||||
|
||||
|
||||
def grouper(iterable_obj, count, fillvalue=None):
|
||||
args = [iter(iterable_obj)] * count
|
||||
return zip_longest(*args, fillvalue=fillvalue)
|
||||
|
||||
|
||||
class ParamsTab(ScrolledPanel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.params = kwargs.pop('params', [])
|
||||
|
@ -38,6 +43,8 @@ class ParamsTab(ScrolledPanel):
|
|||
self.dependent_tabs = []
|
||||
self.parent_tab = None
|
||||
self.param_inputs = {}
|
||||
self.choice_widgets = defaultdict(list)
|
||||
self.dict_of_choices = {}
|
||||
self.paired_tab = None
|
||||
self.disable_notify_pair = False
|
||||
|
||||
|
@ -46,14 +53,16 @@ class ParamsTab(ScrolledPanel):
|
|||
if toggles:
|
||||
self.toggle = toggles[0]
|
||||
self.params.remove(self.toggle)
|
||||
self.toggle_checkbox = wx.CheckBox(self, label=self.toggle.description)
|
||||
self.toggle_checkbox = wx.CheckBox(
|
||||
self, label=self.toggle.description)
|
||||
|
||||
value = any(self.toggle.values)
|
||||
if self.toggle.inverse:
|
||||
value = not value
|
||||
self.toggle_checkbox.SetValue(value)
|
||||
|
||||
self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.update_toggle_state)
|
||||
self.toggle_checkbox.Bind(
|
||||
wx.EVT_CHECKBOX, self.update_toggle_state)
|
||||
self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.changed)
|
||||
|
||||
self.param_inputs[self.toggle.name] = self.toggle_checkbox
|
||||
|
@ -66,7 +75,8 @@ class ParamsTab(ScrolledPanel):
|
|||
self.settings_grid.AddGrowableCol(1, 2)
|
||||
self.settings_grid.SetFlexibleDirection(wx.HORIZONTAL)
|
||||
|
||||
self.pencil_icon = wx.Image(os.path.join(get_resource_dir("icons"), "pencil_20x20.png")).ConvertToBitmap()
|
||||
self.pencil_icon = wx.Image(os.path.join(get_resource_dir(
|
||||
"icons"), "pencil_20x20.png")).ConvertToBitmap()
|
||||
|
||||
self.__set_properties()
|
||||
self.__do_layout()
|
||||
|
@ -76,7 +86,6 @@ class ParamsTab(ScrolledPanel):
|
|||
# end wxGlade
|
||||
|
||||
def pair(self, tab):
|
||||
# print self.name, "paired with", tab.name
|
||||
self.paired_tab = tab
|
||||
self.update_description()
|
||||
|
||||
|
@ -98,7 +107,6 @@ class ParamsTab(ScrolledPanel):
|
|||
|
||||
def update_toggle_state(self, event=None, notify_pair=True):
|
||||
enable = self.enabled()
|
||||
# print self.name, "update_toggle_state", enable
|
||||
for child in self.settings_grid.GetChildren():
|
||||
widget = child.GetWindow()
|
||||
if widget:
|
||||
|
@ -113,8 +121,20 @@ class ParamsTab(ScrolledPanel):
|
|||
if event:
|
||||
event.Skip()
|
||||
|
||||
def update_choice_state(self, event=None):
|
||||
input = event.GetEventObject()
|
||||
selection = input.GetSelection()
|
||||
|
||||
param = self.inputs_to_params[input]
|
||||
|
||||
self.update_choice_widgets((param, selection))
|
||||
self.settings_grid.Layout()
|
||||
self.Layout()
|
||||
|
||||
if event:
|
||||
event.Skip()
|
||||
|
||||
def pair_changed(self, value):
|
||||
# print self.name, "pair_changed", value
|
||||
new_value = not value
|
||||
|
||||
if self.enabled() != new_value:
|
||||
|
@ -169,7 +189,6 @@ class ParamsTab(ScrolledPanel):
|
|||
def apply(self):
|
||||
values = self.get_values()
|
||||
for node in self.nodes:
|
||||
# print >> sys.stderr, "apply: ", self.name, node.id, values
|
||||
for name, value in values.items():
|
||||
node.set_param(name, value)
|
||||
|
||||
|
@ -207,19 +226,25 @@ class ParamsTab(ScrolledPanel):
|
|||
if len(self.nodes) == 1:
|
||||
description = _("These settings will be applied to 1 object.")
|
||||
else:
|
||||
description = _("These settings will be applied to %d objects.") % len(self.nodes)
|
||||
description = _(
|
||||
"These settings will be applied to %d objects.") % len(self.nodes)
|
||||
|
||||
if any(len(param.values) > 1 for param in self.params):
|
||||
description += "\n • " + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.")
|
||||
description += "\n • " + \
|
||||
_("Some settings had different values across objects. Select a value from the dropdown or enter a new one.")
|
||||
|
||||
if self.dependent_tabs:
|
||||
if len(self.dependent_tabs) == 1:
|
||||
description += "\n • " + _("Disabling this tab will disable the following %d tabs.") % len(self.dependent_tabs)
|
||||
description += "\n • " + \
|
||||
_("Disabling this tab will disable the following %d tabs.") % len(
|
||||
self.dependent_tabs)
|
||||
else:
|
||||
description += "\n • " + _("Disabling this tab will disable the following tab.")
|
||||
description += "\n • " + \
|
||||
_("Disabling this tab will disable the following tab.")
|
||||
|
||||
if self.paired_tab:
|
||||
description += "\n • " + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name
|
||||
description += "\n • " + \
|
||||
_("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name
|
||||
|
||||
self.description_text = description
|
||||
|
||||
|
@ -245,35 +270,70 @@ class ParamsTab(ScrolledPanel):
|
|||
# end wxGlade
|
||||
pass
|
||||
|
||||
def __do_layout(self):
|
||||
# choice tuple is None or contains ("choice widget param name", "actual selection")
|
||||
def update_choice_widgets(self, choice_tuple=None):
|
||||
if choice_tuple is None: # update all choices
|
||||
for choice in self.dict_of_choices.values():
|
||||
self.update_choice_widgets(
|
||||
(choice["param"].name, choice["widget"].GetSelection()))
|
||||
else:
|
||||
choice = self.dict_of_choices[choice_tuple[0]]
|
||||
last_selection = choice["last_initialized_choice"]
|
||||
current_selection = choice["widget"].GetSelection()
|
||||
if last_selection != -1 and last_selection != current_selection: # Hide the old widgets
|
||||
for widget in self.choice_widgets[(choice["param"].name, last_selection)]:
|
||||
widget.Hide()
|
||||
# self.settings_grid.Detach(widget)
|
||||
|
||||
for widgets in grouper(self.choice_widgets[choice_tuple], 4):
|
||||
widgets[0].Show(True)
|
||||
widgets[1].Show(True)
|
||||
widgets[2].Show(True)
|
||||
widgets[3].Show(True)
|
||||
choice["last_initialized_choice"] = current_selection
|
||||
|
||||
def __do_layout(self, only_settings_grid=False): # noqa: C901
|
||||
|
||||
# just to add space around the settings
|
||||
box = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
summary_box = wx.StaticBox(self, wx.ID_ANY, label=_("Inkscape objects"))
|
||||
summary_box = wx.StaticBox(
|
||||
self, wx.ID_ANY, label=_("Inkscape objects"))
|
||||
sizer = wx.StaticBoxSizer(summary_box, wx.HORIZONTAL)
|
||||
self.description = wx.StaticText(self)
|
||||
self.update_description()
|
||||
self.description.SetLabel(self.description_text)
|
||||
self.description_container = box
|
||||
self.Bind(wx.EVT_SIZE, self.resized)
|
||||
sizer.Add(self.description, proportion=0, flag=wx.EXPAND | wx.ALL, border=5)
|
||||
sizer.Add(self.description, proportion=0,
|
||||
flag=wx.EXPAND | wx.ALL, border=5)
|
||||
box.Add(sizer, proportion=0, flag=wx.ALL, border=5)
|
||||
|
||||
if self.toggle:
|
||||
toggle_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
toggle_sizer.Add(self.create_change_indicator(self.toggle.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border=5)
|
||||
toggle_sizer.Add(self.toggle_checkbox, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
toggle_sizer.Add(self.create_change_indicator(
|
||||
self.toggle.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border=5)
|
||||
toggle_sizer.Add(self.toggle_checkbox, proportion=0,
|
||||
flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
box.Add(toggle_sizer, proportion=0, flag=wx.BOTTOM, border=10)
|
||||
|
||||
for param in self.params:
|
||||
self.settings_grid.Add(self.create_change_indicator(param.name), proportion=0, flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
|
||||
col1 = self.create_change_indicator(param.name)
|
||||
description = wx.StaticText(self, label=param.description)
|
||||
description.SetToolTip(param.tooltip)
|
||||
self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL | wx.TOP, border=5)
|
||||
|
||||
if param.select_items is not None:
|
||||
col1.Hide()
|
||||
description.Hide()
|
||||
for item in param.select_items:
|
||||
self.choice_widgets[item].extend([col1, description])
|
||||
# else:
|
||||
self.settings_grid.Add(
|
||||
col1, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND |
|
||||
wx.RIGHT | wx.ALIGN_CENTER_VERTICAL | wx.TOP, border=5)
|
||||
|
||||
if param.type == 'boolean':
|
||||
|
||||
if len(param.values) > 1:
|
||||
input = wx.CheckBox(self, style=wx.CHK_3STATE)
|
||||
input.Set3StateValue(wx.CHK_UNDETERMINED)
|
||||
|
@ -287,8 +347,12 @@ class ParamsTab(ScrolledPanel):
|
|||
input = wx.Choice(self, wx.ID_ANY, choices=param.options)
|
||||
input.SetSelection(int(param.values[0]))
|
||||
input.Bind(wx.EVT_CHOICE, self.changed)
|
||||
input.Bind(wx.EVT_CHOICE, self.update_choice_state)
|
||||
self.dict_of_choices[param.name] = {
|
||||
"param": param, "widget": input, "last_initialized_choice": 1}
|
||||
elif len(param.values) > 1:
|
||||
input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(str(value) for value in param.values), style=wx.CB_DROPDOWN)
|
||||
input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(
|
||||
str(value) for value in param.values), style=wx.CB_DROPDOWN)
|
||||
input.Bind(wx.EVT_COMBOBOX, self.changed)
|
||||
input.Bind(wx.EVT_TEXT, self.changed)
|
||||
else:
|
||||
|
@ -298,27 +362,42 @@ class ParamsTab(ScrolledPanel):
|
|||
|
||||
self.param_inputs[param.name] = input
|
||||
|
||||
self.settings_grid.Add(input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL | wx.EXPAND | wx.LEFT, border=40)
|
||||
self.settings_grid.Add(wx.StaticText(self, label=param.unit or ""), proportion=1, flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
col4 = wx.StaticText(self, label=param.unit or "")
|
||||
|
||||
if param.select_items is not None:
|
||||
input.Hide()
|
||||
col4.Hide()
|
||||
for item in param.select_items:
|
||||
self.choice_widgets[item].extend([input, col4])
|
||||
# else:
|
||||
self.settings_grid.Add(
|
||||
input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL | wx.EXPAND | wx.LEFT, border=40)
|
||||
self.settings_grid.Add(
|
||||
col4, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL)
|
||||
|
||||
self.inputs_to_params = {v: k for k, v in self.param_inputs.items()}
|
||||
|
||||
box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10)
|
||||
self.SetSizer(box)
|
||||
self.update_choice_widgets()
|
||||
|
||||
self.Layout()
|
||||
|
||||
def create_change_indicator(self, param):
|
||||
indicator = wx.Button(self, style=wx.BORDER_NONE | wx.BU_NOTEXT, size=(28, 28))
|
||||
indicator.SetToolTip(_('Click to force this parameter to be saved when you click "Apply and Quit"'))
|
||||
indicator.Bind(wx.EVT_BUTTON, lambda event: self.enable_change_indicator(param))
|
||||
indicator = wx.Button(self, style=wx.BORDER_NONE |
|
||||
wx.BU_NOTEXT, size=(28, 28))
|
||||
indicator.SetToolTip(
|
||||
_('Click to force this parameter to be saved when you click "Apply and Quit"'))
|
||||
indicator.Bind(
|
||||
wx.EVT_BUTTON, lambda event: self.enable_change_indicator(param))
|
||||
|
||||
self.param_change_indicators[param] = indicator
|
||||
return indicator
|
||||
|
||||
def enable_change_indicator(self, param):
|
||||
self.param_change_indicators[param].SetBitmapLabel(self.pencil_icon)
|
||||
self.param_change_indicators[param].SetToolTip(_('This parameter will be saved when you click "Apply and Quit"'))
|
||||
self.param_change_indicators[param].SetToolTip(
|
||||
_('This parameter will be saved when you click "Apply and Quit"'))
|
||||
|
||||
self.changed_inputs.add(self.param_inputs[param])
|
||||
|
||||
|
@ -344,7 +423,8 @@ class SettingsFrame(wx.Frame):
|
|||
_("Embroidery Params")
|
||||
)
|
||||
|
||||
icon = wx.Icon(os.path.join(get_resource_dir("icons"), "inkstitch256x256.png"))
|
||||
icon = wx.Icon(os.path.join(
|
||||
get_resource_dir("icons"), "inkstitch256x256.png"))
|
||||
self.SetIcon(icon)
|
||||
|
||||
self.notebook = wx.Notebook(self, wx.ID_ANY)
|
||||
|
@ -362,7 +442,8 @@ class SettingsFrame(wx.Frame):
|
|||
self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel)
|
||||
self.Bind(wx.EVT_CLOSE, self.cancel)
|
||||
|
||||
self.use_last_button = wx.Button(self, wx.ID_ANY, _("Use Last Settings"))
|
||||
self.use_last_button = wx.Button(
|
||||
self, wx.ID_ANY, _("Use Last Settings"))
|
||||
self.use_last_button.Bind(wx.EVT_BUTTON, self.use_last)
|
||||
|
||||
self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit"))
|
||||
|
@ -481,7 +562,8 @@ class SettingsFrame(wx.Frame):
|
|||
for tab in self.tabs:
|
||||
self.notebook.AddPage(tab, tab.name)
|
||||
sizer_1.Add(self.warning_panel, 0, flag=wx.EXPAND | wx.ALL, border=10)
|
||||
sizer_1.Add(self.notebook, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10)
|
||||
sizer_1.Add(self.notebook, 1, wx.EXPAND |
|
||||
wx.LEFT | wx.TOP | wx.RIGHT, 10)
|
||||
sizer_1.Add(self.presets_panel, 0, flag=wx.EXPAND | wx.ALL, border=10)
|
||||
sizer_3.Add(self.cancel_button, 0, wx.RIGHT, 5)
|
||||
sizer_3.Add(self.use_last_button, 0, wx.RIGHT | wx.BOTTOM, 5)
|
||||
|
@ -520,8 +602,7 @@ class Params(InkstitchExtension):
|
|||
classes.append(Clone)
|
||||
else:
|
||||
if element.get_style("fill", 'black') and not element.get_style("fill-opacity", 1) == "0":
|
||||
classes.append(AutoFill)
|
||||
classes.append(Fill)
|
||||
classes.append(FillStitch)
|
||||
if element.get_style("stroke") is not None:
|
||||
classes.append(Stroke)
|
||||
if element.get_style("stroke-dasharray") is None:
|
||||
|
@ -548,7 +629,8 @@ class Params(InkstitchExtension):
|
|||
else:
|
||||
getter = 'get_param'
|
||||
|
||||
values = [item for item in (getattr(node, getter)(param.name, param.default) for node in nodes) if item is not None]
|
||||
values = [item for item in (getattr(node, getter)(
|
||||
param.name, param.default) for node in nodes) if item is not None]
|
||||
|
||||
return values
|
||||
|
||||
|
@ -614,7 +696,8 @@ class Params(InkstitchExtension):
|
|||
|
||||
for group, params in self.group_params(params):
|
||||
tab_name = group or cls.element_name
|
||||
tab = ParamsTab(parent, id=wx.ID_ANY, name=tab_name, params=list(params), nodes=nodes)
|
||||
tab = ParamsTab(parent, id=wx.ID_ANY, name=tab_name,
|
||||
params=list(params), nodes=nodes)
|
||||
new_tabs.append(tab)
|
||||
|
||||
if group == "":
|
||||
|
@ -634,14 +717,16 @@ class Params(InkstitchExtension):
|
|||
def effect(self):
|
||||
try:
|
||||
app = wx.App()
|
||||
frame = SettingsFrame(tabs_factory=self.create_tabs, on_cancel=self.cancel)
|
||||
frame = SettingsFrame(
|
||||
tabs_factory=self.create_tabs, on_cancel=self.cancel)
|
||||
|
||||
# position left, center
|
||||
current_screen = wx.Display.GetFromPoint(wx.GetMousePosition())
|
||||
display = wx.Display(current_screen)
|
||||
display_size = display.GetClientArea()
|
||||
frame_size = frame.GetSize()
|
||||
frame.SetPosition((int(display_size[0]), int(display_size[3]/2 - frame_size[1]/2)))
|
||||
frame.SetPosition((int(display_size[0]), int(
|
||||
display_size[3]/2 - frame_size[1]/2)))
|
||||
|
||||
frame.Show()
|
||||
app.MainLoop()
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
# Authors: see git history
|
||||
#
|
||||
# Copyright (c) 2021 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
import inkex
|
||||
|
||||
from ..i18n import _
|
||||
from ..marker import set_marker
|
||||
from ..svg.tags import EMBROIDERABLE_TAGS
|
||||
from .base import InkstitchExtension
|
||||
|
||||
|
||||
class SelectionToGuideLine(InkstitchExtension):
|
||||
|
||||
def effect(self):
|
||||
if not self.get_elements():
|
||||
return
|
||||
|
||||
if not self.svg.selected:
|
||||
inkex.errormsg(_("Please select at least one object to be marked as a guide line."))
|
||||
return
|
||||
|
||||
for pattern in self.get_nodes():
|
||||
if pattern.tag in EMBROIDERABLE_TAGS:
|
||||
set_marker(pattern, 'start', 'guide-line')
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
from .auto_fill import auto_fill
|
||||
from .fill import legacy_fill
|
||||
from .guided_fill import guided_fill
|
||||
from .running_stitch import *
|
||||
|
||||
# Can't put this here because we get a circular import :(
|
||||
|
|
|
@ -16,8 +16,7 @@ from shapely.strtree import STRtree
|
|||
from ..debug import debug
|
||||
from ..stitch_plan import Stitch
|
||||
from ..svg import PIXELS_PER_MM
|
||||
from ..utils.geometry import Point as InkstitchPoint
|
||||
from ..utils.geometry import line_string_to_point_list
|
||||
from ..utils.geometry import Point as InkstitchPoint, line_string_to_point_list, ensure_multi_line_string
|
||||
from .fill import intersect_region_with_grating, stitch_row
|
||||
from .running_stitch import running_stitch
|
||||
|
||||
|
@ -59,9 +58,10 @@ def auto_fill(shape,
|
|||
starting_point,
|
||||
ending_point=None,
|
||||
underpath=True):
|
||||
fill_stitch_graph = []
|
||||
try:
|
||||
fill_stitch_graph = build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point, ending_point)
|
||||
rows = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing)
|
||||
segments = [segment for row in rows for segment in row]
|
||||
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
|
||||
except ValueError:
|
||||
# Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node
|
||||
return fallback(shape, running_stitch_length)
|
||||
|
@ -88,9 +88,10 @@ def which_outline(shape, coords):
|
|||
# fail sometimes.
|
||||
|
||||
point = shgeo.Point(*coords)
|
||||
outlines = list(shape.boundary)
|
||||
outlines = ensure_multi_line_string(shape.boundary).geoms
|
||||
outline_indices = list(range(len(outlines)))
|
||||
closest = min(outline_indices, key=lambda index: outlines[index].distance(point))
|
||||
closest = min(outline_indices,
|
||||
key=lambda index: outlines[index].distance(point))
|
||||
|
||||
return closest
|
||||
|
||||
|
@ -101,12 +102,12 @@ def project(shape, coords, outline_index):
|
|||
This returns the distance along the outline at which the point resides.
|
||||
"""
|
||||
|
||||
outline = list(shape.boundary)[outline_index]
|
||||
outline = ensure_multi_line_string(shape.boundary).geoms[outline_index]
|
||||
return outline.project(shgeo.Point(*coords))
|
||||
|
||||
|
||||
@debug.time
|
||||
def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point=None, ending_point=None):
|
||||
def build_fill_stitch_graph(shape, segments, starting_point=None, ending_point=None):
|
||||
"""build a graph representation of the grating segments
|
||||
|
||||
This function builds a specialized graph (as in graph theory) that will
|
||||
|
@ -141,10 +142,6 @@ def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting
|
|||
|
||||
debug.add_layer("auto-fill fill stitch")
|
||||
|
||||
# Convert the shape into a set of parallel line segments.
|
||||
rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing)
|
||||
segments = [segment for row in rows_of_segments for segment in row]
|
||||
|
||||
graph = networkx.MultiGraph()
|
||||
|
||||
# First, add the grating segments as edges. We'll use the coordinates
|
||||
|
@ -152,7 +149,7 @@ def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting
|
|||
for segment in segments:
|
||||
# networkx allows us to label nodes with arbitrary data. We'll
|
||||
# mark this one as a grating segment.
|
||||
graph.add_edge(*segment, key="segment", underpath_edges=[])
|
||||
graph.add_edge(segment[0], segment[-1], key="segment", underpath_edges=[], geometry=shgeo.LineString(segment))
|
||||
|
||||
tag_nodes_with_outline_and_projection(graph, shape, graph.nodes())
|
||||
add_edges_between_outline_nodes(graph, duplicate_every_other=True)
|
||||
|
@ -174,7 +171,7 @@ def insert_node(graph, shape, point):
|
|||
point = tuple(point)
|
||||
outline = which_outline(shape, point)
|
||||
projection = project(shape, point, outline)
|
||||
projected_point = list(shape.boundary)[outline].interpolate(projection)
|
||||
projected_point = ensure_multi_line_string(shape.boundary).geoms[outline].interpolate(projection)
|
||||
node = (projected_point.x, projected_point.y)
|
||||
|
||||
edges = []
|
||||
|
@ -199,7 +196,8 @@ def tag_nodes_with_outline_and_projection(graph, shape, nodes):
|
|||
|
||||
|
||||
def add_boundary_travel_nodes(graph, shape):
|
||||
for outline_index, outline in enumerate(shape.boundary):
|
||||
outlines = ensure_multi_line_string(shape.boundary).geoms
|
||||
for outline_index, outline in enumerate(outlines):
|
||||
prev = None
|
||||
for point in outline.coords:
|
||||
point = shgeo.Point(point)
|
||||
|
@ -230,7 +228,8 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False):
|
|||
outline.
|
||||
"""
|
||||
|
||||
nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...]
|
||||
# returns a list of tuples: [(node, {data}), (node, {data}) ...]
|
||||
nodes = list(graph.nodes(data=True))
|
||||
nodes.sort(key=lambda node: (node[1]['outline'], node[1]['projection']))
|
||||
|
||||
for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['outline']):
|
||||
|
@ -261,7 +260,10 @@ def fallback(shape, running_stitch_length):
|
|||
matter.
|
||||
"""
|
||||
|
||||
return running_stitch(line_string_to_point_list(shape.boundary[0]), running_stitch_length)
|
||||
boundary = ensure_multi_line_string(shape.boundary)
|
||||
outline = boundary.geoms[0]
|
||||
|
||||
return running_stitch(line_string_to_point_list(outline), running_stitch_length)
|
||||
|
||||
|
||||
@debug.time
|
||||
|
@ -325,7 +327,7 @@ def get_segments(graph):
|
|||
segments = []
|
||||
for start, end, key, data in graph.edges(keys=True, data=True):
|
||||
if key == 'segment':
|
||||
segments.append(shgeo.LineString((start, end)))
|
||||
segments.append(data["geometry"])
|
||||
|
||||
return segments
|
||||
|
||||
|
@ -363,7 +365,8 @@ def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges):
|
|||
# segments that _might_ intersect ls. Refining the result is
|
||||
# necessary but the STRTree still saves us a ton of time.
|
||||
if segment.crosses(ls):
|
||||
start, end = segment.coords
|
||||
start = segment.coords[0]
|
||||
end = segment.coords[-1]
|
||||
fill_stitch_graph[start][end]['segment']['underpath_edges'].append(edge)
|
||||
|
||||
# The weight of a travel edge is the length of the line segment.
|
||||
|
@ -384,19 +387,10 @@ def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges):
|
|||
|
||||
|
||||
def travel_grating(shape, angle, row_spacing):
|
||||
rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing)
|
||||
segments = list(chain(*rows_of_segments))
|
||||
rows = intersect_region_with_grating(shape, angle, row_spacing)
|
||||
segments = [segment for row in rows for segment in row]
|
||||
|
||||
return shgeo.MultiLineString(segments)
|
||||
|
||||
|
||||
def ensure_multi_line_string(thing):
|
||||
"""Given either a MultiLineString or a single LineString, return a MultiLineString"""
|
||||
|
||||
if isinstance(thing, shgeo.LineString):
|
||||
return shgeo.MultiLineString([thing])
|
||||
else:
|
||||
return thing
|
||||
return shgeo.MultiLineString(list(segments))
|
||||
|
||||
|
||||
def build_travel_edges(shape, fill_angle):
|
||||
|
@ -443,7 +437,7 @@ def build_travel_edges(shape, fill_angle):
|
|||
debug.log_line_strings(grating3, "grating3")
|
||||
|
||||
endpoints = [coord for mls in (grating1, grating2, grating3)
|
||||
for ls in mls
|
||||
for ls in mls.geoms
|
||||
for coord in ls.coords]
|
||||
|
||||
diagonal_edges = ensure_multi_line_string(grating1.symmetric_difference(grating2))
|
||||
|
@ -451,7 +445,7 @@ def build_travel_edges(shape, fill_angle):
|
|||
# without this, floating point inaccuracies prevent the intersection points from lining up perfectly.
|
||||
vertical_edges = ensure_multi_line_string(snap(grating3.difference(grating1), diagonal_edges, 0.005))
|
||||
|
||||
return endpoints, chain(diagonal_edges, vertical_edges)
|
||||
return endpoints, chain(diagonal_edges.geoms, vertical_edges.geoms)
|
||||
|
||||
|
||||
def nearest_node(nodes, point, attr=None):
|
||||
|
|
|
@ -0,0 +1,551 @@
|
|||
from collections import namedtuple
|
||||
from itertools import chain
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, LineString, Point
|
||||
from shapely.geometry.polygon import orient
|
||||
from shapely.ops import nearest_points
|
||||
from shapely.ops import polygonize
|
||||
|
||||
from .running_stitch import running_stitch
|
||||
from ..stitch_plan import Stitch
|
||||
from ..utils import DotDict
|
||||
from ..utils.geometry import cut, reverse_line_string, roll_linear_ring
|
||||
from ..utils.geometry import ensure_geometry_collection, ensure_multi_polygon
|
||||
|
||||
|
||||
class Tree(nx.DiGraph):
|
||||
# This lets us do tree.nodes['somenode'].parent instead of the default
|
||||
# tree.nodes['somenode']['parent'].
|
||||
node_attr_dict_factory = DotDict
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.__node_num = 0
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def generate_node_name(self):
|
||||
node = self.__node_num
|
||||
self.__node_num += 1
|
||||
|
||||
return node
|
||||
|
||||
|
||||
nearest_neighbor_tuple = namedtuple(
|
||||
"nearest_neighbor_tuple",
|
||||
[
|
||||
"nearest_point_parent",
|
||||
"nearest_point_child",
|
||||
"proj_distance_parent",
|
||||
"child_node",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _offset_linear_ring(ring, offset, resolution, join_style, mitre_limit):
|
||||
result = Polygon(ring).buffer(-offset, resolution, cap_style=2, join_style=join_style, mitre_limit=mitre_limit, single_sided=True)
|
||||
result = ensure_multi_polygon(result)
|
||||
rings = GeometryCollection([poly.exterior for poly in result.geoms])
|
||||
rings = rings.simplify(0.01, False)
|
||||
|
||||
return _take_only_valid_linear_rings(rings)
|
||||
|
||||
|
||||
def _take_only_valid_linear_rings(rings):
|
||||
"""
|
||||
Removes all geometries which do not form a "valid" LinearRing.
|
||||
|
||||
A "valid" ring is one that does not form a straight line.
|
||||
"""
|
||||
|
||||
valid_rings = []
|
||||
|
||||
for ring in ensure_geometry_collection(rings).geoms:
|
||||
if len(ring.coords) > 3 or (len(ring.coords) == 3 and ring.coords[0] != ring.coords[-1]):
|
||||
valid_rings.append(ring)
|
||||
|
||||
return GeometryCollection(valid_rings)
|
||||
|
||||
|
||||
def _orient_linear_ring(ring, clockwise=True):
|
||||
# Unfortunately for us, Inkscape SVGs have an inverted Y coordinate.
|
||||
# Normally we don't have to care about that, but in this very specific
|
||||
# case, the meaning of is_ccw is flipped. It actually tests whether
|
||||
# a ring is clockwise. That makes this logic super-confusing.
|
||||
if ring.is_ccw != clockwise:
|
||||
return reverse_line_string(ring)
|
||||
else:
|
||||
return ring
|
||||
|
||||
|
||||
def _orient_tree(tree, clockwise=True):
|
||||
"""
|
||||
Orient all linear rings in the tree.
|
||||
|
||||
Since naturally holes have the opposite point ordering than non-holes we
|
||||
make all lines within the tree uniform (having all the same ordering
|
||||
direction)
|
||||
"""
|
||||
|
||||
for node in tree.nodes.values():
|
||||
node.val = _orient_linear_ring(node.val, clockwise)
|
||||
|
||||
|
||||
def offset_polygon(polygon, offset, join_style, clockwise):
|
||||
"""
|
||||
Convert a polygon to a tree of isocontours.
|
||||
|
||||
An isocontour is an offset version of the polygon's boundary. For example,
|
||||
the isocontours of a circle are a set of concentric circles inside the
|
||||
circle.
|
||||
|
||||
This function takes a polygon (which may have holes) as input and creates
|
||||
isocontours until the polygon is filled completely. The isocontours are
|
||||
returned as a Tree, with a parent-child relationship indicating that the
|
||||
parent isocontour contains the child isocontour.
|
||||
|
||||
Arguments:
|
||||
polygon - The shapely Polygon which may have holes
|
||||
offset - The spacing between isocontours
|
||||
join_style - Join style used when offsetting the Polygon border to create
|
||||
isocontours. Can be round, mitered or bevel, as defined by
|
||||
shapely:
|
||||
https://shapely.readthedocs.io/en/stable/manual.html#shapely.geometry.JOIN_STYLE
|
||||
clockwise - If True, isocontour points are in clockwise order; if False, counter-clockwise.
|
||||
|
||||
Return Value:
|
||||
Tree - see above
|
||||
"""
|
||||
|
||||
ordered_polygon = orient(polygon, -1)
|
||||
tree = Tree()
|
||||
tree.add_node('root', type='node', parent=None, val=ordered_polygon.exterior)
|
||||
active_polygons = ['root']
|
||||
active_holes = [[]]
|
||||
|
||||
for hole in ordered_polygon.interiors:
|
||||
hole_node = tree.generate_node_name()
|
||||
tree.add_node(hole_node, type="hole", val=hole)
|
||||
active_holes[0].append(hole_node)
|
||||
|
||||
while len(active_polygons) > 0:
|
||||
current_poly = active_polygons.pop()
|
||||
current_holes = active_holes.pop()
|
||||
|
||||
outer, inners = _offset_polygon_and_holes(tree, current_poly, current_holes, offset, join_style)
|
||||
polygons = _match_polygons_and_holes(outer, inners)
|
||||
|
||||
for polygon in polygons.geoms:
|
||||
new_polygon, new_holes = _convert_polygon_to_nodes(tree, polygon, parent_polygon=current_poly, child_holes=current_holes)
|
||||
|
||||
if new_polygon is not None:
|
||||
active_polygons.append(new_polygon)
|
||||
active_holes.append(new_holes)
|
||||
|
||||
for previous_hole in current_holes:
|
||||
# If the previous holes are not
|
||||
# contained in the new holes they
|
||||
# have been merged with the
|
||||
# outer polygon
|
||||
if not tree.nodes[previous_hole].parent:
|
||||
tree.nodes[previous_hole].parent = current_poly
|
||||
tree.add_edge(current_poly, previous_hole)
|
||||
|
||||
_orient_tree(tree, clockwise)
|
||||
return tree
|
||||
|
||||
|
||||
def _offset_polygon_and_holes(tree, poly, holes, offset, join_style):
|
||||
outer = _offset_linear_ring(
|
||||
tree.nodes[poly].val,
|
||||
offset,
|
||||
resolution=5,
|
||||
join_style=join_style,
|
||||
mitre_limit=10,
|
||||
)
|
||||
|
||||
inners = []
|
||||
for hole in holes:
|
||||
inner = _offset_linear_ring(
|
||||
tree.nodes[hole].val,
|
||||
-offset, # take negative offset for holes
|
||||
resolution=5,
|
||||
join_style=join_style,
|
||||
mitre_limit=10,
|
||||
)
|
||||
if not inner.is_empty:
|
||||
inners.append(Polygon(inner.geoms[0]))
|
||||
|
||||
return outer, inners
|
||||
|
||||
|
||||
def _match_polygons_and_holes(outer, inners):
|
||||
result = MultiPolygon(polygonize(outer.geoms))
|
||||
if len(inners) > 0:
|
||||
result = ensure_geometry_collection(result.difference(MultiPolygon(inners)))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _convert_polygon_to_nodes(tree, polygon, parent_polygon, child_holes):
|
||||
polygon = orient(polygon, -1)
|
||||
|
||||
if polygon.area < 0.1:
|
||||
return None, None
|
||||
|
||||
valid_rings = _take_only_valid_linear_rings(polygon.exterior)
|
||||
|
||||
try:
|
||||
exterior = valid_rings.geoms[0]
|
||||
except IndexError:
|
||||
return None, None
|
||||
|
||||
node = tree.generate_node_name()
|
||||
tree.add_node(node, type='node', parent=parent_polygon, val=exterior)
|
||||
tree.add_edge(parent_polygon, node)
|
||||
|
||||
hole_nodes = []
|
||||
for hole in polygon.interiors:
|
||||
hole_node = tree.generate_node_name()
|
||||
tree.add_node(hole_node, type="hole", val=hole)
|
||||
for previous_hole in child_holes:
|
||||
if Polygon(hole).contains(Polygon(tree.nodes[previous_hole].val)):
|
||||
tree.nodes[previous_hole].parent = hole_node
|
||||
tree.add_edge(hole_node, previous_hole)
|
||||
hole_nodes.append(hole_node)
|
||||
|
||||
return node, hole_nodes
|
||||
|
||||
|
||||
def _get_nearest_points_closer_than_thresh(travel_line, next_line, threshold):
|
||||
"""
|
||||
Find the first point along travel_line that is within threshold of next_line.
|
||||
|
||||
Input:
|
||||
travel_line - The "parent" line for which the distance should
|
||||
be minimized to enter next_line
|
||||
next_line - contains the next_line which need to be entered
|
||||
threshold - The distance between travel_line and next_line needs
|
||||
to below threshold to be a valid point for entering
|
||||
|
||||
Return value:
|
||||
tuple or None
|
||||
- the tuple structure is:
|
||||
(point in travel_line, point in next_line)
|
||||
- None is returned if there is no point that satisfies the threshold.
|
||||
"""
|
||||
|
||||
# We'll buffer next_line and find the intersection with travel_line.
|
||||
# Then we'll return the very first point in the intersection,
|
||||
# matched with a corresponding point on next_line. Fortunately for
|
||||
# us, intersection of a Polygon with a LineString yields pieces of
|
||||
# the LineString in the same order as the input LineString.
|
||||
threshold_area = next_line.buffer(threshold)
|
||||
portion_within_threshold = travel_line.intersection(threshold_area)
|
||||
|
||||
if portion_within_threshold.is_empty:
|
||||
return None
|
||||
else:
|
||||
# Projecting with 0 lets us avoid distinguishing between LineString and
|
||||
# MultiLineString.
|
||||
parent_point = Point(portion_within_threshold.interpolate(0))
|
||||
return nearest_points(parent_point, next_line)
|
||||
|
||||
|
||||
def _create_nearest_points_list(travel_line, tree, children, threshold, threshold_hard):
|
||||
"""Determine the best place to enter each of parent's children
|
||||
|
||||
Arguments:
|
||||
travel_line - The "parent" line for which the distance should
|
||||
be minimized to enter each child
|
||||
children - children of travel_line that need to be entered
|
||||
threshold - The distance between travel_line and a child should
|
||||
to be below threshold to be a valid point for entering
|
||||
threshold_hard - As a last resort, we can accept an entry point
|
||||
that is this far way
|
||||
|
||||
Return value:
|
||||
list of nearest_neighbor_tuple - indicating where to enter each
|
||||
respective child
|
||||
"""
|
||||
|
||||
children_nearest_points = []
|
||||
|
||||
for child in children:
|
||||
result = _get_nearest_points_closer_than_thresh(travel_line, tree.nodes[child].val, threshold)
|
||||
if result is None:
|
||||
# where holes meet outer borders a distance
|
||||
# up to 2 * used offset can arise
|
||||
result = _get_nearest_points_closer_than_thresh(travel_line, tree.nodes[child].val, threshold_hard)
|
||||
|
||||
proj = travel_line.project(result[0])
|
||||
children_nearest_points.append(
|
||||
nearest_neighbor_tuple(
|
||||
nearest_point_parent=result[0],
|
||||
nearest_point_child=result[1],
|
||||
proj_distance_parent=proj,
|
||||
child_node=child,
|
||||
)
|
||||
)
|
||||
|
||||
return children_nearest_points
|
||||
|
||||
|
||||
def _find_path_inner_to_outer(tree, node, offset, starting_point, avoid_self_crossing, forward=True):
|
||||
"""Find a stitch path for this ring and its children.
|
||||
|
||||
Strategy: A connection from parent to child is made as fast as possible to
|
||||
reach the innermost child as fast as possible in order to stitch afterwards
|
||||
from inner to outer.
|
||||
|
||||
This function calls itself recursively to find a stitch path for each child
|
||||
(and its children).
|
||||
|
||||
Arguments:
|
||||
tree - a Tree of isocontours (as returned by offset_polygon)
|
||||
offset - offset that was passed to offset_polygon
|
||||
starting_point - starting point for stitching
|
||||
avoid_self_crossing - if True, tries to generate a path that does not
|
||||
cross itself.
|
||||
forward - if True, this ring will be stitched in its natural direction
|
||||
(used internally by avoid_self_crossing)
|
||||
|
||||
Return value:
|
||||
LineString -- the stitching path
|
||||
"""
|
||||
|
||||
current_node = tree.nodes[node]
|
||||
current_ring = current_node.val
|
||||
|
||||
if not forward and avoid_self_crossing:
|
||||
current_ring = reverse_line_string(current_ring)
|
||||
|
||||
# reorder the coordinates of this ring so that it starts with
|
||||
# a point nearest the starting_point
|
||||
start_distance = current_ring.project(starting_point)
|
||||
current_ring = roll_linear_ring(current_ring, start_distance)
|
||||
current_node.val = current_ring
|
||||
|
||||
# Find where along this ring to connect to each child.
|
||||
nearest_points_list = _create_nearest_points_list(
|
||||
current_ring,
|
||||
tree,
|
||||
tree[node],
|
||||
threshold=1.5 * offset,
|
||||
threshold_hard=2.05 * offset
|
||||
)
|
||||
nearest_points_list.sort(key=lambda tup: tup.proj_distance_parent)
|
||||
|
||||
result_coords = []
|
||||
if not nearest_points_list:
|
||||
# We have no children, so we're at the center of a spiral. Reversing
|
||||
# the innermost ring gives a nicer visual appearance.
|
||||
if not avoid_self_crossing:
|
||||
current_ring = reverse_line_string(current_ring)
|
||||
else:
|
||||
# This is a recursive algorithm. We'll stitch along this ring, pausing
|
||||
# to jump to each child ring in turn and sew it before continuing on
|
||||
# this ring. We'll end back where we started.
|
||||
|
||||
result_coords.append(current_ring.coords[0])
|
||||
distance_so_far = 0
|
||||
for child_connection in nearest_points_list:
|
||||
# Cut this ring into pieces before and after where this child will connect.
|
||||
before, after = cut(current_ring, child_connection.proj_distance_parent - distance_so_far)
|
||||
distance_so_far = child_connection.proj_distance_parent
|
||||
|
||||
# Stitch the part leading up to this child.
|
||||
if before is not None:
|
||||
result_coords.extend(before.coords)
|
||||
|
||||
# Stitch this child. The child will start and end in the same
|
||||
# place, which should be close to our current location.
|
||||
child_path = _find_path_inner_to_outer(
|
||||
tree,
|
||||
child_connection.child_node,
|
||||
offset,
|
||||
child_connection.nearest_point_child,
|
||||
avoid_self_crossing,
|
||||
not forward
|
||||
)
|
||||
result_coords.extend(child_path.coords)
|
||||
|
||||
# Skip ahead a little bit on this ring before resuming. This
|
||||
# gives a nice spiral pattern, where we spiral out from the
|
||||
# innermost child.
|
||||
if after is not None:
|
||||
skip, after = cut(after, offset)
|
||||
distance_so_far += offset
|
||||
|
||||
current_ring = after
|
||||
|
||||
if current_ring is not None:
|
||||
# skip a little at the end so we don't end exactly where we started.
|
||||
remaining_length = current_ring.length
|
||||
if remaining_length > offset:
|
||||
current_ring, skip = cut(current_ring, current_ring.length - offset)
|
||||
|
||||
result_coords.extend(current_ring.coords)
|
||||
|
||||
return LineString(result_coords)
|
||||
|
||||
|
||||
def inner_to_outer(tree, offset, stitch_length, starting_point, avoid_self_crossing):
|
||||
"""Fill a shape with spirals, from innermost to outermost."""
|
||||
|
||||
stitch_path = _find_path_inner_to_outer(tree, 'root', offset, starting_point, avoid_self_crossing)
|
||||
points = [Stitch(*point) for point in stitch_path.coords]
|
||||
stitches = running_stitch(points, stitch_length)
|
||||
|
||||
return stitches
|
||||
|
||||
|
||||
def _reorder_linear_ring(ring, start):
|
||||
distances = ring - start
|
||||
start_index = np.argmin(np.linalg.norm(distances, axis=1))
|
||||
return np.roll(ring, -start_index, axis=0)
|
||||
|
||||
|
||||
def _interpolate_linear_rings(ring1, ring2, max_stitch_length, start=None):
|
||||
"""
|
||||
Interpolate between two LinearRings
|
||||
|
||||
Creates a path from start_point on ring1 and around the rings, ending at a
|
||||
nearby point on ring2. The path will smoothly transition from ring1 to
|
||||
ring2 as it travels around the rings.
|
||||
|
||||
Inspired by interpolate() from https://github.com/mikedh/pocketing/blob/master/pocketing/polygons.py
|
||||
|
||||
Arguments:
|
||||
ring1 -- LinearRing start point will lie on
|
||||
ring2 -- LinearRing end point will lie on
|
||||
max_stitch_length -- maximum stitch length (used to calculate resampling accuracy)
|
||||
start -- Point on ring1 to start at, as a tuple
|
||||
|
||||
Return value: Path interpolated between two LinearRings, as a LineString.
|
||||
"""
|
||||
|
||||
# Resample the two LinearRings so that they are the same number of points
|
||||
# long. Then take the corresponding points in each ring and interpolate
|
||||
# between them, gradually going more toward ring2.
|
||||
#
|
||||
# This is a little less accurate than the method in interpolate(), but several
|
||||
# orders of magnitude faster because we're not building and querying a KDTree.
|
||||
|
||||
num_points = int(20 * ring1.length / max_stitch_length)
|
||||
ring1_resampled = trimesh.path.traversal.resample_path(np.array(ring1.coords), count=num_points)
|
||||
ring2_resampled = trimesh.path.traversal.resample_path(np.array(ring2.coords), count=num_points)
|
||||
|
||||
if start is not None:
|
||||
ring1_resampled = _reorder_linear_ring(ring1_resampled, start)
|
||||
ring2_resampled = _reorder_linear_ring(ring2_resampled, start)
|
||||
|
||||
weights = np.linspace(0.0, 1.0, num_points).reshape((-1, 1))
|
||||
points = (ring1_resampled * (1.0 - weights)) + (ring2_resampled * weights)
|
||||
result = LineString(points)
|
||||
|
||||
return result.simplify(0.1, False)
|
||||
|
||||
|
||||
def _check_and_prepare_tree_for_valid_spiral(tree):
|
||||
"""Check whether spiral fill is possible, and tweak if necessary.
|
||||
|
||||
Takes a tree consisting of isocontours. If a parent has more than one child
|
||||
we cannot create a spiral. However, to make the routine more robust, we
|
||||
allow more than one child if only one of the children has own children. The
|
||||
other children are removed in this routine then. If the routine returns true,
|
||||
the tree will have been cleaned up from unwanted children.
|
||||
|
||||
If even with these weaker constraints, a spiral is not possible, False is
|
||||
returned.
|
||||
"""
|
||||
|
||||
def process_node(node):
|
||||
children = set(tree[node])
|
||||
|
||||
if len(children) == 0:
|
||||
return True
|
||||
elif len(children) == 1:
|
||||
child = children.pop()
|
||||
return process_node(child)
|
||||
else:
|
||||
children_with_children = {child for child in children if tree[child]}
|
||||
if len(children_with_children) > 1:
|
||||
# Node has multiple children with children, so a perfect spiral is not possible.
|
||||
# This False value will be returned all the way up the stack.
|
||||
return False
|
||||
elif len(children_with_children) == 1:
|
||||
children_without_children = children - children_with_children
|
||||
child = children_with_children.pop()
|
||||
tree.remove_nodes_from(children_without_children)
|
||||
return process_node(child)
|
||||
else:
|
||||
# None of the children has its own children, so we'll just take the longest.
|
||||
longest = max(children, key=lambda child: tree[child]['val'].length)
|
||||
shorter_children = children - {longest}
|
||||
tree.remove_nodes_from(shorter_children)
|
||||
return process_node(longest)
|
||||
|
||||
return process_node('root')
|
||||
|
||||
|
||||
def single_spiral(tree, stitch_length, starting_point):
|
||||
"""Fill a shape with a single spiral going from outside to center."""
|
||||
return _spiral_fill(tree, stitch_length, starting_point, _make_spiral)
|
||||
|
||||
|
||||
def double_spiral(tree, stitch_length, starting_point):
|
||||
"""Fill a shape with a double spiral going from outside to center and back to outside. """
|
||||
return _spiral_fill(tree, stitch_length, starting_point, _make_fermat_spiral)
|
||||
|
||||
|
||||
def _spiral_fill(tree, stitch_length, close_point, spiral_maker):
|
||||
starting_point = close_point.coords[0]
|
||||
|
||||
rings = _get_spiral_rings(tree)
|
||||
path = spiral_maker(rings, stitch_length, starting_point)
|
||||
path = [Stitch(*stitch) for stitch in path]
|
||||
|
||||
return running_stitch(path, stitch_length)
|
||||
|
||||
|
||||
def _get_spiral_rings(tree):
|
||||
rings = []
|
||||
|
||||
node = 'root'
|
||||
while True:
|
||||
rings.append(tree.nodes[node].val)
|
||||
|
||||
children = tree[node]
|
||||
if len(children) == 0:
|
||||
break
|
||||
elif len(children) == 1:
|
||||
node = list(children)[0]
|
||||
else:
|
||||
# We can only really fill a shape with a single spiral if each
|
||||
# parent has only one child. We'll do our best though, because
|
||||
# that is probably more helpful to the user than just refusing
|
||||
# entirely. We'll pick the child that's closest to the center.
|
||||
parent_center = rings[-1].centroid
|
||||
node = min(children, key=lambda child: parent_center.distance(tree.nodes[child].val.centroid))
|
||||
|
||||
return rings
|
||||
|
||||
|
||||
def _make_fermat_spiral(rings, stitch_length, starting_point):
|
||||
forward = _make_spiral(rings[::2], stitch_length, starting_point)
|
||||
back = _make_spiral(rings[1::2], stitch_length, starting_point)
|
||||
back.reverse()
|
||||
|
||||
return chain(forward, back)
|
||||
|
||||
|
||||
def _make_spiral(rings, stitch_length, starting_point):
|
||||
path = []
|
||||
|
||||
for ring1, ring2 in zip(rings[:-1], rings[1:]):
|
||||
spiral_part = _interpolate_linear_rings(ring1, ring2, stitch_length, starting_point)
|
||||
path.extend(spiral_part.coords)
|
||||
|
||||
return path
|
|
@ -131,8 +131,6 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non
|
|||
# fill regions at the same angle and spacing always line up nicely.
|
||||
start -= (start + normal * center) % row_spacing
|
||||
|
||||
rows = []
|
||||
|
||||
current_row_y = start
|
||||
|
||||
while current_row_y < end:
|
||||
|
@ -159,15 +157,13 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non
|
|||
runs.reverse()
|
||||
runs = [tuple(reversed(run)) for run in runs]
|
||||
|
||||
rows.append(runs)
|
||||
yield runs
|
||||
|
||||
if end_row_spacing:
|
||||
current_row_y += row_spacing + (end_row_spacing - row_spacing) * ((current_row_y - start) / height)
|
||||
else:
|
||||
current_row_y += row_spacing
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def section_to_stitches(group_of_segments, angle, row_spacing, max_stitch_length, staggers, skip_last):
|
||||
stitches = []
|
||||
|
@ -221,6 +217,7 @@ def pull_runs(rows, shape, row_spacing):
|
|||
|
||||
# print >>sys.stderr, "\n".join(str(len(row)) for row in rows)
|
||||
|
||||
rows = list(rows)
|
||||
runs = []
|
||||
count = 0
|
||||
while (len(rows) > 0):
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
from shapely import geometry as shgeo
|
||||
from shapely.ops import linemerge, unary_union
|
||||
|
||||
from .auto_fill import (build_fill_stitch_graph,
|
||||
build_travel_graph, collapse_sequential_outline_edges, fallback,
|
||||
find_stitch_path, graph_is_valid, travel)
|
||||
from .running_stitch import running_stitch
|
||||
from ..i18n import _
|
||||
from ..stitch_plan import Stitch
|
||||
from ..utils.geometry import Point as InkstitchPoint, reverse_line_string
|
||||
|
||||
|
||||
def guided_fill(shape,
|
||||
guideline,
|
||||
angle,
|
||||
row_spacing,
|
||||
max_stitch_length,
|
||||
running_stitch_length,
|
||||
skip_last,
|
||||
starting_point,
|
||||
ending_point=None,
|
||||
underpath=True):
|
||||
try:
|
||||
segments = intersect_region_with_grating_guideline(shape, guideline, row_spacing)
|
||||
fill_stitch_graph = build_fill_stitch_graph(shape, segments, starting_point, ending_point)
|
||||
except ValueError:
|
||||
# Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node
|
||||
return fallback(shape, running_stitch_length)
|
||||
|
||||
if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length):
|
||||
return fallback(shape, running_stitch_length)
|
||||
|
||||
travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
|
||||
path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
|
||||
result = path_to_stitches(path, travel_graph, fill_stitch_graph, max_stitch_length, running_stitch_length, skip_last)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def path_to_stitches(path, travel_graph, fill_stitch_graph, stitch_length, running_stitch_length, skip_last):
|
||||
path = collapse_sequential_outline_edges(path)
|
||||
|
||||
stitches = []
|
||||
|
||||
# If the very first stitch is travel, we'll omit it in travel(), so add it here.
|
||||
if not path[0].is_segment():
|
||||
stitches.append(Stitch(*path[0].nodes[0]))
|
||||
|
||||
for edge in path:
|
||||
if edge.is_segment():
|
||||
current_edge = fill_stitch_graph[edge[0]][edge[-1]]['segment']
|
||||
path_geometry = current_edge['geometry']
|
||||
|
||||
if edge[0] != path_geometry.coords[0]:
|
||||
path_geometry = reverse_line_string(path_geometry)
|
||||
|
||||
point_list = [Stitch(*point) for point in path_geometry.coords]
|
||||
new_stitches = running_stitch(point_list, stitch_length)
|
||||
|
||||
# need to tag stitches
|
||||
|
||||
if skip_last:
|
||||
del new_stitches[-1]
|
||||
|
||||
stitches.extend(new_stitches)
|
||||
|
||||
travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
|
||||
else:
|
||||
stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, skip_last))
|
||||
|
||||
return stitches
|
||||
|
||||
|
||||
def extend_line(line, minx, maxx, miny, maxy):
|
||||
line = line.simplify(0.01, False)
|
||||
|
||||
upper_left = InkstitchPoint(minx, miny)
|
||||
lower_right = InkstitchPoint(maxx, maxy)
|
||||
length = (upper_left - lower_right).length()
|
||||
|
||||
point1 = InkstitchPoint(*line.coords[0])
|
||||
point2 = InkstitchPoint(*line.coords[1])
|
||||
new_starting_point = point1 - (point2 - point1).unit() * length
|
||||
|
||||
point3 = InkstitchPoint(*line.coords[-2])
|
||||
point4 = InkstitchPoint(*line.coords[-1])
|
||||
new_ending_point = point4 + (point4 - point3).unit() * length
|
||||
|
||||
return shgeo.LineString([new_starting_point.as_tuple()] +
|
||||
line.coords[1:-1] + [new_ending_point.as_tuple()])
|
||||
|
||||
|
||||
def repair_multiple_parallel_offset_curves(multi_line):
|
||||
lines = linemerge(multi_line)
|
||||
lines = list(lines.geoms)
|
||||
max_length = -1
|
||||
max_length_idx = -1
|
||||
for idx, subline in enumerate(lines):
|
||||
if subline.length > max_length:
|
||||
max_length = subline.length
|
||||
max_length_idx = idx
|
||||
# need simplify to avoid doubled points caused by linemerge
|
||||
return lines[max_length_idx].simplify(0.01, False)
|
||||
|
||||
|
||||
def repair_non_simple_lines(line):
|
||||
repaired = unary_union(line)
|
||||
counter = 0
|
||||
# Do several iterations since we might have several concatenated selfcrossings
|
||||
while repaired.geom_type != 'LineString' and counter < 4:
|
||||
line_segments = []
|
||||
for line_seg in repaired.geoms:
|
||||
if not line_seg.is_ring:
|
||||
line_segments.append(line_seg)
|
||||
|
||||
repaired = unary_union(linemerge(line_segments))
|
||||
counter += 1
|
||||
if repaired.geom_type != 'LineString':
|
||||
raise ValueError(
|
||||
_("Guide line (or offset copy) is self crossing!"))
|
||||
else:
|
||||
return repaired
|
||||
|
||||
|
||||
def intersect_region_with_grating_guideline(shape, line, row_spacing, flip=False): # noqa: C901
|
||||
|
||||
row_spacing = abs(row_spacing)
|
||||
(minx, miny, maxx, maxy) = shape.bounds
|
||||
upper_left = InkstitchPoint(minx, miny)
|
||||
rows = []
|
||||
|
||||
if line.geom_type != 'LineString' or not line.is_simple:
|
||||
line = repair_non_simple_lines(line)
|
||||
# extend the line towards the ends to increase probability that all offsetted curves cross the shape
|
||||
line = extend_line(line, minx, maxx, miny, maxy)
|
||||
|
||||
line_offsetted = line
|
||||
res = line_offsetted.intersection(shape)
|
||||
while isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)) or (not res.is_empty and len(res.coords) > 1):
|
||||
if isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)):
|
||||
runs = [line_string.coords for line_string in res.geoms if (
|
||||
not line_string.is_empty and len(line_string.coords) > 1)]
|
||||
else:
|
||||
runs = [res.coords]
|
||||
|
||||
runs.sort(key=lambda seg: (
|
||||
InkstitchPoint(*seg[0]) - upper_left).length())
|
||||
if flip:
|
||||
runs.reverse()
|
||||
runs = [tuple(reversed(run)) for run in runs]
|
||||
|
||||
if row_spacing > 0:
|
||||
rows.append(runs)
|
||||
else:
|
||||
rows.insert(0, runs)
|
||||
|
||||
line_offsetted = line_offsetted.parallel_offset(row_spacing, 'left', 5)
|
||||
if line_offsetted.geom_type == 'MultiLineString': # if we got multiple lines take the longest
|
||||
line_offsetted = repair_multiple_parallel_offset_curves(line_offsetted)
|
||||
if not line_offsetted.is_simple:
|
||||
line_offsetted = repair_non_simple_lines(line_offsetted)
|
||||
|
||||
if row_spacing < 0:
|
||||
line_offsetted = reverse_line_string(line_offsetted)
|
||||
line_offsetted = line_offsetted.simplify(0.01, False)
|
||||
res = line_offsetted.intersection(shape)
|
||||
if row_spacing > 0 and not isinstance(res, (shgeo.GeometryCollection, shgeo.MultiLineString)):
|
||||
if (res.is_empty or len(res.coords) == 1):
|
||||
row_spacing = -row_spacing
|
||||
|
||||
line_offsetted = line.parallel_offset(row_spacing, 'left', 5)
|
||||
if line_offsetted.geom_type == 'MultiLineString': # if we got multiple lines take the longest
|
||||
line_offsetted = repair_multiple_parallel_offset_curves(
|
||||
line_offsetted)
|
||||
if not line_offsetted.is_simple:
|
||||
line_offsetted = repair_non_simple_lines(line_offsetted)
|
||||
# using negative row spacing leads as a side effect to reversed offsetted lines - here we undo this
|
||||
line_offsetted = reverse_line_string(line_offsetted)
|
||||
line_offsetted = line_offsetted.simplify(0.01, False)
|
||||
res = line_offsetted.intersection(shape)
|
||||
|
||||
for row in rows:
|
||||
yield from row
|
|
@ -3,11 +3,15 @@
|
|||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
from ..debug import debug
|
||||
import math
|
||||
from copy import copy
|
||||
from shapely.geometry import LineString
|
||||
|
||||
""" Utility functions to produce running stitches. """
|
||||
|
||||
|
||||
@debug.time
|
||||
def running_stitch(points, stitch_length):
|
||||
"""Generate running stitch along a path.
|
||||
|
||||
|
@ -23,56 +27,50 @@ def running_stitch(points, stitch_length):
|
|||
if len(points) < 2:
|
||||
return []
|
||||
|
||||
# simplify will remove as many points as possible while ensuring that the
|
||||
# resulting path stays within 0.75 pixels (0.2mm) of the original path.
|
||||
path = LineString(points)
|
||||
simplified = path.simplify(0.75, preserve_topology=False)
|
||||
|
||||
# save the points that simplify picked and make sure we stitch them
|
||||
important_points = set(simplified.coords)
|
||||
important_point_indices = [i for i, point in enumerate(points) if point.as_tuple() in important_points]
|
||||
|
||||
output = []
|
||||
segment_start = points[0]
|
||||
last_segment_direction = None
|
||||
for start, end in zip(important_point_indices[:-1], important_point_indices[1:]):
|
||||
# consider sections of the original path, each one starting and ending
|
||||
# with an important point
|
||||
section = points[start:end + 1]
|
||||
output.append(section[0])
|
||||
|
||||
# This tracks the distance we've traveled along the current segment so
|
||||
# far. Each time we make a stitch, we add the stitch_length to this
|
||||
# value. If we fall off the end of the current segment, we carry over
|
||||
# the remainder to the next segment.
|
||||
distance = 0.0
|
||||
# Now split each section up evenly into stitches, each with a length no
|
||||
# greater than the specified stitch_length.
|
||||
section_ls = LineString(section)
|
||||
section_length = section_ls.length
|
||||
if section_length > stitch_length:
|
||||
# a fractional stitch needs to be rounded up, which will make all
|
||||
# of the stitches shorter
|
||||
num_stitches = math.ceil(section_length / stitch_length)
|
||||
actual_stitch_length = section_length / num_stitches
|
||||
|
||||
for segment_end in points[1:]:
|
||||
segment = segment_end - segment_start
|
||||
segment_length = segment.length()
|
||||
distance = actual_stitch_length
|
||||
|
||||
if segment_length == 0:
|
||||
continue
|
||||
segment_start = section[0]
|
||||
for segment_end in section[1:]:
|
||||
segment = segment_end - segment_start
|
||||
segment_length = segment.length()
|
||||
|
||||
segment_direction = segment.unit()
|
||||
if distance < segment_length:
|
||||
segment_direction = segment.unit()
|
||||
|
||||
# corner detection
|
||||
if last_segment_direction:
|
||||
cos_angle_between = segment_direction * last_segment_direction
|
||||
while distance < segment_length:
|
||||
output.append(segment_start + distance * segment_direction)
|
||||
distance += actual_stitch_length
|
||||
|
||||
# This checks whether the corner is sharper than 45 degrees.
|
||||
if cos_angle_between < 0.5:
|
||||
# Only add the corner point if it's more than 0.1mm away to
|
||||
# avoid a double-stitch.
|
||||
if (segment_start - output[-1]).length() > 0.1:
|
||||
# add a stitch at the corner
|
||||
output.append(segment_start)
|
||||
distance -= segment_length
|
||||
segment_start = segment_end
|
||||
|
||||
# next stitch needs to be stitch_length along this segment
|
||||
distance = stitch_length
|
||||
|
||||
while distance < segment_length:
|
||||
output.append(segment_start + distance * segment_direction)
|
||||
distance += stitch_length
|
||||
|
||||
# prepare for the next segment
|
||||
segment_start = segment_end
|
||||
last_segment_direction = segment_direction
|
||||
distance -= segment_length
|
||||
|
||||
# stitch a single point if the path has a length of zero
|
||||
if not output:
|
||||
output.append(segment_start)
|
||||
|
||||
# stitch the last point unless we're already almost there
|
||||
if (segment_start - output[-1]).length() > 0.1 or len(output) == 0:
|
||||
output.append(segment_start)
|
||||
output.append(points[-1])
|
||||
|
||||
return output
|
||||
|
||||
|
|
|
@ -74,6 +74,12 @@ def get_correction_transform(node, child=False):
|
|||
|
||||
|
||||
def line_strings_to_csp(line_strings):
|
||||
try:
|
||||
# This lets us accept a MultiLineString or a list.
|
||||
line_strings = line_strings.geoms
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return point_lists_to_csp(ls.coords for ls in line_strings)
|
||||
|
||||
|
||||
|
|
108
lib/svg/tags.py
108
lib/svg/tags.py
|
@ -3,9 +3,10 @@
|
|||
# Copyright (c) 2010 Authors
|
||||
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
|
||||
|
||||
import inkex
|
||||
from lxml import etree
|
||||
|
||||
import inkex
|
||||
|
||||
etree.register_namespace("inkstitch", "http://inkstitch.org/namespace")
|
||||
inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace'
|
||||
|
||||
|
@ -48,55 +49,60 @@ SVG_OBJECT_TAGS = (SVG_ELLIPSE_TAG, SVG_CIRCLE_TAG, SVG_RECT_TAG)
|
|||
|
||||
INKSTITCH_ATTRIBS = {}
|
||||
inkstitch_attribs = [
|
||||
'ties',
|
||||
'force_lock_stitches',
|
||||
# clone
|
||||
'clone',
|
||||
# polyline
|
||||
'polyline',
|
||||
# fill
|
||||
'angle',
|
||||
'auto_fill',
|
||||
'expand_mm',
|
||||
'fill_underlay',
|
||||
'fill_underlay_angle',
|
||||
'fill_underlay_inset_mm',
|
||||
'fill_underlay_max_stitch_length_mm',
|
||||
'fill_underlay_row_spacing_mm',
|
||||
'fill_underlay_skip_last',
|
||||
'max_stitch_length_mm',
|
||||
'row_spacing_mm',
|
||||
'end_row_spacing_mm',
|
||||
'skip_last',
|
||||
'staggers',
|
||||
'underlay_underpath',
|
||||
'underpath',
|
||||
'flip',
|
||||
'expand_mm',
|
||||
# stroke
|
||||
'manual_stitch',
|
||||
'bean_stitch_repeats',
|
||||
'repeats',
|
||||
'running_stitch_length_mm',
|
||||
# satin column
|
||||
'satin_column',
|
||||
'running_stitch_length_mm',
|
||||
'center_walk_underlay',
|
||||
'center_walk_underlay_stitch_length_mm',
|
||||
'contour_underlay',
|
||||
'contour_underlay_stitch_length_mm',
|
||||
'contour_underlay_inset_mm',
|
||||
'zigzag_underlay',
|
||||
'zigzag_spacing_mm',
|
||||
'zigzag_underlay_inset_mm',
|
||||
'zigzag_underlay_spacing_mm',
|
||||
'zigzag_underlay_max_stitch_length_mm',
|
||||
'e_stitch',
|
||||
'pull_compensation_mm',
|
||||
'stroke_first',
|
||||
# Legacy
|
||||
'trim_after',
|
||||
'stop_after'
|
||||
]
|
||||
'ties',
|
||||
'force_lock_stitches',
|
||||
# clone
|
||||
'clone',
|
||||
# polyline
|
||||
'polyline',
|
||||
# fill
|
||||
'angle',
|
||||
'auto_fill',
|
||||
'fill_method',
|
||||
'contour_strategy',
|
||||
'join_style',
|
||||
'avoid_self_crossing',
|
||||
'clockwise',
|
||||
'expand_mm',
|
||||
'fill_underlay',
|
||||
'fill_underlay_angle',
|
||||
'fill_underlay_inset_mm',
|
||||
'fill_underlay_max_stitch_length_mm',
|
||||
'fill_underlay_row_spacing_mm',
|
||||
'fill_underlay_skip_last',
|
||||
'max_stitch_length_mm',
|
||||
'row_spacing_mm',
|
||||
'end_row_spacing_mm',
|
||||
'skip_last',
|
||||
'staggers',
|
||||
'underlay_underpath',
|
||||
'underpath',
|
||||
'flip',
|
||||
'expand_mm',
|
||||
# stroke
|
||||
'manual_stitch',
|
||||
'bean_stitch_repeats',
|
||||
'repeats',
|
||||
'running_stitch_length_mm',
|
||||
# satin column
|
||||
'satin_column',
|
||||
'running_stitch_length_mm',
|
||||
'center_walk_underlay',
|
||||
'center_walk_underlay_stitch_length_mm',
|
||||
'contour_underlay',
|
||||
'contour_underlay_stitch_length_mm',
|
||||
'contour_underlay_inset_mm',
|
||||
'zigzag_underlay',
|
||||
'zigzag_spacing_mm',
|
||||
'zigzag_underlay_inset_mm',
|
||||
'zigzag_underlay_spacing_mm',
|
||||
'zigzag_underlay_max_stitch_length_mm',
|
||||
'e_stitch',
|
||||
'pull_compensation_mm',
|
||||
'stroke_first',
|
||||
# Legacy
|
||||
'trim_after',
|
||||
'stop_after'
|
||||
]
|
||||
for attrib in inkstitch_attribs:
|
||||
INKSTITCH_ATTRIBS[attrib] = inkex.addNS(attrib, 'inkstitch')
|
||||
|
|
|
@ -15,7 +15,7 @@ class DotDict(dict):
|
|||
|
||||
def update(self, *args, **kwargs):
|
||||
super(DotDict, self).update(*args, **kwargs)
|
||||
self.dotdictify()
|
||||
self._dotdictify()
|
||||
|
||||
def _dotdictify(self):
|
||||
for k, v in self.items():
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import math
|
||||
|
||||
from shapely.geometry import LineString
|
||||
from shapely.geometry import LineString, LinearRing, MultiLineString, Polygon, MultiPolygon, GeometryCollection
|
||||
from shapely.geometry import Point as ShapelyPoint
|
||||
|
||||
|
||||
|
@ -39,6 +39,62 @@ def cut(line, distance, normalized=False):
|
|||
LineString([(cp.x, cp.y)] + coords[i:])]
|
||||
|
||||
|
||||
def roll_linear_ring(ring, distance, normalized=False):
|
||||
"""Make a linear ring start at a different point.
|
||||
|
||||
Example: A B C D E F G A -> D E F G A B C
|
||||
|
||||
Same linear ring, different ordering of the coordinates.
|
||||
"""
|
||||
|
||||
if not isinstance(ring, LinearRing):
|
||||
# In case they handed us a LineString
|
||||
ring = LinearRing(ring)
|
||||
|
||||
pieces = cut(LinearRing(ring), distance, normalized=False)
|
||||
|
||||
if None in pieces:
|
||||
# We cut exactly at the start or end.
|
||||
return ring
|
||||
|
||||
# The first and last point in a linear ring are duplicated, so we omit one
|
||||
# copy
|
||||
return LinearRing(pieces[1].coords[:] + pieces[0].coords[1:])
|
||||
|
||||
|
||||
def reverse_line_string(line_string):
|
||||
return LineString(line_string.coords[::-1])
|
||||
|
||||
|
||||
def ensure_multi_line_string(thing):
|
||||
"""Given either a MultiLineString or a single LineString, return a MultiLineString"""
|
||||
|
||||
if isinstance(thing, LineString):
|
||||
return MultiLineString([thing])
|
||||
else:
|
||||
return thing
|
||||
|
||||
|
||||
def ensure_geometry_collection(thing):
|
||||
"""Given either some kind of geometry or a GeometryCollection, return a GeometryCollection"""
|
||||
|
||||
if isinstance(thing, (MultiLineString, MultiPolygon)):
|
||||
return GeometryCollection(thing.geoms)
|
||||
elif isinstance(thing, GeometryCollection):
|
||||
return thing
|
||||
else:
|
||||
return GeometryCollection([thing])
|
||||
|
||||
|
||||
def ensure_multi_polygon(thing):
|
||||
"""Given either a MultiPolygon or a single Polygon, return a MultiPolygon"""
|
||||
|
||||
if isinstance(thing, Polygon):
|
||||
return MultiPolygon([thing])
|
||||
else:
|
||||
return thing
|
||||
|
||||
|
||||
def cut_path(points, length):
|
||||
"""Return a subsection of at the start of the path that is length units long.
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 235e5d044bc0913b2a01758935151e10d3e1db49
|
||||
Subproject commit 2ab0085cc997762ece7b9f96aef86457a205ca82
|
|
@ -19,6 +19,8 @@ stringcase
|
|||
tinycss2
|
||||
flask
|
||||
fonttools
|
||||
trimesh
|
||||
scipy
|
||||
|
||||
pywinutils; sys.platform == 'win32'
|
||||
pywin32; sys.platform == 'win32'
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<inkscape-extension translationdomain="inkstitch" xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||
<name>Selection to guide line</name>
|
||||
<id>org.inkstitch.selection_to_guide_line</id>
|
||||
<param name="extension" type="string" gui-hidden="true">selection_to_guide_line</param>
|
||||
<effect>
|
||||
<object-type>all</object-type>
|
||||
<effects-menu>
|
||||
<submenu name="Ink/Stitch">
|
||||
<submenu name="Edit" />
|
||||
</submenu>
|
||||
</effects-menu>
|
||||
</effect>
|
||||
<script>
|
||||
{{ command_tag | safe }}
|
||||
</script>
|
||||
</inkscape-extension>
|
Ładowanie…
Reference in New Issue