summaryrefslogtreecommitdiff
path: root/lib/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'lib/extensions')
-rw-r--r--lib/extensions/base.py2
-rw-r--r--lib/extensions/lettering_force_lock_stitches.py2
-rwxr-xr-xlib/extensions/params.py5
-rw-r--r--lib/extensions/stroke_to_satin.py319
4 files changed, 37 insertions, 291 deletions
diff --git a/lib/extensions/base.py b/lib/extensions/base.py
index 91afbc38..7ec98735 100644
--- a/lib/extensions/base.py
+++ b/lib/extensions/base.py
@@ -7,7 +7,7 @@ import os
import inkex
-from ..elements.utils import iterate_nodes, nodes_to_elements
+from ..elements import iterate_nodes, nodes_to_elements
from ..i18n import _
from ..metadata import InkStitchMetadata
from ..svg import generate_unique_id
diff --git a/lib/extensions/lettering_force_lock_stitches.py b/lib/extensions/lettering_force_lock_stitches.py
index 16f81227..a04fac79 100644
--- a/lib/extensions/lettering_force_lock_stitches.py
+++ b/lib/extensions/lettering_force_lock_stitches.py
@@ -6,7 +6,7 @@
import inkex
from shapely.geometry import Point
-from ..elements.utils import iterate_nodes, nodes_to_elements
+from ..elements import iterate_nodes, nodes_to_elements
from ..i18n import _
from ..marker import has_marker
from ..svg import PIXELS_PER_MM
diff --git a/lib/extensions/params.py b/lib/extensions/params.py
index 0bba13fe..fd4af28f 100755
--- a/lib/extensions/params.py
+++ b/lib/extensions/params.py
@@ -24,6 +24,7 @@ from ..gui import PresetsPanel, PreviewRenderer, WarningPanel
from ..gui.simulator import SplitSimulatorWindow
from ..i18n import _
from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import PIXELS_PER_MM
from ..svg.tags import EMBROIDERABLE_TAGS
from ..utils import get_resource_dir
from ..utils.param import ParamOption
@@ -724,9 +725,9 @@ class Params(InkstitchExtension):
if element.fill_color is not None and not element.get_style("fill-opacity", 1) == "0":
classes.append(FillStitch)
if element.stroke_color is not None:
- classes.append(Stroke)
- if len(element.path) > 1:
+ if len(element.path) > 1 or element.stroke_width >= 0.3 * PIXELS_PER_MM:
classes.append(SatinColumn)
+ classes.append(Stroke)
return classes
def get_nodes_by_class(self):
diff --git a/lib/extensions/stroke_to_satin.py b/lib/extensions/stroke_to_satin.py
index d9bf2ff0..fa360548 100644
--- a/lib/extensions/stroke_to_satin.py
+++ b/lib/extensions/stroke_to_satin.py
@@ -3,28 +3,19 @@
# Copyright (c) 2010 Authors
# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details.
-import math
-import sys
-from itertools import chain, groupby
+from itertools import chain
import inkex
-import numpy
-from numpy import diff, setdiff1d, sign
from shapely import geometry as shgeo
-from shapely.ops import substring
from ..elements import SatinColumn, Stroke
+from ..elements.utils.stroke_to_satin import convert_path_to_satin
from ..i18n import _
-from ..svg import PIXELS_PER_MM, get_correction_transform
-from ..svg.tags import INKSTITCH_ATTRIBS
-from ..utils import Point
+from ..svg import get_correction_transform
+from ..svg.styles import get_join_style_args
from .base import InkstitchExtension
-class SelfIntersectionError(Exception):
- pass
-
-
class StrokeToSatin(InkstitchExtension):
"""Convert a line to a satin column of the same width."""
@@ -36,296 +27,51 @@ class StrokeToSatin(InkstitchExtension):
inkex.errormsg(_("Please select at least one line to convert to a satin column."))
return
- if not any(isinstance(item, Stroke) for item in self.elements):
- # L10N: Convert To Satin extension, user selected one or more objects that were not lines.
- inkex.errormsg(_("Only simple lines may be converted to satin columns."))
- return
-
+ satin_converted = False
for element in self.elements:
- if not isinstance(element, Stroke):
+ if not isinstance(element, Stroke) and not (isinstance(element, SatinColumn) and len(element.paths) == 1):
continue
parent = element.node.getparent()
index = parent.index(element.node)
correction_transform = get_correction_transform(element.node)
- style_args = self.join_style_args(element)
+ style_args = get_join_style_args(element)
path_style = self.path_style(element)
for path in element.paths:
- path = self.remove_duplicate_points(self.fix_loop(path))
-
- if len(path) < 2:
- # ignore paths with just one point -- they're not visible to the user anyway
- continue
+ satin_paths = convert_path_to_satin(path, element.stroke_width, style_args)
- satins = list(self.convert_path_to_satins(path, element.stroke_width, style_args, path_style))
+ if satin_paths is not None:
+ rails, rungs = list(satin_paths)
+ rungs = self.filtered_rungs(rails, rungs)
- if satins:
- joined_satin = satins[0]
- for satin in satins[1:]:
- joined_satin = joined_satin.merge(satin)
-
- joined_satin.node.set('transform', correction_transform)
- parent.insert(index, joined_satin.node)
+ path_element = self.satin_to_svg_node(rails, rungs)
+ path_element.set('id', self.uniqueId("path"))
+ path_element.set('transform', correction_transform)
+ path_element.set('style', path_style)
+ parent.insert(index, path_element)
element.node.delete()
+ satin_converted = True
- def convert_path_to_satins(self, path, stroke_width, style_args, path_style, depth=0):
- try:
- rails, rungs = self.path_to_satin(path, stroke_width, style_args)
- yield SatinColumn(self.satin_to_svg_node(rails, rungs, path_style))
- except SelfIntersectionError:
- # The path intersects itself. Split it in two and try doing the halves
- # individually.
-
- if depth >= 20:
- # At this point we're slicing the path way too small and still
- # getting nowhere. Just give up on this section of the path.
- return
-
- halves = self.split_path(path)
-
- for path in halves:
- for satin in self.convert_path_to_satins(path, stroke_width, style_args, path_style, depth=depth + 1):
- yield satin
-
- def split_path(self, path):
- linestring = shgeo.LineString(path)
- halves = [
- list(substring(linestring, 0, 0.5, normalized=True).coords),
- list(substring(linestring, 0.5, 1, normalized=True).coords),
- ]
-
- return halves
-
- def fix_loop(self, path):
- if path[0] == path[-1] and len(path) > 1:
- first = Point.from_tuple(path[0])
- second = Point.from_tuple(path[1])
- midpoint = (first + second) / 2
- midpoint = midpoint.as_tuple()
-
- return [midpoint] + path[1:] + [path[0], midpoint]
- else:
- return path
-
- def remove_duplicate_points(self, path):
- path = [[round(coord, 4) for coord in point] for point in path]
- return [point for point, repeats in groupby(path)]
-
- def join_style_args(self, element):
- """Convert svg line join style to shapely offset_curve arguments."""
-
- args = {
- # mitre is the default per SVG spec
- 'join_style': shgeo.JOIN_STYLE.mitre
- }
-
- element_join_style = element.get_style('stroke-linejoin')
-
- if element_join_style is not None:
- if element_join_style == "miter":
- args['join_style'] = shgeo.JOIN_STYLE.mitre
-
- # 4 is the default per SVG spec
- miter_limit = float(element.get_style('stroke-miterlimit', 4))
- args['mitre_limit'] = miter_limit
- elif element_join_style == "bevel":
- args['join_style'] = shgeo.JOIN_STYLE.bevel
- elif element_join_style == "round":
- args['join_style'] = shgeo.JOIN_STYLE.round
-
- return args
-
- def path_to_satin(self, path, stroke_width, style_args):
- if Point(*path[0]).distance(Point(*path[-1])) < 1:
- raise SelfIntersectionError()
-
- path = shgeo.LineString(path)
- distance = stroke_width / 2.0
-
- try:
- left_rail = path.offset_curve(-distance, **style_args)
- right_rail = path.offset_curve(distance, **style_args)
- except ValueError:
- # TODO: fix this error automatically
- # Error reference: https://github.com/inkstitch/inkstitch/issues/964
- inkex.errormsg(_("Ink/Stitch cannot convert your stroke into a satin column. "
- "Please break up your path and try again.") + '\n')
- sys.exit(1)
-
- if left_rail.geom_type != 'LineString' or right_rail.geom_type != 'LineString':
- # If the offset curve come out as anything but a LineString, that means the
- # path intersects itself, when taking its stroke width into consideration.
- raise SelfIntersectionError()
-
- rungs = self.generate_rungs(path, stroke_width, left_rail, right_rail)
-
- left_rail = list(left_rail.coords)
- right_rail = list(right_rail.coords)
-
- return (left_rail, right_rail), rungs
-
- def get_scores(self, path):
- """Generate an array of "scores" of the sharpness of corners in a path
-
- A higher score means that there are sharper corners in that section of
- the path. We'll divide the path into boxes, with the score in each
- box indicating the sharpness of corners at around that percentage of
- the way through the path. For example, if scores[40] is 100 and
- scores[45] is 200, then the path has sharper corners at a spot 45%
- along its length than at a spot 40% along its length.
- """
-
- # need 101 boxes in order to encompass percentages from 0% to 100%
- scores = numpy.zeros(101, numpy.int32)
- path_length = path.length
-
- prev_point = None
- prev_direction = None
- length_so_far = 0
- for point in path.coords:
- point = Point(*point)
-
- if prev_point is None:
- prev_point = point
- continue
-
- direction = (point - prev_point).unit()
-
- if prev_direction is not None:
- # The dot product of two vectors is |v1| * |v2| * cos(angle).
- # These are unit vectors, so their magnitudes are 1.
- cos_angle_between = prev_direction * direction
-
- # Clamp to the valid range for a cosine. The above _should_
- # already be in this range, but floating point inaccuracy can
- # push it outside the range causing math.acos to throw
- # ValueError ("math domain error").
- cos_angle_between = max(-1.0, min(1.0, cos_angle_between))
-
- angle = abs(math.degrees(math.acos(cos_angle_between)))
-
- # Use the square of the angle, measured in degrees.
- #
- # Why the square? This penalizes bigger angles more than
- # smaller ones.
- #
- # Why degrees? This is kind of arbitrary but allows us to
- # use integer math effectively and avoid taking the square
- # of a fraction between 0 and 1.
- scores[int(round(length_so_far / path_length * 100.0))] += angle ** 2
-
- length_so_far += (point - prev_point).length()
- prev_direction = direction
- prev_point = point
-
- return scores
-
- def local_minima(self, array):
- # from: https://stackoverflow.com/a/9667121/4249120
- # This finds spots where the curvature (second derivative) is > 0.
- #
- # This method has the convenient benefit of choosing points around
- # 5% before and after a sharp corner such as in a square.
- return (diff(sign(diff(array))) > 0).nonzero()[0] + 1
-
- def generate_rungs(self, path, stroke_width, left_rail, right_rail):
- """Create rungs for a satin column.
-
- Where should we put the rungs along a path? We want to ensure that the
- resulting satin matches the original path as closely as possible. We
- want to avoid having a ton of rungs that will annoy the user. We want
- to ensure that the rungs we choose actually intersect both rails.
-
- We'll place a few rungs perpendicular to the tangent of the path.
- Things get pretty tricky at sharp corners. If we naively place a rung
- perpendicular to the path just on either side of a sharp corner, the
- rung may not intersect both paths:
- | |
- _______________| |
- ______|_
- ____________________|
-
- It'd be best to place rungs in the straight sections before and after
- the sharp corner and allow the satin column to bend the stitches around
- the corner automatically.
-
- How can we find those spots?
-
- The general algorithm below is:
-
- * assign a "score" to each section of the path based on how sharp its
- corners are (higher means a sharper corner)
- * pick spots with lower scores
- """
-
- scores = self.get_scores(path)
-
- # This is kind of like a 1-dimensional gaussian blur filter. We want to
- # avoid the area near a sharp corner, so we spread out its effect for
- # 5 buckets in either direction.
- scores = numpy.convolve(scores, [1, 2, 4, 8, 16, 8, 4, 2, 1], mode='same')
-
- # Now we'll find the spots that aren't near corners, whose scores are
- # low -- the local minima.
- rung_locations = self.local_minima(scores)
-
- # Remove the start and end, because we can't stick a rung there.
- rung_locations = setdiff1d(rung_locations, [0, 100])
-
- if len(rung_locations) == 0:
- # Straight lines won't have local minima, so add a rung in the center.
- rung_locations = [50]
-
- rungs = []
- last_rung_center = None
-
- for location in rung_locations:
- # Convert percentage to a fraction so that we can use interpolate's
- # normalized parameter.
- location = location / 100.0
-
- rung_center = path.interpolate(location, normalized=True)
- rung_center = Point(rung_center.x, rung_center.y)
-
- # Avoid placing rungs too close together. This somewhat
- # arbitrarily rejects the rung if there was one less than 2
- # millimeters before this one.
- if last_rung_center is not None and \
- (rung_center - last_rung_center).length() < 2 * PIXELS_PER_MM:
- continue
- else:
- last_rung_center = rung_center
-
- # We need to know the tangent of the path's curve at this point.
- # Pick another point just after this one and subtract them to
- # approximate a tangent vector.
- tangent_end = path.interpolate(location + 0.001, normalized=True)
- tangent_end = Point(tangent_end.x, tangent_end.y)
- tangent = (tangent_end - rung_center).unit()
-
- # Rotate 90 degrees left to make a normal vector.
- normal = tangent.rotate_left()
-
- # Extend the rungs by an offset value to make sure they will cross the rails
- offset = normal * (stroke_width / 2) * 1.2
- rung_start = rung_center + offset
- rung_end = rung_center - offset
-
- rung_tuple = (rung_start.as_tuple(), rung_end.as_tuple())
- rung_linestring = shgeo.LineString(rung_tuple)
- if (isinstance(rung_linestring.intersection(left_rail), shgeo.Point) and
- isinstance(rung_linestring.intersection(right_rail), shgeo.Point)):
- rungs.append(rung_tuple)
+ if not satin_converted:
+ # L10N: Convert To Satin extension, user selected only objects that were not lines.
+ inkex.errormsg(_("Only simple lines may be converted to satin columns."))
- return rungs
+ def filtered_rungs(self, rails, rungs):
+ rails = shgeo.MultiLineString(rails)
+ filtered_rungs = []
+ for rung in shgeo.MultiLineString(rungs).geoms:
+ intersection = rung.intersection(rails)
+ if intersection.geom_type == "MultiPoint" and len(intersection.geoms) == 2:
+ filtered_rungs.append(list(rung.coords))
+ return filtered_rungs
def path_style(self, element):
color = element.get_style('stroke', '#000000')
return "stroke:%s;stroke-width:1px;fill:none" % (color)
- def satin_to_svg_node(self, rails, rungs, path_style):
+ def satin_to_svg_node(self, rails, rungs):
d = ""
for path in chain(rails, rungs):
d += "M"
@@ -333,9 +79,8 @@ class StrokeToSatin(InkstitchExtension):
d += "%s,%s " % (x, y)
d += " "
- return inkex.PathElement(attrib={
- "id": self.uniqueId("path"),
- "style": path_style,
+ path_element = inkex.PathElement(attrib={
"d": d,
- INKSTITCH_ATTRIBS['satin_column']: "true",
})
+ path_element.set("inkstitch:satin_column", True)
+ return path_element