From 077f7ea72ba38790bf030d3181c44787fef953b6 Mon Sep 17 00:00:00 2001 From: Kaalleen <36401965+kaalleen@users.noreply.github.com> Date: Tue, 6 Aug 2019 04:42:48 +0200 Subject: [PATCH] add Troubleshoot extension (#465) adds an extension to help you understand what's wrong with an object and how to fix it, e.g. "invalid" fill shapes --- lib/commands.py | 19 ++++ lib/elements/auto_fill.py | 24 +++-- lib/elements/element.py | 37 ++++++- lib/elements/fill.py | 54 ++++++++-- lib/elements/polyline.py | 21 +++- lib/elements/satin_column.py | 121 +++++++++++++++-------- lib/elements/stroke.py | 7 +- lib/elements/validation.py | 41 ++++++++ lib/extensions/__init__.py | 4 +- lib/extensions/troubleshoot.py | 176 +++++++++++++++++++++++++++++++++ lib/stitches/auto_fill.py | 44 +++++---- lib/stitches/running_stitch.py | 1 - lib/svg/tags.py | 3 + lib/utils/geometry.py | 4 + templates/troubleshoot.inx | 18 ++++ 15 files changed, 487 insertions(+), 87 deletions(-) create mode 100644 lib/elements/validation.py create mode 100644 lib/extensions/troubleshoot.py create mode 100644 templates/troubleshoot.inx diff --git a/lib/commands.py b/lib/commands.py index c735185f6..908c6e530 100644 --- a/lib/commands.py +++ b/lib/commands.py @@ -371,3 +371,22 @@ def add_commands(element, commands): pos = get_command_pos(element, i, len(commands)) symbol = add_symbol(document, group, command, pos) add_connector(document, symbol, element) + + +def add_layer_commands(layer, commands): + document = get_document(layer) + correction_transform = get_correction_transform(layer) + + for command in commands: + ensure_symbol(document, command) + inkex.etree.SubElement(layer, SVG_USE_TAG, + { + "id": generate_unique_id(document, "use"), + INKSCAPE_LABEL: _("Ink/Stitch Command") + ": %s" % get_command_description(command), + XLINK_HREF: "#inkstitch_%s" % command, + "height": "100%", + "width": "100%", + "x": "0", + "y": "-10", + "transform": correction_transform + }) diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py index 62d3493cc..a37078b89 100644 --- a/lib/elements/auto_fill.py +++ b/lib/elements/auto_fill.py @@ -4,13 +4,21 @@ import traceback from shapely import geometry as shgeo -from ..exceptions import InkstitchException from ..i18n import _ from ..stitches import auto_fill from ..utils import cache -from .element import param, Patch +from .element import Patch, param from .fill import Fill +from .validation import 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 AutoFill(Fill): element_name = _("AutoFill") @@ -208,10 +216,7 @@ class AutoFill(Fill): starting_point, ending_point, self.underpath)) - except InkstitchException, exc: - # for one of our exceptions, just print the message - self.fatal(_("Unable to autofill: ") + str(exc)) - except Exception, exc: + except Exception: if hasattr(sys, 'gettrace') and sys.gettrace(): # if we're debugging, let the exception bubble up raise @@ -228,3 +233,10 @@ class AutoFill(Fill): self.fatal(message) return [Patch(stitches=stitches, color=self.color)] + + def validation_warnings(self): + if self.shape.area < 20: + yield SmallShapeWarning(self.shape.centroid) + + for warning in super(AutoFill, self).validation_warnings(): + yield warning diff --git a/lib/elements/element.py b/lib/elements/element.py index e85657cd1..dd6c9063d 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -7,7 +7,7 @@ import simplestyle from ..commands import find_commands from ..i18n import _ -from ..svg import PIXELS_PER_MM, convert_length, get_doc_size, apply_transforms +from ..svg import PIXELS_PER_MM, apply_transforms, convert_length, get_doc_size from ..svg.tags import INKSCAPE_LABEL from ..utils import cache @@ -265,6 +265,8 @@ class EmbroideryElement(object): raise NotImplementedError("%s must implement to_patches()" % self.__class__.__name__) def embroider(self, last_patch): + self.validate() + patches = self.to_patches(last_patch) if patches: @@ -286,3 +288,36 @@ class EmbroideryElement(object): error_msg = "%s: %s %s" % (name, _("error:"), message) print >> sys.stderr, "%s" % (error_msg.encode("UTF-8")) sys.exit(1) + + def validation_errors(self): + """Return a list of errors with this Element. + + Validation errors will prevent the Element from being stitched. + + Return value: an iterable or generator of instances of subclasses of ValidationError + """ + return [] + + def validation_warnings(self): + """Return a list of warnings about this Element. + + Validation warnings don't prevent the Element from being stitched but + the user should probably fix them anyway. + + Return value: an iterable or generator of instances of subclasses of ValidationWarning + """ + return [] + + def is_valid(self): + # We have to iterate since it could be a generator. + for error in self.validation_errors(): + return False + + return True + + def validate(self): + """Print an error message and exit if this Element is invalid.""" + + for error in self.validation_errors(): + # note that self.fatal() exits, so this only shows the first error + self.fatal(error.description) diff --git a/lib/elements/fill.py b/lib/elements/fill.py index 7ccf7b27e..7157cc460 100644 --- a/lib/elements/fill.py +++ b/lib/elements/fill.py @@ -1,4 +1,6 @@ +import logging import math +import re from shapely import geometry as shgeo from shapely.validation import explain_validity @@ -7,7 +9,29 @@ from ..i18n import _ from ..stitches import legacy_fill from ..svg import PIXELS_PER_MM from ..utils import cache -from .element import param, EmbroideryElement, Patch +from .element import EmbroideryElement, Patch, param +from .validation import ValidationError + + +class UnconnectedError(ValidationError): + name = _("Unconnected") + description = _("Fill: This object is made up of unconnected shapes. This is not allowed because " + "Ink/Stitch doesn't know what order to stitch them in. Please break this " + "object up into separate shapes.") + steps_to_solve = [ + _('* Path > Break apart (Shift+Ctrl+K)'), + _('* (Optional) Recombine shapes with holes (Ctrl+K).') + ] + + +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 = [ + _('* Path > Union (Ctrl++)'), + _('* Path > Break apart (Shift+Ctrl+K)'), + _('* (Optional) Recombine shapes with holes (Ctrl+K).') + ] class Fill(EmbroideryElement): @@ -112,18 +136,28 @@ class Fill(EmbroideryElement): paths.sort(key=lambda point_list: shgeo.Polygon(point_list).area, reverse=True) polygon = shgeo.MultiPolygon([(paths[0], paths[1:])]) - if not polygon.is_valid: - why = explain_validity(polygon) + return polygon + + def validation_errors(self): + # 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 = self.shape.is_valid + + logger.setLevel(level) + + if not valid: + 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 why: - self.fatal(_("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.")) + if "Hole lies outside shell" in message: + yield UnconnectedError((x, y)) else: - self.fatal(_("shape is not valid. This can happen if the border crosses over itself.")) - - return polygon + yield InvalidShapeError((x, y)) def to_patches(self, last_patch): stitch_lists = legacy_fill(self.shape, diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index 5bfe50222..a9870172b 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -1,8 +1,22 @@ from shapely import geometry as shgeo -from .element import EmbroideryElement, Patch -from ..utils.geometry import Point +from ..i18n import _ from ..utils import cache +from ..utils.geometry import Point +from .element import EmbroideryElement, Patch +from .validation import ValidationWarning + + +class PolylineWarning(ValidationWarning): + name = _("Object is a PolyLine") + description = _("This object is an SVG PolyLine. Ink/Stitch can work with this shape, " + "but you can't edit it in Inkscape. Convert it to a manual stitch path " + "to allow editing.") + steps_to_solve = [ + _("* Select this object."), + _("* Do Path > Object to Path."), + _('* Optional: Run the Params extension and check the "manual stitch" box.') + ] class Polyline(EmbroideryElement): @@ -70,6 +84,9 @@ class Polyline(EmbroideryElement): return stitches + def validation_warnings(self): + yield PolylineWarning(self.points[0]) + def to_patches(self, last_patch): patch = Patch(color=self.color) diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index d3c4d3d3a..ccad234ab 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -7,7 +7,54 @@ from shapely import geometry as shgeo, affinity as shaffinity from ..i18n import _ from ..svg import line_strings_to_csp, point_lists_to_csp from ..utils import cache, Point, cut, collapse_duplicate_point + from .element import param, EmbroideryElement, Patch +from .validation import ValidationError + + +class SatinHasFillError(ValidationError): + name = _("Satin column has fill") + description = _("Satin column: Object has a fill (but should not)") + steps_to_solve = [ + _("* Select this object."), + _("* Open the Fill and Stroke panel"), + _("* Open the Fill tab"), + _("* Disable the Fill"), + _("* Alternative: open Params and switch this path to Stroke to disable Satin Column mode") + ] + + +class TooFewPathsError(ValidationError): + name = _("Too few subpaths") + description = _("Satin column: Object has too few subpaths. A satin column should have at least two subpaths (the rails).") + steps_to_solve = [ + _("* Add another subpath (select two rails and do Path > Combine)"), + _("* Convert to running stitch or simple satin (Params extension)") + ] + + +class UnequalPointsError(ValidationError): + name = _("Unequal number of points") + description = _("Satin column: There are no rungs and rails have an an unequal number of points.") + steps_to_solve = [ + _('The easiest way to solve this issue is to add one or more rungs. '), + _('Rungs control the stitch direction in satin columns.'), + _('* With the selected object press "P" to activate the pencil tool.'), + _('* Hold "Shift" while drawing the rung.') + ] + + +rung_message = _("Each rung should intersect both rails once.") + + +class DanglingRungError(ValidationError): + name = _("Rung doesn't intersect rails") + description = _("Satin column: A rung doesn't intersect both rails.") + " " + rung_message + + +class TooManyIntersectionsError(ValidationError): + name = _("Rung intersects too many times") + description = _("Satin column: A rung intersects a rail more than once.") + " " + rung_message class SatinColumn(EmbroideryElement): @@ -146,13 +193,25 @@ class SatinColumn(EmbroideryElement): @property @cache def rails(self): - """The rails in order, as LineStrings""" + """The rails in order, as point lists""" return [subpath for i, subpath in enumerate(self.csp) if i in self.rail_indices] + @property + @cache + def flattened_rails(self): + """The rails, as LineStrings.""" + return tuple(shgeo.LineString(self.flatten_subpath(rail)) for rail in self.rails) + + @property + @cache + def flattened_rungs(self): + """The rungs, as LineStrings.""" + return tuple(shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs) + @property @cache def rungs(self): - """The rungs, as LineStrings. + """The rungs, as point lists. If there are no rungs, then this is an old-style satin column. The rails are expected to have the same number of path nodes. The path @@ -238,8 +297,6 @@ class SatinColumn(EmbroideryElement): return indices_by_length[:2] def _cut_rail(self, rail, rung): - intersections = 0 - for segment_index, rail_segment in enumerate(rail[:]): if rail_segment is None: continue @@ -252,14 +309,6 @@ class SatinColumn(EmbroideryElement): intersection = collapse_duplicate_point(intersection) if not intersection.is_empty: - if isinstance(intersection, shgeo.MultiLineString): - intersections += len(intersection) - break - elif not isinstance(intersection, shgeo.Point): - self.fatal("INTERNAL ERROR: intersection is: %s %s" % (intersection, getattr(intersection, 'geoms', None))) - else: - intersections += 1 - cut_result = cut(rail_segment, rail_segment.project(intersection)) rail[segment_index:segment_index + 1] = cut_result @@ -269,29 +318,17 @@ class SatinColumn(EmbroideryElement): # segment break - return intersections - @property @cache def flattened_sections(self): """Flatten the rails, cut with the rungs, and return the sections in pairs.""" - if len(self.csp) < 2: - self.fatal(_("satin column: %(id)s: at least two subpaths required (%(num)d found)") % dict(num=len(self.csp), id=self.node.get('id'))) - - rails = [[shgeo.LineString(self.flatten_subpath(rail))] for rail in self.rails] - rungs = [shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs] + rails = [[rail] for rail in self.flattened_rails] + rungs = self.flattened_rungs for rung in rungs: - for rail_index, rail in enumerate(rails): - intersections = self._cut_rail(rail, rung) - - if intersections == 0: - self.fatal(_("satin column: One or more of the rungs doesn't intersect both rails.") + - " " + _("Each rail should intersect both rungs once.")) - elif intersections > 1: - self.fatal(_("satin column: One or more of the rungs intersects the rails more than once.") + - " " + _("Each rail should intersect both rungs once.")) + for rail in rails: + self._cut_rail(rail, rung) for rail in rails: for i in xrange(len(rail)): @@ -314,24 +351,27 @@ class SatinColumn(EmbroideryElement): return sections - def validate_satin_column(self): + def validation_errors(self): # The node should have exactly two paths with no fill. Each # path should have the same number of points, meaning that they # will both be made up of the same number of bezier curves. - node_id = self.node.get("id") - if self.get_style("fill") is not None: - self.fatal(_("satin column: object %s has a fill (but should not)") % node_id) - - if not self.rungs: - if len(self.rails) < 2: - self.fatal(_("satin column: object %(id)s has too few paths. A satin column should have at least two paths (the rails).") % - dict(id=node_id)) + yield SatinHasFillError(self.shape.centroid) + if len(self.rails) < 2: + yield TooFewPathsError(self.shape.centroid) + elif len(self.csp) == 2: if len(self.rails[0]) != len(self.rails[1]): - self.fatal(_("satin column: object %(id)s has two paths with an unequal number of points (%(length1)d and %(length2)d)") % - dict(id=node_id, length1=len(self.rails[0]), length2=len(self.rails[1]))) + yield UnequalPointsError(self.flattened_rails[0].interpolate(0.5, normalized=True)) + else: + for rung in self.flattened_rungs: + for rail in self.flattened_rails: + intersection = rung.intersection(rail) + if intersection.is_empty: + yield DanglingRungError(rung.interpolate(0.5, normalized=True)) + elif not isinstance(intersection, shgeo.Point): + yield TooManyIntersectionsError(rung.interpolate(0.5, normalized=True)) def reverse(self): """Return a new SatinColumn like this one but in the opposite direction. @@ -772,9 +812,6 @@ class SatinColumn(EmbroideryElement): # beziers. The boundary points between beziers serve as "checkpoints", # allowing the user to control how the zigzags flow around corners. - # First, verify that we have valid paths. - self.validate_satin_column() - patch = Patch(color=self.color) if self.center_walk_underlay: diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index e0a0aaccb..4828bf65f 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -3,11 +3,10 @@ import sys import shapely.geometry from ..i18n import _ -from ..stitches import running_stitch, bean_stitch +from ..stitches import bean_stitch, running_stitch from ..svg import parse_length_with_units -from ..utils import cache, Point -from .element import param, EmbroideryElement, Patch - +from ..utils import Point, cache +from .element import EmbroideryElement, Patch, param warned_about_legacy_running_stitch = False diff --git a/lib/elements/validation.py b/lib/elements/validation.py new file mode 100644 index 000000000..41098922d --- /dev/null +++ b/lib/elements/validation.py @@ -0,0 +1,41 @@ +from shapely.geometry import Point as ShapelyPoint + +from ..utils import Point as InkstitchPoint + + +class ValidationMessage(object): + '''Holds information about a problem with an element. + + Attributes: + name - A short descriptor for the problem, such as "dangling rung" + description - A detailed description of the problem, such as + "One or more rungs does not intersect both rails." + position - An optional position where the problem occurs, + to aid the user in correcting it. type: Point or tuple of (x, y) + steps_to_solve - A list of operations necessary to solve the problem + ''' + + # Subclasses will fill these in. + name = None + description = None + steps_to_solve = [] + + def __init__(self, position=None): + if isinstance(position, ShapelyPoint): + position = (position.x, position.y) + + self.position = InkstitchPoint(*position) + + +class ValidationError(ValidationMessage): + """A problem that will prevent the shape from being embroidered.""" + pass + + +class ValidationWarning(ValidationMessage): + """A problem that won't prevent a shape from being embroidered. + + The user will almost certainly want to fix the warning, but if they + don't, Ink/Stitch will do its best to process the object. + """ + pass diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index 741973ab2..d6d0e279f 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -2,6 +2,7 @@ from auto_satin import AutoSatin from convert_to_satin import ConvertToSatin from cut_satin import CutSatin from embroider import Embroider +from lib.extensions.troubleshoot import Troubleshoot from flip import Flip from global_commands import GlobalCommands from input import Input @@ -31,4 +32,5 @@ __all__ = extensions = [Embroider, ConvertToSatin, CutSatin, AutoSatin, - Lettering] + Lettering, + Troubleshoot] diff --git a/lib/extensions/troubleshoot.py b/lib/extensions/troubleshoot.py new file mode 100644 index 000000000..b67a5dc16 --- /dev/null +++ b/lib/extensions/troubleshoot.py @@ -0,0 +1,176 @@ +from itertools import chain +import textwrap + +import inkex + +from ..commands import add_layer_commands +from ..elements.validation import ValidationWarning, ValidationError +from ..i18n import _ +from ..svg import get_correction_transform +from ..svg.tags import (INKSCAPE_GROUPMODE, INKSCAPE_LABEL, + SODIPODI_ROLE, SVG_GROUP_TAG, SVG_PATH_TAG, + SVG_TEXT_TAG, SVG_TSPAN_TAG) +from .base import InkstitchExtension + + +class Troubleshoot(InkstitchExtension): + + def effect(self): + if not self.get_elements(): + return + + self.create_troubleshoot_layer() + + problem_types = set() + for element in self.elements: + for problem in chain(element.validation_errors(), element.validation_warnings()): + problem_types.add(type(problem)) + self.insert_pointer(problem) + + if problem_types: + self.add_descriptions(problem_types) + else: + svg = self.document.getroot() + svg.remove(self.troubleshoot_layer) + + message = _("All selected shapes are valid!") + message += "\n\n" + message += _("Tip: If you are still having an issue with an object not being rendered, " + "you might need to convert it it to a path (Path -> Object to Path) or check if it is possibly in an ignored layer.") + + inkex.errormsg(message) + + def insert_pointer(self, problem): + correction_transform = get_correction_transform(self.troubleshoot_layer) + + if isinstance(problem, ValidationWarning): + fill_color = "#ffdd00" + layer = self.warning_group + elif isinstance(problem, ValidationError): + fill_color = "#ff0000" + layer = self.error_group + + pointer_style = "stroke:#ffffff;stroke-width:0.2;fill:%s;" % (fill_color) + text_style = "fill:%s;stroke:#ffffff;stroke-width:0.2;font-size:8px;text-align:center;text-anchor:middle" % (fill_color) + + path = inkex.etree.Element( + SVG_PATH_TAG, + { + "id": self.uniqueId("inkstitch__invalid_pointer__"), + "d": "m %s,%s 4,20 h -8 l 4,-20" % (problem.position.x, problem.position.y), + "style": pointer_style, + INKSCAPE_LABEL: _('Invalid Pointer'), + "transform": correction_transform + } + ) + layer.insert(0, path) + + text = inkex.etree.Element( + SVG_TEXT_TAG, + { + INKSCAPE_LABEL: _('Description'), + "x": str(problem.position.x), + "y": str(float(problem.position.y) + 30), + "transform": correction_transform, + "style": text_style + } + ) + layer.append(text) + + tspan = inkex.etree.Element(SVG_TSPAN_TAG) + tspan.text = problem.name + text.append(tspan) + + def create_troubleshoot_layer(self): + svg = self.document.getroot() + layer = svg.find(".//*[@id='__validation_layer__']") + + if layer is None: + layer = inkex.etree.Element( + SVG_GROUP_TAG, + { + 'id': '__validation_layer__', + INKSCAPE_LABEL: _('Troubleshoot'), + INKSCAPE_GROUPMODE: 'layer', + }) + svg.append(layer) + + else: + # Clear out everything from the last run + del layer[:] + + add_layer_commands(layer, ["ignore_layer"]) + + error_group = inkex.etree.SubElement( + layer, + SVG_GROUP_TAG, + { + "id": '__validation_errors__', + INKSCAPE_LABEL: _("Errors"), + }) + layer.append(error_group) + + warning_group = inkex.etree.SubElement( + layer, + SVG_GROUP_TAG, + { + "id": '__validation_warnings__', + INKSCAPE_LABEL: _("Warnings"), + }) + layer.append(warning_group) + + self.troubleshoot_layer = layer + self.error_group = error_group + self.warning_group = warning_group + + def add_descriptions(self, problem_types): + svg = self.document.getroot() + text_x = str(self.unittouu(svg.get('width')) + 5) + + text_container = inkex.etree.Element( + SVG_TEXT_TAG, + { + "x": text_x, + "y": str(5), + "style": "fill:#000000;font-size:5px;line-height:1;" + } + ) + self.troubleshoot_layer.append(text_container) + + text = [ + [_("Troubleshoot"), "font-weight: bold; font-size: 6px;"], + ["", ""] + ] + + for problem in problem_types: + text_color = "#ff0000" + if issubclass(problem, ValidationWarning): + text_color = "#ffdd00" + + text.append([problem.name, "font-weight: bold; fill:%s;" % text_color]) + description_parts = textwrap.wrap(problem.description, 60) + for description in description_parts: + text.append([description, "font-size: 3px;"]) + text.append(["", ""]) + for step in problem.steps_to_solve: + text.append([step, "font-size: 4px;"]) + text.append(["", ""]) + + explain_layer = _('It is possible, that one object contains more than one error, ' + + 'yet there will be only one pointer per object. Run this function again, ' + + 'when further errors occur. Remove pointers by deleting the layer named ' + '"Troubleshoot" through the objects panel (Object -> Objects...).') + explain_layer_parts = textwrap.wrap(explain_layer, 60) + for description in explain_layer_parts: + text.append([description, "font-style: italic; font-size: 3px;"]) + + for text_line in text: + tspan = inkex.etree.Element( + SVG_TSPAN_TAG, + { + SODIPODI_ROLE: "line", + "style": text_line[1] + } + ) + tspan.text = text_line[0] + text_container.append(tspan) diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 8c8cdefd1..5833b779f 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -9,18 +9,12 @@ from shapely.ops import snap from shapely.strtree import STRtree from ..debug import debug -from ..exceptions import InkstitchException -from ..i18n import _ from ..svg import PIXELS_PER_MM -from ..utils.geometry import Point as InkstitchPoint +from ..utils.geometry import Point as InkstitchPoint, line_string_to_point_list from .fill import intersect_region_with_grating, stitch_row from .running_stitch import running_stitch -class InvalidPath(InkstitchException): - pass - - class PathEdge(object): OUTLINE_KEYS = ("outline", "extra", "initial") SEGMENT_KEY = "segment" @@ -60,7 +54,10 @@ def auto_fill(shape, underpath=True): fill_stitch_graph = build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing) - check_graph(fill_stitch_graph, shape, max_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, angle, row_spacing, @@ -188,6 +185,25 @@ def add_edges_between_outline_nodes(graph, duplicate_every_other=False): graph.add_edge(node1, node2, key="extra", **data) +def graph_is_valid(graph, shape, max_stitch_length): + # The graph may be empty if the shape is so small that it fits between the + # rows of stitching. Certain small weird shapes can also cause a non- + # eulerian graph. + return not networkx.is_empty(graph) and networkx.is_eulerian(graph) + + +def fallback(shape, running_stitch_length): + """Generate stitches when the auto-fill algorithm fails. + + If graph_is_valid() returns False, we're not going to be able to run the + auto-fill algorithm. Instead, we'll just do running stitch around the + outside of the shape. In all likelihood, the shape is so small it won't + matter. + """ + + return running_stitch(line_string_to_point_list(shape.boundary[0]), running_stitch_length) + + @debug.time def build_travel_graph(fill_stitch_graph, shape, fill_stitch_angle, underpath): """Build a graph for travel stitches. @@ -376,18 +392,6 @@ def build_travel_edges(shape, fill_angle): return endpoints, chain(diagonal_edges, vertical_edges) -def check_graph(graph, shape, max_stitch_length): - if networkx.is_empty(graph) or not networkx.is_eulerian(graph): - if shape.area < max_stitch_length ** 2: - message = "This shape is so small that it cannot be filled with rows of stitches. " \ - "It would probably look best as a satin column or running stitch." - raise InvalidPath(_(message)) - else: - message = "Cannot parse shape. " \ - "This most often happens because your shape is made up of multiple sections that aren't connected." - raise InvalidPath(_(message)) - - def nearest_node(nodes, point, attr=None): point = shgeo.Point(*point) nearest = min(nodes, key=lambda node: shgeo.Point(*node).distance(point)) diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py index fa8c50ba2..0bb8fc7d7 100644 --- a/lib/stitches/running_stitch.py +++ b/lib/stitches/running_stitch.py @@ -1,6 +1,5 @@ from copy import copy - """ Utility functions to produce running stitches. """ diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 84509f90c..55af113a3 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -6,6 +6,8 @@ inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' SVG_PATH_TAG = inkex.addNS('path', 'svg') SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') +SVG_TEXT_TAG = inkex.addNS('text', 'svg') +SVG_TSPAN_TAG = inkex.addNS('tspan', 'svg') SVG_DEFS_TAG = inkex.addNS('defs', 'svg') SVG_GROUP_TAG = inkex.addNS('g', 'svg') SVG_SYMBOL_TAG = inkex.addNS('symbol', 'svg') @@ -19,6 +21,7 @@ CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape') XLINK_HREF = inkex.addNS('href', 'xlink') SODIPODI_NAMEDVIEW = inkex.addNS('namedview', 'sodipodi') SODIPODI_GUIDE = inkex.addNS('guide', 'sodipodi') +SODIPODI_ROLE = inkex.addNS('role', 'sodipodi') INKSTITCH_LETTERING = inkex.addNS('lettering', 'inkstitch') EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 05cfc4b28..647636c89 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -128,3 +128,7 @@ class Point: def __len__(self): return 2 + + +def line_string_to_point_list(line_string): + return [Point(*point) for point in line_string.coords] diff --git a/templates/troubleshoot.inx b/templates/troubleshoot.inx new file mode 100644 index 000000000..6931fb39f --- /dev/null +++ b/templates/troubleshoot.inx @@ -0,0 +1,18 @@ + + + {% trans %}Troubleshoot Objects{% endtrans %} + org.inkstitch.troubleshoot.{{ locale }} + inkstitch.py + inkex.py + troubleshoot + + all + + + + + + +