Merge pull request #1548 from inkstitch/feature_guided_fill

Feature guided fill
pull/1666/head
Lex Neva 2022-05-20 12:06:31 -04:00 zatwierdzone przez GitHub
commit 8ab4abf190
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
32 zmienionych plików z 1798 dodań i 722 usunięć

Wyświetl plik

@ -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'

Wyświetl plik

@ -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

Wyświetl plik

@ -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" \

Wyświetl plik

@ -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

8
bin/style-check 100755
Wyświetl plik

@ -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 "${@:-.}"

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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 = []

Wyświetl plik

@ -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__)

Wyświetl plik

@ -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]

Wyświetl plik

@ -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)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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))

Wyświetl plik

@ -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,

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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')

Wyświetl plik

@ -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 :(

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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')

Wyświetl plik

@ -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():

Wyświetl plik

@ -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

Wyświetl plik

@ -19,6 +19,8 @@ stringcase
tinycss2
flask
fonttools
trimesh
scipy
pywinutils; sys.platform == 'win32'
pywin32; sys.platform == 'win32'

Wyświetl plik

@ -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>