diff options
Diffstat (limited to 'lib/elements')
| -rw-r--r-- | lib/elements/element.py | 6 | ||||
| -rw-r--r-- | lib/elements/satin_column.py | 315 |
2 files changed, 265 insertions, 56 deletions
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 |
