summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/commands.py8
-rw-r--r--lib/elements/element.py6
-rw-r--r--lib/elements/satin_column.py315
-rw-r--r--lib/extensions/__init__.py4
-rw-r--r--lib/extensions/cut_satin.py37
-rw-r--r--lib/extensions/flip.py13
-rw-r--r--lib/stitches/auto_fill.py4
-rw-r--r--lib/svg/__init__.py2
-rw-r--r--lib/svg/path.py13
-rw-r--r--lib/utils/geometry.py10
10 files changed, 337 insertions, 75 deletions
diff --git a/lib/commands.py b/lib/commands.py
index db3c8a71..df82a8c4 100644
--- a/lib/commands.py
+++ b/lib/commands.py
@@ -24,6 +24,10 @@ COMMANDS = {
# L10N command attached to an object
N_("ignore_object"): N_("Ignore this object (do not stitch)"),
+ # L10N command attached to an object
+ N_("satin_cut_point"): N_("Satin cut point (use with Cut Satin Column)"),
+
+
# L10N command that affects a layer
N_("ignore_layer"): N_("Ignore layer (do not stitch any objects in this layer)"),
@@ -34,7 +38,7 @@ COMMANDS = {
N_("stop_position"): N_("Jump destination for Stop commands (a.k.a. \"Frame Out position\")."),
}
-OBJECT_COMMANDS = ["fill_start", "fill_end", "stop", "trim", "ignore_object"]
+OBJECT_COMMANDS = ["fill_start", "fill_end", "stop", "trim", "ignore_object", "satin_cut_point"]
LAYER_COMMANDS = ["ignore_layer"]
GLOBAL_COMMANDS = ["origin", "stop_position"]
@@ -101,6 +105,8 @@ class Command(BaseCommand):
if neighbors[0][0].tag != SVG_USE_TAG:
raise CommandParseError("connector does not point to a use tag")
+ self.use = neighbors[0][0]
+
self.symbol = self.get_node_by_url(neighbors[0][0].get(XLINK_HREF))
self.parse_symbol()
diff --git a/lib/elements/element.py b/lib/elements/element.py
index ec50ce22..78954683 100644
--- a/lib/elements/element.py
+++ b/lib/elements/element.py
@@ -247,6 +247,12 @@ class EmbroideryElement(object):
return [self.strip_control_points(subpath) for subpath in path]
+ def flatten_subpath(self, subpath):
+ path = [deepcopy(subpath)]
+ cspsubdiv(path, 0.1)
+
+ return self.strip_control_points(path[0])
+
@property
def trim_after(self):
return self.get_boolean_param('trim_after', False)
diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py
index 9927a606..705983d7 100644
--- a/lib/elements/satin_column.py
+++ b/lib/elements/satin_column.py
@@ -1,9 +1,12 @@
from itertools import chain, izip
-from shapely import geometry as shgeo, ops as shops
+from copy import deepcopy
+from shapely import geometry as shgeo, affinity as shaffinity
+import cubicsuperpath
from .element import param, EmbroideryElement, Patch
from ..i18n import _
-from ..utils import cache, Point
+from ..utils import cache, Point, cut
+from ..svg import line_strings_to_csp, get_correction_transform
class SatinColumn(EmbroideryElement):
@@ -141,77 +144,167 @@ class SatinColumn(EmbroideryElement):
@property
@cache
- def flattened_beziers(self):
+ def rails(self):
+ """The rails in order, as LineStrings"""
+ return [subpath for i, subpath in enumerate(self.csp) if i in self.rail_indices]
+
+ @property
+ @cache
+ def rungs(self):
+ """The rungs, as LineStrings.
+
+ 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
+ nodes, taken in sequential pairs, act in the same way as rungs would.
+ """
if len(self.csp) == 2:
- return self.simple_flatten_beziers()
- elif 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')))
+ # It's an old-style satin column. To make things easier we'll
+ # actually create the implied rungs.
+ return self._synthesize_rungs()
else:
- return self.flatten_beziers_with_rungs()
+ return [subpath for i, subpath in enumerate(self.csp) if i not in self.rail_indices]
+
+ def _synthesize_rungs(self):
+ rung_endpoints = []
+ for rail in self.rails:
+ points = self.strip_control_points(rail)
- def flatten_beziers_with_rungs(self):
- input_paths = [self.flatten([path]) for path in self.csp]
- input_paths = [shgeo.LineString(path[0]) for path in input_paths]
+ # ignore the start and end
+ points = points[1:-1]
- paths = input_paths[:]
- paths.sort(key=lambda path: path.length, reverse=True)
+ rung_endpoints.append(points)
+
+ rungs = []
+ for start, end in izip(*rung_endpoints):
+ # Expand the points just a bit to ensure that shapely thinks they
+ # intersect with the rails even with floating point inaccuracy.
+ start = Point(*start)
+ end = Point(*end)
+ start, end = self.offset_points(start, end, 0.01)
+ start = list(start)
+ end = list(end)
+
+ rungs.append([[start, start, start], [end, end, end]])
+
+ return rungs
+
+ @property
+ @cache
+ def rail_indices(self):
+ paths = [self.flatten_subpath(subpath) for subpath in self.csp]
+ paths = [shgeo.LineString(path) for path in paths]
+ num_paths = len(paths)
# Imagine a satin column as a curvy ladder.
# The two long paths are the "rails" of the ladder. The remainder are
# the "rungs".
- rails = paths[:2]
- rungs = shgeo.MultiLineString(paths[2:])
+ #
+ # The subpaths in this SVG path may be in arbitrary order, so we need
+ # to figure out which are the rails and which are the rungs.
+ #
+ # Rungs are the paths that intersect with exactly 2 other paths.
+ # Rails are everything else.
+
+ if num_paths <= 2:
+ # old-style satin column with no rungs
+ return range(num_paths)
+
+ # This takes advantage of the fact that sum() counts True as 1
+ intersection_counts = [sum(paths[i].intersects(paths[j]) for j in xrange(num_paths) if i != j)
+ for i in xrange(num_paths)]
+ paths_not_intersecting_two = [i for i in xrange(num_paths) if intersection_counts[i] != 2]
+ num_not_intersecting_two = len(paths_not_intersecting_two)
+
+ if num_not_intersecting_two == 2:
+ # Great, we have two unambiguous rails.
+ return paths_not_intersecting_two
+ else:
+ # This is one of two situations:
+ #
+ # 1. There are two rails and two rungs, and it looks like a
+ # hash symbol (#). Unfortunately for us, this is an ambiguous situation
+ # and we'll have to take a guess as to which are the rails and
+ # which are the rungs. We'll guess that the rails are the longest
+ # ones.
+ #
+ # or,
+ #
+ # 2. The paths don't look like a ladder at all, but some other
+ # kind of weird thing. Maybe one of the rungs crosses a rail more
+ # than once. Treat it like the previous case and we'll sort out
+ # the intersection issues later.
+ indices_by_length = sorted(range(num_paths), key=lambda index: paths[index].length, reverse=True)
+ return indices_by_length[:2]
- # The rails should stay in the order they were in the original CSP.
- # (this lets the user control where the satin starts and ends)
- rails.sort(key=lambda rail: input_paths.index(rail))
+ def _cut_rail(self, rail, rung):
+ intersections = 0
- result = []
+ for segment_index, rail_segment in enumerate(rail[:]):
+ if rail_segment is None:
+ continue
- for rail in rails:
- if not rail.is_simple:
- self.fatal(_("One or more rails crosses itself, and this is not allowed. Please split into multiple satin columns."))
+ intersection = rail_segment.intersection(rung)
- # handle null intersections here?
- linestrings = shops.split(rail, rungs)
+ if not intersection.is_empty:
+ if isinstance(intersection, shgeo.MultiLineString):
+ intersections += len(intersection)
+ break
+ else:
+ intersections += 1
- # print >> dbg, "rails and rungs", [str(rail) for rail in rails], [str(rung) for rung in rungs]
- if len(linestrings.geoms) < len(rungs.geoms) + 1:
- self.fatal(_("satin column: One or more of the rungs doesn't intersect both rails.") +
- " " + _("Each rail should intersect both rungs once."))
- elif len(linestrings.geoms) > len(rungs.geoms) + 1:
- self.fatal(_("satin column: One or more of the rungs intersects the rails more than once.") +
- " " + _("Each rail should intersect both rungs once."))
+ cut_result = cut(rail_segment, rail_segment.project(intersection))
+ rail[segment_index:segment_index + 1] = cut_result
- paths = [[Point(*coord) for coord in ls.coords] for ls in linestrings.geoms]
- result.append(paths)
+ if cut_result[1] is None:
+ # if we were exactly at the end of one of the existing rail segments,
+ # stop here or we'll get a spurious second intersection on the next
+ # segment
+ break
- return zip(*result)
+ return intersections
- def simple_flatten_beziers(self):
- # Given a pair of paths made up of bezier segments, flatten
- # each individual bezier segment into line segments that approximate
- # the curves. Retain the divisions between beziers -- we'll use those
- # later.
+ @property
+ @cache
+ def flattened_sections(self):
+ """Flatten the rails, cut with the rungs, and return the sections in pairs."""
- paths = []
+ 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')))
- for path in self.csp:
- # See the documentation in the parent class for parse_path() for a
- # description of the format of the CSP. Each bezier is constructed
- # using two neighboring 3-tuples in the list.
+ rails = [[shgeo.LineString(self.flatten_subpath(rail))] for rail in self.rails]
+ rungs = [shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs]
- flattened_path = []
+ for rung in rungs:
+ for rail_index, rail in enumerate(rails):
+ intersections = self._cut_rail(rail, rung)
- # iterate over pairs of 3-tuples
- for prev, current in zip(path[:-1], path[1:]):
- flattened_segment = self.flatten([[prev, current]])
- flattened_segment = [Point(x, y) for x, y in flattened_segment[0]]
- flattened_path.append(flattened_segment)
+ 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."))
- paths.append(flattened_path)
+ for rail in rails:
+ for i in xrange(len(rail)):
+ if rail[i] is not None:
+ rail[i] = [Point(*coord) for coord in rail[i].coords]
- return zip(*paths)
+ # Clean out empty segments. Consider an old-style satin like this:
+ #
+ # | |
+ # * *---*
+ # | |
+ # | |
+ #
+ # The stars indicate where the bezier endpoints lay. On the left, there's a
+ # zero-length bezier at the star. The user's goal here is to ignore the
+ # horizontal section of the right rail.
+
+ sections = zip(*rails)
+ sections = [s for s in sections if s[0] is not None and s[1] is not None]
+
+ return sections
def validate_satin_column(self):
# The node should have exactly two paths with no fill. Each
@@ -223,10 +316,120 @@ class SatinColumn(EmbroideryElement):
if self.get_style("fill") is not None:
self.fatal(_("satin column: object %s has a fill (but should not)") % node_id)
- if len(self.csp) == 2:
- if len(self.csp[0]) != len(self.csp[1]):
+ if not self.rungs:
+ 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.csp[0]), length2=len(self.csp[1])))
+ dict(id=node_id, length1=len(self.rails[0]), length2=len(self.rails[1])))
+
+ def split(self, split_point):
+ """Split a satin into two satins at the specified point
+
+ split_point is a point on or near one of the rails, not at one of the
+ ends. Finds corresponding point on the other rail (taking into account
+ the rungs) and breaks the rails at these points.
+
+ Returns two new SatinColumn instances: the part before and the part
+ after the split point. All parameters are copied over to the new
+ SatinColumn instances.
+ """
+
+ cut_points = self._find_cut_points(split_point)
+ path_lists = self._cut_rails(cut_points)
+ self._assign_rungs_to_split_rails(path_lists)
+ self._add_rungs_if_necessary(path_lists)
+ return self._path_lists_to_satins(path_lists)
+
+ def _find_cut_points(self, split_point):
+ """Find the points on each satin corresponding to the split point.
+
+ split_point is a point that is near but not necessarily touching one
+ of the rails. It is projected onto that rail to obtain the cut point
+ for that rail. A corresponding cut point will be chosen on the other
+ rail, taking into account the satin's rungs to choose a matching point.
+
+ Returns: a list of two Point objects corresponding to the selected
+ cut points.
+ """
+
+ split_point = Point(*split_point)
+ patch = self.do_satin()
+ index_of_closest_stitch = min(range(len(patch)), key=lambda index: split_point.distance(patch.stitches[index]))
+
+ if index_of_closest_stitch % 2 == 0:
+ # split point is on the first rail
+ return (patch.stitches[index_of_closest_stitch],
+ patch.stitches[index_of_closest_stitch + 1])
+ else:
+ # split point is on the second rail
+ return (patch.stitches[index_of_closest_stitch - 1],
+ patch.stitches[index_of_closest_stitch])
+
+ def _cut_rails(self, cut_points):
+ """Cut the rails of this satin at the specified points.
+
+ cut_points is a list of two elements, corresponding to the cut points
+ for each rail in order.
+
+ Returns: A list of two elements, corresponding two the two new sets of
+ rails. Each element is a list of two rails of type LineString.
+ """
+
+ rails = [shgeo.LineString(self.flatten_subpath(rail)) for rail in self.rails]
+
+ path_lists = [[], []]
+
+ for i, rail in enumerate(rails):
+ before, after = cut(rail, rail.project(shgeo.Point(cut_points[i])))
+ path_lists[0].append(before)
+ path_lists[1].append(after)
+
+ return path_lists
+
+ def _assign_rungs_to_split_rails(self, split_rails):
+ """Add this satin's rungs to the new satins.
+
+ Each rung is appended to the correct one of the two new satin columns.
+ """
+
+ rungs = [shgeo.LineString(self.flatten_subpath(rung)) for rung in self.rungs]
+ for path_list in split_rails:
+ path_list.extend(rung for rung in rungs if path_list[0].intersects(rung) and path_list[1].intersects(rung))
+
+ def _add_rungs_if_necessary(self, path_lists):
+ """Add an additional rung to each new satin if it ended up with none.
+
+ If the split point is between the end and the last rung, then one of
+ the satins will have no rungs. Add one to make it stitch properly.
+ """
+
+ # no need to add rungs if there weren't any in the first place
+ if not self.rungs:
+ return
+
+ for path_list in path_lists:
+ if len(path_list) == 2:
+ # If a path has no rungs, it may be invalid. Add a rung at the start.
+ rung_start = path_list[0].interpolate(0.1)
+ rung_end = path_list[1].interpolate(0.1)
+ rung = shgeo.LineString((rung_start, rung_end))
+
+ # make it a bit bigger so that it definitely intersects
+ rung = shaffinity.scale(rung, 1.1, 1.1)
+
+ path_list.append(rung)
+
+ def _path_lists_to_satins(self, path_lists):
+ transform = get_correction_transform(self.node)
+ satins = []
+ for path_list in path_lists:
+ node = deepcopy(self.node)
+ csp = line_strings_to_csp(path_list)
+ d = cubicsuperpath.formatPath(csp)
+ node.set("d", d)
+ node.set("transform", transform)
+ satins.append(SatinColumn(node))
+
+ return satins
def offset_points(self, pos1, pos2, offset_px):
# Expand or contract two points about their midpoint. This is
@@ -300,7 +503,7 @@ class SatinColumn(EmbroideryElement):
remainder_path1 = []
remainder_path2 = []
- for segment1, segment2 in self.flattened_beziers:
+ for segment1, segment2 in self.flattened_sections:
subpath1 = remainder_path1 + segment1
subpath2 = remainder_path2 + segment2
@@ -410,7 +613,7 @@ class SatinColumn(EmbroideryElement):
# zigzag looks like this to make the satin stitches look perpendicular
# to the column:
#
- # /|/|/|/|/|/|/|/|
+ # |/|/|/|/|/|/|/|/|
# print >> dbg, "satin", self.zigzag_spacing, self.pull_compensation
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index 5b72ecb3..56cd774b 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -11,6 +11,7 @@ from object_commands import ObjectCommands
from layer_commands import LayerCommands
from global_commands import GlobalCommands
from convert_to_satin import ConvertToSatin
+from cut_satin import CutSatin
__all__ = extensions = [Embroider,
Install,
@@ -24,4 +25,5 @@ __all__ = extensions = [Embroider,
ObjectCommands,
LayerCommands,
GlobalCommands,
- ConvertToSatin]
+ ConvertToSatin,
+ CutSatin]
diff --git a/lib/extensions/cut_satin.py b/lib/extensions/cut_satin.py
new file mode 100644
index 00000000..0bef794e
--- /dev/null
+++ b/lib/extensions/cut_satin.py
@@ -0,0 +1,37 @@
+import inkex
+
+from .base import InkstitchExtension
+from ..i18n import _
+from ..elements import SatinColumn
+
+
+class CutSatin(InkstitchExtension):
+ def effect(self):
+ if not self.get_elements():
+ return
+
+ if not self.selected:
+ inkex.errormsg(_("Please select one or more satin columns to cut."))
+ return
+
+ for satin in self.elements:
+ if isinstance(satin, SatinColumn):
+ command = satin.get_command("satin_cut_point")
+
+ if command is None:
+ # L10N will have the satin's id prepended, like this:
+ # path12345: error: this satin column does not ...
+ satin.fatal(_('this satin column does not have a "satin column cut point" command attached to it. '
+ 'Please use the "Attach commands" extension and attach the "Satin Column cut point" command first.'))
+
+ split_point = command.target_point
+ command.use.getparent().remove(command.use)
+ command.connector.getparent().remove(command.connector)
+
+ new_satins = satin.split(split_point)
+ parent = satin.node.getparent()
+ index = parent.index(satin.node)
+ parent.remove(satin.node)
+ for new_satin in new_satins:
+ parent.insert(index, new_satin.node)
+ index += 1
diff --git a/lib/extensions/flip.py b/lib/extensions/flip.py
index 65dbdc1f..0864da85 100644
--- a/lib/extensions/flip.py
+++ b/lib/extensions/flip.py
@@ -1,6 +1,5 @@
import inkex
import cubicsuperpath
-from shapely import geometry as shgeo
from .base import InkstitchExtension
from ..i18n import _
@@ -8,21 +7,11 @@ from ..elements import SatinColumn
class Flip(InkstitchExtension):
- def subpath_to_linestring(self, subpath):
- return shgeo.LineString()
-
def flip(self, satin):
csp = satin.path
if len(csp) > 1:
- flattened = satin.flatten(csp)
-
- # find the rails (the two longest paths) and swap them
- indices = range(len(csp))
- indices.sort(key=lambda i: shgeo.LineString(flattened[i]).length, reverse=True)
-
- first = indices[0]
- second = indices[1]
+ first, second = satin.rail_indices
csp[first], csp[second] = csp[second], csp[first]
satin.node.set("d", cubicsuperpath.formatPath(csp))
diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py
index 1660cd4e..28c79eff 100644
--- a/lib/stitches/auto_fill.py
+++ b/lib/stitches/auto_fill.py
@@ -485,9 +485,9 @@ def connect_points(shape, start, end, running_stitch_length, row_spacing):
# up at 12 again.
result = cut(outline, start_projection)
- # result will be None if our starting point happens to already be at
+ # result[0] will be None if our starting point happens to already be at
# 12 o'clock.
- if result is not None and result[1] is not None:
+ if result[0] is not None:
before, after = result
# Make a new outline, starting from the starting point. This is
diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py
index 429e6b5e..a56fcca7 100644
--- a/lib/svg/__init__.py
+++ b/lib/svg/__init__.py
@@ -1,3 +1,3 @@
from .svg import color_block_to_point_lists, render_stitch_plan
from .units import *
-from .path import apply_transforms, get_node_transform, get_correction_transform
+from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp
diff --git a/lib/svg/path.py b/lib/svg/path.py
index 4502b2ea..abaeda52 100644
--- a/lib/svg/path.py
+++ b/lib/svg/path.py
@@ -47,3 +47,16 @@ def get_correction_transform(node, child=False):
transform = simpletransform.invertTransform(transform)
return simpletransform.formatTransform(transform)
+
+
+def line_strings_to_csp(line_strings):
+ csp = []
+
+ for ls in line_strings:
+ subpath = []
+ for point in ls.coords:
+ # create a straight line as a degenerate bezier
+ subpath.append((point, point, point))
+ csp.append(subpath)
+
+ return csp
diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py
index ef5f12b5..64f6f16f 100644
--- a/lib/utils/geometry.py
+++ b/lib/utils/geometry.py
@@ -7,8 +7,11 @@ def cut(line, distance):
This is an example in the Shapely documentation.
"""
- if distance <= 0.0 or distance >= line.length:
- return [LineString(line), None]
+ if distance <= 0.0:
+ return [None, line]
+ elif distance >= line.length:
+ return [line, None]
+
coords = list(ShapelyPoint(p) for p in line.coords)
traveled = 0
last_point = coords[0]
@@ -88,6 +91,9 @@ class Point:
def length(self):
return math.sqrt(math.pow(self.x, 2.0) + math.pow(self.y, 2.0))
+ def distance(self, other):
+ return (other - self).length()
+
def unit(self):
return self.mul(1.0 / self.length())