From 1b31806423c8fec4040fed6d1009db016860b763 Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 1 May 2018 20:37:51 -0400 Subject: rename inkstitch/ to lib/ You can't have a module and a package named the same thing. PyInstaller wants to import the main script as if it were a module, and this doesn't work unless there's no directory of the same name with a __init__.py in it. --- lib/stitches/__init__.py | 3 + lib/stitches/auto_fill.py | 447 +++++++++++++++++++++++++++++++++++++++++ lib/stitches/fill.py | 245 ++++++++++++++++++++++ lib/stitches/running_stitch.py | 66 ++++++ 4 files changed, 761 insertions(+) create mode 100644 lib/stitches/__init__.py create mode 100644 lib/stitches/auto_fill.py create mode 100644 lib/stitches/fill.py create mode 100644 lib/stitches/running_stitch.py (limited to 'lib/stitches') diff --git a/lib/stitches/__init__.py b/lib/stitches/__init__.py new file mode 100644 index 00000000..d2ff0446 --- /dev/null +++ b/lib/stitches/__init__.py @@ -0,0 +1,3 @@ +from running_stitch import * +from auto_fill import auto_fill +from fill import legacy_fill diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py new file mode 100644 index 00000000..7f265909 --- /dev/null +++ b/lib/stitches/auto_fill.py @@ -0,0 +1,447 @@ +from fill import intersect_region_with_grating, row_num, stitch_row +from .. import _, PIXELS_PER_MM, Point as InkstitchPoint +import sys +import shapely +import networkx +import math +from itertools import groupby +from collections import deque + + +class MaxQueueLengthExceeded(Exception): + pass + + +def auto_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, running_stitch_length, staggers, starting_point=None): + stitches = [] + + 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 = build_graph(shape, segments, angle, row_spacing) + path = find_stitch_path(graph, segments) + + if starting_point: + stitches.extend(connect_points(shape, starting_point, path[0][0], running_stitch_length)) + + stitches.extend(path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers)) + + return stitches + + +def which_outline(shape, coords): + """return the index of the outline on which the point resides + + Index 0 is the outer boundary of the fill region. 1+ are the + outlines of the holes. + """ + + # I'd use an intersection check, but floating point errors make it + # fail sometimes. + + point = shapely.geometry.Point(*coords) + outlines = enumerate(list(shape.boundary)) + closest = min(outlines, key=lambda (index, outline): outline.distance(point)) + + return closest[0] + + +def project(shape, coords, outline_index): + """project the point onto the specified outline + + This returns the distance along the outline at which the point resides. + """ + + outline = list(shape.boundary)[outline_index] + return outline.project(shapely.geometry.Point(*coords)) + + +def build_graph(shape, segments, angle, row_spacing): + """build a graph representation of the grating segments + + This function builds a specialized graph (as in graph theory) that will + help us determine a stitching path. The idea comes from this paper: + + http://www.sciencedirect.com/science/article/pii/S0925772100000158 + + The goal is to build a graph that we know must have an Eulerian Path. + An Eulerian Path is a path from edge to edge in the graph that visits + every edge exactly once and ends at the node it started at. Algorithms + exist to build such a path, and we'll use Hierholzer's algorithm. + + A graph must have an Eulerian Path if every node in the graph has an + even number of edges touching it. Our goal here is to build a graph + that will have this property. + + Based on the paper linked above, we'll build the graph as follows: + + * nodes are the endpoints of the grating segments, where they meet + with the outer outline of the region the outlines of the interior + holes in the region. + * edges are: + * each section of the outer and inner outlines of the region, + between nodes + * double every other edge in the outer and inner hole outlines + + Doubling up on some of the edges seems as if it will just mean we have + to stitch those spots twice. This may be true, but it also ensures + that every node has 4 edges touching it, ensuring that a valid stitch + path must exist. + """ + + graph = networkx.MultiGraph() + + # First, add the grating segments as edges. We'll use the coordinates + # of the endpoints as nodes, which networkx will add automatically. + 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") + + for node in graph.nodes(): + outline_index = which_outline(shape, node) + outline_projection = project(shape, node, outline_index) + + # Tag each node with its index and projection. + graph.add_node(node, index=outline_index, projection=outline_projection) + + nodes = list(graph.nodes(data=True)) # returns a list of tuples: [(node, {data}), (node, {data}) ...] + nodes.sort(key=lambda node: (node[1]['index'], node[1]['projection'])) + + for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['index']): + nodes = [ node for node, data in nodes ] + + # heuristic: change the order I visit the nodes in the outline if necessary. + # If the start and endpoints are in the same row, I can't tell which row + # I should treat it as being in. + for i in xrange(len(nodes)): + row0 = row_num(InkstitchPoint(*nodes[0]), angle, row_spacing) + row1 = row_num(InkstitchPoint(*nodes[1]), angle, row_spacing) + + if row0 == row1: + nodes = nodes[1:] + [nodes[0]] + else: + break + + # heuristic: it's useful to try to keep the duplicated edges in the same rows. + # this prevents the BFS from having to search a ton of edges. + min_row_num = min(row0, row1) + if min_row_num % 2 == 0: + edge_set = 0 + else: + edge_set = 1 + + #print >> sys.stderr, outline_index, "es", edge_set, "rn", row_num, inkstitch.Point(*nodes[0]) * self.north(angle), inkstitch.Point(*nodes[1]) * self.north(angle) + + # add an edge between each successive node + for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])): + graph.add_edge(node1, node2, key="outline") + + # duplicate every other edge around this outline + if i % 2 == edge_set: + graph.add_edge(node1, node2, key="extra") + + + if not networkx.is_eulerian(graph): + raise Exception(_("Unable to autofill. This most often happens because your shape is made up of multiple sections that aren't connected.")) + + return graph + + +def node_list_to_edge_list(node_list): + return zip(node_list[:-1], node_list[1:]) + + +def bfs_for_loop(graph, starting_node, max_queue_length=2000): + to_search = deque() + to_search.appendleft(([starting_node], set(), 0)) + + while to_search: + if len(to_search) > max_queue_length: + raise MaxQueueLengthExceeded() + + path, visited_edges, visited_segments = to_search.pop() + ending_node = path[-1] + + # get a list of neighbors paired with the key of the edge I can follow to get there + neighbors = [ + (node, key) + for node, adj in graph.adj[ending_node].iteritems() + for key in adj + ] + + # heuristic: try grating segments first + neighbors.sort(key=lambda (dest, key): key == "segment", reverse=True) + + for next_node, key in neighbors: + # skip if I've already followed this edge + edge = (tuple(sorted((ending_node, next_node))), key) + if edge in visited_edges: + continue + + new_path = path + [next_node] + + if key == "segment": + new_visited_segments = visited_segments + 1 + else: + new_visited_segments = visited_segments + + if next_node == starting_node: + # ignore trivial loops (down and back a doubled edge) + if len(new_path) > 3: + return node_list_to_edge_list(new_path), new_visited_segments + + new_visited_edges = visited_edges.copy() + new_visited_edges.add(edge) + + to_search.appendleft((new_path, new_visited_edges, new_visited_segments)) + + +def find_loop(graph, starting_nodes): + """find a loop in the graph that is connected to the existing path + + Start at a candidate node and search through edges to find a path + back to that node. We'll use a breadth-first search (BFS) in order to + find the shortest available loop. + + In most cases, the BFS should not need to search far to find a loop. + The queue should stay relatively short. + + An added heuristic will be used: if the BFS queue's length becomes + too long, we'll abort and try a different starting point. Due to + the way we've set up the graph, there's bound to be a better choice + somewhere else. + """ + + #loop = self.simple_loop(graph, starting_nodes[-2]) + + #if loop: + # print >> sys.stderr, "simple_loop success" + # starting_nodes.pop() + # starting_nodes.pop() + # return loop + + loop = None + retry = [] + max_queue_length = 2000 + + while not loop: + while not loop and starting_nodes: + starting_node = starting_nodes.pop() + #print >> sys.stderr, "find loop from", starting_node + + try: + # Note: if bfs_for_loop() returns None, no loop can be + # constructed from the starting_node (because the + # necessary edges have already been consumed). In that + # case we discard that node and try the next. + loop = bfs_for_loop(graph, starting_node, max_queue_length) + + #if not loop: + #print >> dbg, "failed on", starting_node + #dbg.flush() + except MaxQueueLengthExceeded: + #print >> dbg, "gave up on", starting_node + #dbg.flush() + # We're giving up on this node for now. We could try + # this node again later, so add it to the bottm of the + # stack. + retry.append(starting_node) + + # Darn, couldn't find a loop. Try harder. + starting_nodes.extendleft(retry) + max_queue_length *= 2 + + starting_nodes.extendleft(retry) + return loop + + +def insert_loop(path, loop): + """insert a sub-loop into an existing path + + The path will be a series of edges describing a path through the graph + that ends where it starts. The loop will be similar, and its starting + point will be somewhere along the path. + + Insert the loop into the path, resulting in a longer path. + + Both the path and the loop will be a list of edges specified as a + start and end point. The points will be specified in order, such + that they will look like this: + + ((p1, p2), (p2, p3), (p3, p4) ... (pn, p1)) + + path will be modified in place. + """ + + loop_start = loop[0][0] + + for i, (start, end) in enumerate(path): + if start == loop_start: + break + + path[i:i] = loop + + +def find_stitch_path(graph, segments): + """find a path that visits every grating segment exactly once + + Theoretically, we just need to find an Eulerian Path in the graph. + However, we don't actually care whether every single edge is visited. + The edges on the outline of the region are only there to help us get + from one grating segment to the next. + + We'll build a "cycle" (a path that ends where it starts) using + Hierholzer's algorithm. We'll stop once we've visited every grating + segment. + + Hierholzer's algorithm says to select an arbitrary starting node at + each step. In order to produce a reasonable stitch path, we'll select + the vertex carefully such that we get back-and-forth traversal like + mowing a lawn. + + To do this, we'll use a simple heuristic: try to start from nodes in + the order of most-recently-visited first. + """ + + original_graph = graph + graph = graph.copy() + num_segments = len(segments) + segments_visited = 0 + nodes_visited = deque() + + # start with a simple loop: down one segment and then back along the + # outer border to the starting point. + path = [segments[0], list(reversed(segments[0]))] + + graph.remove_edges_from(path) + + segments_visited += 1 + nodes_visited.extend(segments[0]) + + while segments_visited < num_segments: + result = find_loop(graph, nodes_visited) + + if not result: + print >> sys.stderr, _("Unexpected error while generating fill stitches. Please send your SVG file to lexelby@github.") + break + + loop, segments = result + + #print >> dbg, "found loop:", loop + #dbg.flush() + + segments_visited += segments + nodes_visited += [edge[0] for edge in loop] + graph.remove_edges_from(loop) + + insert_loop(path, loop) + + #if segments_visited >= 12: + # break + + # Now we have a loop that covers every grating segment. It returns to + # where it started, which is unnecessary, so we'll snip the last bit off. + #while original_graph.has_edge(*path[-1], key="outline"): + # path.pop() + + return path + + +def collapse_sequential_outline_edges(graph, path): + """collapse sequential edges that fall on the same outline + + When the path follows multiple edges along the outline of the region, + replace those edges with the starting and ending points. We'll use + these to stitch along the outline later on. + """ + + start_of_run = None + new_path = [] + + for edge in path: + if graph.has_edge(*edge, key="segment"): + if start_of_run: + # close off the last run + new_path.append((start_of_run, edge[0])) + start_of_run = None + + new_path.append(edge) + else: + if not start_of_run: + start_of_run = edge[0] + + if start_of_run: + # if we were still in a run, close it off + new_path.append((start_of_run, edge[1])) + + return new_path + + +def outline_distance(outline, p1, p2): + # how far around the outline (and in what direction) do I need to go + # to get from p1 to p2? + + p1_projection = outline.project(shapely.geometry.Point(p1)) + p2_projection = outline.project(shapely.geometry.Point(p2)) + + distance = p2_projection - p1_projection + + if abs(distance) > outline.length / 2.0: + # if we'd have to go more than halfway around, it's faster to go + # the other way + if distance < 0: + return distance + outline.length + elif distance > 0: + return distance - outline.length + else: + # this ought not happen, but just for completeness, return 0 if + # p1 and p0 are the same point + return 0 + else: + return distance + + +def connect_points(shape, start, end, running_stitch_length): + outline_index = which_outline(shape, start) + outline = shape.boundary[outline_index] + + pos = outline.project(shapely.geometry.Point(start)) + distance = outline_distance(outline, start, end) + num_stitches = abs(int(distance / running_stitch_length)) + + direction = math.copysign(1.0, distance) + one_stitch = running_stitch_length * direction + + #print >> dbg, "connect_points:", outline_index, start, end, distance, stitches, direction + #dbg.flush() + + stitches = [InkstitchPoint(*outline.interpolate(pos).coords[0])] + + for i in xrange(num_stitches): + pos = (pos + one_stitch) % outline.length + + stitches.append(InkstitchPoint(*outline.interpolate(pos).coords[0])) + + end = InkstitchPoint(*end) + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + #print >> dbg, "end connect_points" + #dbg.flush() + + return stitches + + +def path_to_stitches(graph, path, shape, angle, row_spacing, max_stitch_length, running_stitch_length, staggers): + path = collapse_sequential_outline_edges(graph, path) + + stitches = [] + + for edge in path: + if graph.has_edge(*edge, key="segment"): + stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers) + else: + stitches.extend(connect_points(shape, edge[0], edge[1], running_stitch_length)) + + return stitches diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py new file mode 100644 index 00000000..1b7377b0 --- /dev/null +++ b/lib/stitches/fill.py @@ -0,0 +1,245 @@ +from .. import PIXELS_PER_MM +from ..utils import cache, Point as InkstitchPoint +import shapely +import math +import sys + + +def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): + rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) + groups_of_segments = pull_runs(rows_of_segments, shape, row_spacing) + + return [section_to_stitches(group, angle, row_spacing, max_stitch_length, staggers) + for group in groups_of_segments] + + +@cache +def east(angle): + # "east" is the name of the direction that is to the right along a row + return InkstitchPoint(1, 0).rotate(-angle) + + +@cache +def north(angle): + return east(angle).rotate(math.pi / 2) + + +def row_num(point, angle, row_spacing): + return round((point * north(angle)) / row_spacing) + + +def adjust_stagger(stitch, angle, row_spacing, max_stitch_length, staggers): + this_row_num = row_num(stitch, angle, row_spacing) + row_stagger = this_row_num % staggers + stagger_offset = (float(row_stagger) / staggers) * max_stitch_length + offset = ((stitch * east(angle)) - stagger_offset) % max_stitch_length + + return stitch - offset * east(angle) + +def stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers): + # We want our stitches to look like this: + # + # ---*-----------*----------- + # ------*-----------*-------- + # ---------*-----------*----- + # ------------*-----------*-- + # ---*-----------*----------- + # + # Each successive row of stitches will be staggered, with + # num_staggers rows before the pattern repeats. A value of + # 4 gives a nice fill while hiding the needle holes. The + # first row is offset 0%, the second 25%, the third 50%, and + # the fourth 75%. + # + # Actually, instead of just starting at an offset of 0, we + # can calculate a row's offset relative to the origin. This + # way if we have two abutting fill regions, they'll perfectly + # tile with each other. That's important because we often get + # abutting fill regions from pull_runs(). + + beg = InkstitchPoint(*beg) + end = InkstitchPoint(*end) + + row_direction = (end - beg).unit() + segment_length = (end - beg).length() + + # only stitch the first point if it's a reasonable distance away from the + # last stitch + if not stitches or (beg - stitches[-1]).length() > 0.5 * PIXELS_PER_MM: + stitches.append(beg) + + first_stitch = adjust_stagger(beg, angle, row_spacing, max_stitch_length, staggers) + + # we might have chosen our first stitch just outside this row, so move back in + if (first_stitch - beg) * row_direction < 0: + first_stitch += row_direction * max_stitch_length + + offset = (first_stitch - beg).length() + + while offset < segment_length: + stitches.append(beg + offset * row_direction) + offset += max_stitch_length + + if (end - stitches[-1]).length() > 0.1 * PIXELS_PER_MM: + stitches.append(end) + + +def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=None, flip=False): + # the max line length I'll need to intersect the whole shape is the diagonal + (minx, miny, maxx, maxy) = shape.bounds + upper_left = InkstitchPoint(minx, miny) + lower_right = InkstitchPoint(maxx, maxy) + length = (upper_left - lower_right).length() + half_length = length / 2.0 + + # Now get a unit vector rotated to the requested angle. I use -angle + # because shapely rotates clockwise, but my geometry textbooks taught + # me to consider angles as counter-clockwise from the X axis. + direction = InkstitchPoint(1, 0).rotate(-angle) + + # and get a normal vector + normal = direction.rotate(math.pi / 2) + + # I'll start from the center, move in the normal direction some amount, + # and then walk left and right half_length in each direction to create + # a line segment in the grating. + center = InkstitchPoint((minx + maxx) / 2.0, (miny + maxy) / 2.0) + + # I need to figure out how far I need to go along the normal to get to + # the edge of the shape. To do that, I'll rotate the bounding box + # angle degrees clockwise and ask for the new bounding box. The max + # and min y tell me how far to go. + + _, start, _, end = shapely.affinity.rotate(shape, angle, origin='center', use_radians=True).bounds + + # convert start and end to be relative to center (simplifies things later) + start -= center.y + end -= center.y + + height = abs(end - start) + + #print >> dbg, "grating:", start, end, height, row_spacing, end_row_spacing + + # offset start slightly so that rows are always an even multiple of + # row_spacing_px from the origin. This makes it so that abutting + # 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: + p0 = center + normal * current_row_y + direction * half_length + p1 = center + normal * current_row_y - direction * half_length + endpoints = [p0.as_tuple(), p1.as_tuple()] + grating_line = shapely.geometry.LineString(endpoints) + + res = grating_line.intersection(shape) + + if (isinstance(res, shapely.geometry.MultiLineString)): + runs = map(lambda line_string: line_string.coords, res.geoms) + else: + if res.is_empty or len(res.coords) == 1: + # ignore if we intersected at a single point or no points + runs = [] + else: + runs = [res.coords] + + if runs: + runs.sort(key=lambda seg: (InkstitchPoint(*seg[0]) - upper_left).length()) + + if flip: + runs.reverse() + runs = map(lambda run: tuple(reversed(run)), runs) + + rows.append(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): + stitches = [] + first_segment = True + swap = False + last_end = None + + for segment in group_of_segments: + (beg, end) = segment + + if (swap): + (beg, end) = (end, beg) + + stitch_row(stitches, beg, end, angle, row_spacing, max_stitch_length, staggers) + + swap = not swap + + return stitches + + +def make_quadrilateral(segment1, segment2): + return shapely.geometry.Polygon((segment1[0], segment1[1], segment2[1], segment2[0], segment1[0])) + + +def is_same_run(segment1, segment2, shape, row_spacing): + line1 = shapely.geometry.LineString(segment1) + line2 = shapely.geometry.LineString(segment2) + + if line1.distance(line2) > row_spacing * 1.1: + return False + + quad = make_quadrilateral(segment1, segment2) + quad_area = quad.area + intersection_area = shape.intersection(quad).area + + return (intersection_area / quad_area) >= 0.9 + + +def pull_runs(rows, shape, row_spacing): + # Given a list of rows, each containing a set of line segments, + # break the area up into contiguous patches of line segments. + # + # This is done by repeatedly pulling off the first line segment in + # each row and calling that a shape. We have to be careful to make + # sure that the line segments are part of the same shape. Consider + # the letter "H", with an embroidery angle of 45 degrees. When + # we get to the bottom of the lower left leg, the next row will jump + # over to midway up the lower right leg. We want to stop there and + # start a new patch. + + # for row in rows: + # print >> sys.stderr, len(row) + + # print >>sys.stderr, "\n".join(str(len(row)) for row in rows) + + runs = [] + count = 0 + while (len(rows) > 0): + run = [] + prev = None + + for row_num in xrange(len(rows)): + row = rows[row_num] + first, rest = row[0], row[1:] + + # TODO: only accept actually adjacent rows here + if prev is not None and not is_same_run(prev, first, shape, row_spacing): + break + + run.append(first) + prev = first + + rows[row_num] = rest + + # print >> sys.stderr, len(run) + runs.append(run) + rows = [row for row in rows if len(row) > 0] + + count += 1 + + return runs + diff --git a/lib/stitches/running_stitch.py b/lib/stitches/running_stitch.py new file mode 100644 index 00000000..81124339 --- /dev/null +++ b/lib/stitches/running_stitch.py @@ -0,0 +1,66 @@ +""" Utility functions to produce running stitches. """ + + +def running_stitch(points, stitch_length): + """Generate running stitch along a path. + + Given a path and a stitch length, walk along the path in increments of the + stitch length. If sharp corners are encountered, an extra stitch will be + added at the corner to avoid rounding the corner. The starting and ending + point are always stitched. + + The path is described by a set of line segments, each connected to the next. + The line segments are described by a sequence of points. + """ + + if len(points) < 2: + return [] + + output = [points[0]] + segment_start = points[0] + last_segment_direction = None + + # This tracks the distance we've travelled 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 + + for segment_end in points[1:]: + segment = segment_end - segment_start + segment_length = segment.length() + + if segment_length == 0: + continue + + segment_direction = segment.unit() + + # corner detection + if last_segment_direction: + cos_angle_between = segment_direction * last_segment_direction + + # 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) + + # 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 the last point unless we're already almos there + if (segment_start - points[-1]).length() > 0.1: + output.append(segment_start) + + return output -- cgit v1.3.1 From 05daffb7e01db55879eb24f3c00532324a5d41af Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 1 May 2018 21:21:07 -0400 Subject: refactor everything out of lib/__init__.py --- lib/__init__.py | 298 ----------------------------------------- lib/elements/auto_fill.py | 8 +- lib/elements/element.py | 18 ++- lib/elements/fill.py | 9 +- lib/elements/polyline.py | 3 +- lib/elements/satin_column.py | 6 +- lib/elements/stroke.py | 5 +- lib/extensions/base.py | 3 +- lib/extensions/embroider.py | 7 +- lib/extensions/input.py | 10 +- lib/extensions/palettes.py | 1 + lib/extensions/params.py | 2 +- lib/extensions/print_pdf.py | 15 +-- lib/extensions/simulate.py | 1 + lib/i18n.py | 21 +++ lib/output.py | 130 ++++++++++++++++++ lib/simulator.py | 3 +- lib/stitch_plan/__init__.py | 1 + lib/stitch_plan/stitch.py | 15 +++ lib/stitch_plan/stitch_plan.py | 5 +- lib/stitch_plan/ties.py | 6 +- lib/stitches/auto_fill.py | 7 +- lib/stitches/fill.py | 6 +- lib/svg.py | 76 ----------- lib/svg/__init__.py | 2 + lib/svg/svg.py | 81 +++++++++++ lib/svg/tags.py | 12 ++ lib/svg/units.py | 105 +++++++++++++++ lib/threads/catalog.py | 2 + lib/threads/palette.py | 3 +- lib/utils/io.py | 1 + messages.po | 24 ++-- 32 files changed, 454 insertions(+), 432 deletions(-) create mode 100644 lib/i18n.py create mode 100644 lib/output.py create mode 100644 lib/stitch_plan/stitch.py delete mode 100644 lib/svg.py create mode 100644 lib/svg/__init__.py create mode 100644 lib/svg/svg.py create mode 100644 lib/svg/tags.py create mode 100644 lib/svg/units.py (limited to 'lib/stitches') diff --git a/lib/__init__.py b/lib/__init__.py index 2c0ee620..e69de29b 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,298 +0,0 @@ -#!/usr/bin/env python -# http://www.achatina.de/sewing/main/TECHNICL.HTM - -import os -import sys -import gettext -from copy import deepcopy -import math -import libembroidery -from .utils import cache -from .utils.geometry import Point - -import inkex -import simplepath -import simplestyle -import simpletransform -from bezmisc import bezierlength, beziertatlength, bezierpointatt -from cspsubdiv import cspsubdiv -import cubicsuperpath -from shapely import geometry as shgeo - - -# modern versions of Inkscape use 96 pixels per inch as per the CSS standard -PIXELS_PER_MM = 96 / 25.4 - -SVG_PATH_TAG = inkex.addNS('path', 'svg') -SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') -SVG_DEFS_TAG = inkex.addNS('defs', 'svg') -SVG_GROUP_TAG = inkex.addNS('g', 'svg') -INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') -INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') - -EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) - -dbg = open(os.devnull, "w") - -translation = None -_ = lambda message: message - - -def localize(): - if getattr(sys, 'frozen', False): - # we are in a pyinstaller installation - locale_dir = sys._MEIPASS - else: - locale_dir = os.path.dirname(__file__) - - locale_dir = os.path.join(locale_dir, 'locales') - - global translation, _ - - translation = gettext.translation("inkstitch", locale_dir, fallback=True) - _ = translation.gettext - -localize() - -# cribbed from inkscape-silhouette -def parse_length_with_units( str ): - - ''' - Parse an SVG value which may or may not have units attached - This version is greatly simplified in that it only allows: no units, - units of px, mm, and %. Everything else, it returns None for. - There is a more general routine to consider in scour.py if more - generality is ever needed. - ''' - - u = 'px' - s = str.strip() - if s[-2:] == 'px': - s = s[:-2] - elif s[-2:] == 'mm': - u = 'mm' - s = s[:-2] - elif s[-2:] == 'pt': - u = 'pt' - s = s[:-2] - elif s[-2:] == 'pc': - u = 'pc' - s = s[:-2] - elif s[-2:] == 'cm': - u = 'cm' - s = s[:-2] - elif s[-2:] == 'in': - u = 'in' - s = s[:-2] - elif s[-1:] == '%': - u = '%' - s = s[:-1] - try: - v = float( s ) - except: - raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) - - return v, u - - -def convert_length(length): - value, units = parse_length_with_units(length) - - if not units or units == "px": - return value - - if units == 'pt': - value /= 72 - units = 'in' - - if units == 'pc': - value /= 6 - units = 'in' - - if units == 'cm': - value *= 10 - units = 'mm' - - if units == 'mm': - value = value / 25.4 - units = 'in' - - if units == 'in': - # modern versions of Inkscape use CSS's 96 pixels per inch. When you - # open an old document, inkscape will add a viewbox for you. - return value * 96 - - raise ValueError(_("Unknown unit: %s") % units) - - -@cache -def get_doc_size(svg): - doc_width = convert_length(svg.get('width')) - doc_height = convert_length(svg.get('height')) - - return doc_width, doc_height - -@cache -def get_viewbox_transform(node): - # somewhat cribbed from inkscape-silhouette - doc_width, doc_height = get_doc_size(node) - - viewbox = node.get('viewBox').strip().replace(',', ' ').split() - - dx = -float(viewbox[0]) - dy = -float(viewbox[1]) - transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) - - try: - sx = doc_width / float(viewbox[2]) - sy = doc_height / float(viewbox[3]) - scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) - transform = simpletransform.composeTransform(transform, scale_transform) - except ZeroDivisionError: - pass - - return transform - -@cache -def get_stroke_scale(node): - doc_width, doc_height = get_doc_size(node) - viewbox = node.get('viewBox').strip().replace(',', ' ').split() - return doc_width / float(viewbox[2]) - - -class Stitch(Point): - def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): - self.x = x - self.y = y - self.color = color - self.jump = jump - self.trim = trim - self.stop = stop - self.no_ties = no_ties - - def __repr__(self): - return "Stitch(%s, %s, %s, %s, %s, %s, %s)" % (self.x, self.y, self.color, "JUMP" if self.jump else " ", "TRIM" if self.trim else " ", "STOP" if self.stop else " ", "NO TIES" if self.no_ties else " ") - - -def make_thread(color): - thread = libembroidery.EmbThread() - thread.color = libembroidery.embColor_make(*color.rgb) - - thread.description = color.name - thread.catalogNumber = "" - - return thread - -def add_thread(pattern, thread): - """Add a thread to a pattern and return the thread's index""" - - libembroidery.embPattern_addThread(pattern, thread) - - return libembroidery.embThreadList_count(pattern.threadList) - 1 - -def get_flags(stitch): - flags = 0 - - if stitch.jump: - flags |= libembroidery.JUMP - - if stitch.trim: - flags |= libembroidery.TRIM - - if stitch.stop: - flags |= libembroidery.STOP - - return flags - - -def _string_to_floats(string): - floats = string.split(',') - return [float(num) for num in floats] - - -def get_origin(svg): - # The user can specify the embroidery origin by defining two guides - # named "embroidery origin" that intersect. - - namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) - all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) - label_attribute = inkex.addNS('label', 'inkscape') - guides = [guide for guide in all_guides - if guide.get(label_attribute, "").startswith("embroidery origin")] - - # document size used below - doc_size = list(get_doc_size(svg)) - - # convert the size from viewbox-relative to real-world pixels - viewbox_transform = get_viewbox_transform(svg) - simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) - - default = [doc_size[0] / 2.0, doc_size[1] / 2.0] - simpletransform.applyTransformToPoint(viewbox_transform, default) - default = Point(*default) - - if len(guides) < 2: - return default - - # Find out where the guides intersect. Only pay attention to the first two. - guides = guides[:2] - - lines = [] - for guide in guides: - # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates - position = Point(*_string_to_floats(guide.get('position'))) - position.y = doc_size[1] - position.y - - - # This one baffles me. I think inkscape might have gotten the order of - # their vector wrong? - parts = _string_to_floats(guide.get('orientation')) - direction = Point(parts[1], parts[0]) - - # We have a theoretically infinite line defined by a point on the line - # and a vector direction. Shapely can only deal in concrete line - # segments, so we'll pick points really far in either direction on the - # line and call it good enough. - lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) - - intersection = lines[0].intersection(lines[1]) - - if isinstance(intersection, shgeo.Point): - origin = [intersection.x, intersection.y] - simpletransform.applyTransformToPoint(viewbox_transform, origin) - return Point(*origin) - else: - # Either the two guides are the same line, or they're parallel. - return default - - -def write_embroidery_file(file_path, stitch_plan, svg): - origin = get_origin(svg) - - pattern = libembroidery.embPattern_create() - - for color_block in stitch_plan: - add_thread(pattern, make_thread(color_block.color)) - - for stitch in color_block: - if stitch.stop and stitch is not color_block.last_stitch: - # A STOP stitch that is not at the end of a color block - # occurs when the user specified "STOP after". "STOP" is the - # same thing as a color change, and the user will assign a - # special color at the machine that tells it to pause after. - # We need to add another copy of the same color here so that - # the stitches after the STOP are still the same color. - add_thread(pattern, make_thread(color_block.color)) - - flags = get_flags(stitch) - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, flags, 1) - - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, libembroidery.END, 1) - - # convert from pixels to millimeters - libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) - - # SVG and embroidery disagree on the direction of the Y axis - libembroidery.embPattern_flipVertical(pattern) - - libembroidery.embPattern_write(pattern, file_path) diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py index 6eb1f10c..08ae67f7 100644 --- a/lib/elements/auto_fill.py +++ b/lib/elements/auto_fill.py @@ -1,10 +1,10 @@ import math -from .. import _ -from .element import param, Patch -from ..utils import cache -from .fill import Fill from shapely import geometry as shgeo +from ..i18n import _ +from ..utils import cache from ..stitches import auto_fill +from .element import param, Patch +from .fill import Fill class AutoFill(Fill): diff --git a/lib/elements/element.py b/lib/elements/element.py index cfca3782..300136dd 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -1,9 +1,10 @@ import sys from copy import deepcopy +from shapely import geometry as shgeo +from ..i18n import _ from ..utils import cache -from shapely import geometry as shgeo -from .. import _, PIXELS_PER_MM, get_viewbox_transform, get_stroke_scale, convert_length +from ..svg import PIXELS_PER_MM, get_viewbox_transform, convert_length, get_doc_size # inkscape-provided utilities import simpletransform @@ -11,6 +12,7 @@ import simplestyle import cubicsuperpath from cspsubdiv import cspsubdiv + class Patch: """A raw collection of stitches with attached instructions.""" @@ -144,6 +146,15 @@ class EmbroideryElement(object): style = simplestyle.parseStyle(self.node.get("style")) return style_name in style + @property + @cache + def stroke_scale(self): + svg = self.node.getroottree().getroot() + doc_width, doc_height = get_doc_size(svg) + viewbox = svg.get('viewBox', '0 0 %s %s' % (doc_width, doc_height)) + viewbox = viewbox.strip().replace(',', ' ').split() + return doc_width / float(viewbox[2]) + @property @cache def stroke_width(self): @@ -153,8 +164,7 @@ class EmbroideryElement(object): return 1.0 width = convert_length(width) - - return width * get_stroke_scale(self.node.getroottree().getroot()) + return width * self.stroke_scale @property def path(self): diff --git a/lib/elements/fill.py b/lib/elements/fill.py index a74a897d..52a42260 100644 --- a/lib/elements/fill.py +++ b/lib/elements/fill.py @@ -1,10 +1,13 @@ -from .. import _, PIXELS_PER_MM -from .element import param, EmbroideryElement, Patch -from ..utils import cache from shapely import geometry as shgeo import math + +from .element import param, EmbroideryElement, Patch +from ..i18n import _ +from ..svg import PIXELS_PER_MM +from ..utils import cache from ..stitches import running_stitch, auto_fill, legacy_fill + class Fill(EmbroideryElement): element_name = _("Fill") diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index 6ded9fd1..5c474237 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -1,5 +1,6 @@ -from .. import _, Point from .element import param, EmbroideryElement, Patch +from ..i18n import _ +from ..utils.geometry import Point from ..utils import cache diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index d22f5145..3593db64 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -1,9 +1,9 @@ from itertools import chain, izip +from shapely import geometry as shgeo, ops as shops -from .. import _, Point from .element import param, EmbroideryElement, Patch -from ..utils import cache -from shapely import geometry as shgeo, ops as shops +from ..i18n import _ +from ..utils import cache, Point class SatinColumn(EmbroideryElement): diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 360e3744..48662b6d 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -1,7 +1,8 @@ import sys -from .. import _, Point + from .element import param, EmbroideryElement, Patch -from ..utils import cache +from ..i18n import _ +from ..utils import cache, Point warned_about_legacy_running_stitch = False diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 91e050eb..ff587ca5 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -3,8 +3,9 @@ import re import json from copy import deepcopy from collections import MutableMapping + +from ..svg.tags import * from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement -from .. import SVG_POLYLINE_TAG, SVG_GROUP_TAG, SVG_DEFS_TAG, INKSCAPE_GROUPMODE, EMBROIDERABLE_TAGS, PIXELS_PER_MM from ..utils import cache diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py index 564e96ca..a213be64 100644 --- a/lib/extensions/embroider.py +++ b/lib/extensions/embroider.py @@ -1,12 +1,13 @@ import sys import traceback import os - import inkex -from .. import _, PIXELS_PER_MM, write_embroidery_file + from .base import InkstitchExtension +from ..i18n import _ +from ..output import write_embroidery_file from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan +from ..svg import render_stitch_plan, PIXELS_PER_MM class Embroider(InkstitchExtension): diff --git a/lib/extensions/input.py b/lib/extensions/input.py index bd3db0ed..f8bf5a5d 100644 --- a/lib/extensions/input.py +++ b/lib/extensions/input.py @@ -1,17 +1,19 @@ import os from os.path import realpath, dirname, join as path_join import sys +from inkex import etree +import inkex # help python find libembroidery when running in a local repo clone if getattr(sys, 'frozen', None) is None: sys.path.append(realpath(path_join(dirname(__file__), '..', '..'))) from libembroidery import * -from inkex import etree -import inkex -from .. import PIXELS_PER_MM, INKSCAPE_LABEL, _ + +from ..svg import PIXELS_PER_MM, render_stitch_plan +from ..svg.tags import INKSCAPE_LABEL +from ..i18n import _ from ..stitch_plan import StitchPlan -from ..svg import render_stitch_plan class Input(object): diff --git a/lib/extensions/palettes.py b/lib/extensions/palettes.py index 269dc6dc..f7a6c7a5 100644 --- a/lib/extensions/palettes.py +++ b/lib/extensions/palettes.py @@ -10,6 +10,7 @@ import time import logging import wx import inkex + from ..utils import guess_inkscape_config_path diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 881dab49..03a6f3cc 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -13,8 +13,8 @@ from collections import defaultdict from functools import partial from itertools import groupby -from .. import _ from .base import InkstitchExtension +from ..i18n import _ from ..stitch_plan import patches_to_stitch_plan from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn from ..utils import save_stderr, restore_stderr diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py index 5d462c0f..6450ee7c 100644 --- a/lib/extensions/print_pdf.py +++ b/lib/extensions/print_pdf.py @@ -10,22 +10,21 @@ from copy import deepcopy import wx import appdirs import json - import inkex -from .. import _, PIXELS_PER_MM, SVG_GROUP_TAG, translation as inkstitch_translation -from .base import InkstitchExtension -from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan -from ..threads import ThreadCatalog - from jinja2 import Environment, FileSystemLoader, select_autoescape from datetime import date import base64 - from flask import Flask, request, Response, send_from_directory, jsonify import webbrowser import requests +from .base import InkstitchExtension +from ..i18n import _, translation as inkstitch_translation +from ..svg import PIXELS_PER_MM, render_stitch_plan +from ..svg.tags import SVG_GROUP_TAG +from ..stitch_plan import patches_to_stitch_plan +from ..threads import ThreadCatalog + def datetimeformat(value, format='%Y/%m/%d'): return value.strftime(format) diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py index 75bc62c7..0c372d4d 100644 --- a/lib/extensions/simulate.py +++ b/lib/extensions/simulate.py @@ -1,6 +1,7 @@ import wx from .base import InkstitchExtension +from ..i18n import _ from ..simulator import EmbroiderySimulator from ..stitch_plan import patches_to_stitch_plan diff --git a/lib/i18n.py b/lib/i18n.py new file mode 100644 index 00000000..d20f5d2f --- /dev/null +++ b/lib/i18n.py @@ -0,0 +1,21 @@ +import sys +import os +import gettext + +_ = translation = None + +def localize(): + if getattr(sys, 'frozen', False): + # we are in a pyinstaller installation + locale_dir = sys._MEIPASS + else: + locale_dir = os.path.dirname(__file__) + + locale_dir = os.path.join(locale_dir, 'locales') + + global translation, _ + + translation = gettext.translation("inkstitch", locale_dir, fallback=True) + _ = translation.gettext + +localize() diff --git a/lib/output.py b/lib/output.py new file mode 100644 index 00000000..f1651357 --- /dev/null +++ b/lib/output.py @@ -0,0 +1,130 @@ +import libembroidery +import inkex +import simpletransform + +from .utils import Point +from .svg import PIXELS_PER_MM, get_doc_size, get_viewbox_transform + + +def make_thread(color): + thread = libembroidery.EmbThread() + thread.color = libembroidery.embColor_make(*color.rgb) + + thread.description = color.name + thread.catalogNumber = "" + + return thread + +def add_thread(pattern, thread): + """Add a thread to a pattern and return the thread's index""" + + libembroidery.embPattern_addThread(pattern, thread) + + return libembroidery.embThreadList_count(pattern.threadList) - 1 + +def get_flags(stitch): + flags = 0 + + if stitch.jump: + flags |= libembroidery.JUMP + + if stitch.trim: + flags |= libembroidery.TRIM + + if stitch.stop: + flags |= libembroidery.STOP + + return flags + + +def _string_to_floats(string): + floats = string.split(',') + return [float(num) for num in floats] + + +def get_origin(svg): + # The user can specify the embroidery origin by defining two guides + # named "embroidery origin" that intersect. + + namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) + all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) + label_attribute = inkex.addNS('label', 'inkscape') + guides = [guide for guide in all_guides + if guide.get(label_attribute, "").startswith("embroidery origin")] + + # document size used below + doc_size = list(get_doc_size(svg)) + + # convert the size from viewbox-relative to real-world pixels + viewbox_transform = get_viewbox_transform(svg) + simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) + + default = [doc_size[0] / 2.0, doc_size[1] / 2.0] + simpletransform.applyTransformToPoint(viewbox_transform, default) + default = Point(*default) + + if len(guides) < 2: + return default + + # Find out where the guides intersect. Only pay attention to the first two. + guides = guides[:2] + + lines = [] + for guide in guides: + # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates + position = Point(*_string_to_floats(guide.get('position'))) + position.y = doc_size[1] - position.y + + + # This one baffles me. I think inkscape might have gotten the order of + # their vector wrong? + parts = _string_to_floats(guide.get('orientation')) + direction = Point(parts[1], parts[0]) + + # We have a theoretically infinite line defined by a point on the line + # and a vector direction. Shapely can only deal in concrete line + # segments, so we'll pick points really far in either direction on the + # line and call it good enough. + lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) + + intersection = lines[0].intersection(lines[1]) + + if isinstance(intersection, shgeo.Point): + origin = [intersection.x, intersection.y] + simpletransform.applyTransformToPoint(viewbox_transform, origin) + return Point(*origin) + else: + # Either the two guides are the same line, or they're parallel. + return default + + +def write_embroidery_file(file_path, stitch_plan, svg): + origin = get_origin(svg) + + pattern = libembroidery.embPattern_create() + + for color_block in stitch_plan: + add_thread(pattern, make_thread(color_block.color)) + + for stitch in color_block: + if stitch.stop and stitch is not color_block.last_stitch: + # A STOP stitch that is not at the end of a color block + # occurs when the user specified "STOP after". "STOP" is the + # same thing as a color change, and the user will assign a + # special color at the machine that tells it to pause after. + # We need to add another copy of the same color here so that + # the stitches after the STOP are still the same color. + add_thread(pattern, make_thread(color_block.color)) + + flags = get_flags(stitch) + libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, flags, 1) + + libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, libembroidery.END, 1) + + # convert from pixels to millimeters + libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) + + # SVG and embroidery disagree on the direction of the Y axis + libembroidery.embPattern_flipVertical(pattern) + + libembroidery.embPattern_write(pattern, file_path) diff --git a/lib/simulator.py b/lib/simulator.py index cc9442ea..c7e74353 100644 --- a/lib/simulator.py +++ b/lib/simulator.py @@ -4,8 +4,7 @@ import wx import colorsys from itertools import izip -from . import PIXELS_PER_MM -from .svg import color_block_to_point_lists +from .svg import PIXELS_PER_MM, color_block_to_point_lists class EmbroiderySimulator(wx.Frame): diff --git a/lib/stitch_plan/__init__.py b/lib/stitch_plan/__init__.py index 6c1f418a..791a5f20 100644 --- a/lib/stitch_plan/__init__.py +++ b/lib/stitch_plan/__init__.py @@ -1 +1,2 @@ from stitch_plan import patches_to_stitch_plan, StitchPlan, ColorBlock +from .stitch import Stitch diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py new file mode 100644 index 00000000..6a8579c2 --- /dev/null +++ b/lib/stitch_plan/stitch.py @@ -0,0 +1,15 @@ +from ..utils.geometry import Point + + +class Stitch(Point): + def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): + self.x = x + self.y = y + self.color = color + self.jump = jump + self.trim = trim + self.stop = stop + self.no_ties = no_ties + + def __repr__(self): + return "Stitch(%s, %s, %s, %s, %s, %s, %s)" % (self.x, self.y, self.color, "JUMP" if self.jump else " ", "TRIM" if self.trim else " ", "STOP" if self.stop else " ", "NO TIES" if self.no_ties else " ") diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py index fab87876..570a7645 100644 --- a/lib/stitch_plan/stitch_plan.py +++ b/lib/stitch_plan/stitch_plan.py @@ -1,8 +1,9 @@ -from .. import Stitch, PIXELS_PER_MM -from ..utils.geometry import Point +from .stitch import Stitch from .stop import process_stop from .trim import process_trim from .ties import add_ties +from ..svg import PIXELS_PER_MM +from ..utils.geometry import Point from ..threads import ThreadColor diff --git a/lib/stitch_plan/ties.py b/lib/stitch_plan/ties.py index 1207ea51..f9c5b721 100644 --- a/lib/stitch_plan/ties.py +++ b/lib/stitch_plan/ties.py @@ -1,7 +1,9 @@ +from copy import deepcopy + +from .stitch import Stitch from ..utils import cut_path from ..stitches import running_stitch -from .. import Stitch -from copy import deepcopy + def add_tie(stitches, tie_path): if stitches[-1].no_ties: diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 7f265909..518a2812 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -1,5 +1,3 @@ -from fill import intersect_region_with_grating, row_num, stitch_row -from .. import _, PIXELS_PER_MM, Point as InkstitchPoint import sys import shapely import networkx @@ -7,6 +5,11 @@ import math from itertools import groupby from collections import deque +from .fill import intersect_region_with_grating, row_num, stitch_row +from ..i18n import _ +from ..svg import PIXELS_PER_MM +from ..utils.geometry import Point as InkstitchPoint + class MaxQueueLengthExceeded(Exception): pass diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 1b7377b0..14971cb4 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -1,9 +1,10 @@ -from .. import PIXELS_PER_MM -from ..utils import cache, Point as InkstitchPoint import shapely import math import sys +from ..svg import PIXELS_PER_MM +from ..utils import cache, Point as InkstitchPoint + def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) @@ -242,4 +243,3 @@ def pull_runs(rows, shape, row_spacing): count += 1 return runs - diff --git a/lib/svg.py b/lib/svg.py deleted file mode 100644 index 0728309b..00000000 --- a/lib/svg.py +++ /dev/null @@ -1,76 +0,0 @@ -import simpletransform, simplestyle, inkex -from . import _, get_viewbox_transform, cache, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG - -def color_block_to_point_lists(color_block): - point_lists = [[]] - - for stitch in color_block: - if stitch.trim: - if point_lists[-1]: - point_lists.append([]) - continue - - if not stitch.jump and not stitch.stop: - point_lists[-1].append(stitch.as_tuple()) - - return point_lists - - -@cache -def get_correction_transform(svg): - transform = get_viewbox_transform(svg) - - # we need to correct for the viewbox - transform = simpletransform.invertTransform(transform) - transform = simpletransform.formatTransform(transform) - - return transform - - -def color_block_to_paths(color_block, svg): - paths = [] - # We could emit just a single path with one subpath per point list, but - # emitting multiple paths makes it easier for the user to manipulate them. - for point_list in color_block_to_point_lists(color_block): - color = color_block.color.visible_on_white.to_hex_str() - paths.append(inkex.etree.Element( - SVG_PATH_TAG, - {'style': simplestyle.formatStyle( - {'stroke': color, - 'stroke-width': "0.4", - 'fill': 'none'}), - 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), - 'transform': get_correction_transform(svg), - 'embroider_manual_stitch': 'true', - 'embroider_trim_after': 'true', - })) - - # no need to trim at the end of a thread color - if paths: - paths[-1].attrib.pop('embroider_trim_after') - - return paths - - -def render_stitch_plan(svg, stitch_plan): - layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - if layer is None: - layer = inkex.etree.Element(SVG_GROUP_TAG, - {'id': '__inkstitch_stitch_plan__', - INKSCAPE_LABEL: _('Stitch Plan'), - INKSCAPE_GROUPMODE: 'layer'}) - else: - # delete old stitch plan - del layer[:] - - # make sure the layer is visible - layer.set('style', 'display:inline') - - for i, color_block in enumerate(stitch_plan): - group = inkex.etree.SubElement(layer, - SVG_GROUP_TAG, - {'id': '__color_block_%d__' % i, - INKSCAPE_LABEL: "color block %d" % (i + 1)}) - group.extend(color_block_to_paths(color_block, svg)) - - svg.append(layer) diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py new file mode 100644 index 00000000..1895bba4 --- /dev/null +++ b/lib/svg/__init__.py @@ -0,0 +1,2 @@ +from .svg import color_block_to_point_lists, render_stitch_plan +from .units import * diff --git a/lib/svg/svg.py b/lib/svg/svg.py new file mode 100644 index 00000000..3bc546e7 --- /dev/null +++ b/lib/svg/svg.py @@ -0,0 +1,81 @@ +import simpletransform, simplestyle, inkex + +from .units import get_viewbox_transform +from .tags import SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG +from ..i18n import _ +from ..utils import cache + + +def color_block_to_point_lists(color_block): + point_lists = [[]] + + for stitch in color_block: + if stitch.trim: + if point_lists[-1]: + point_lists.append([]) + continue + + if not stitch.jump and not stitch.stop: + point_lists[-1].append(stitch.as_tuple()) + + return point_lists + + +@cache +def get_correction_transform(svg): + transform = get_viewbox_transform(svg) + + # we need to correct for the viewbox + transform = simpletransform.invertTransform(transform) + transform = simpletransform.formatTransform(transform) + + return transform + + +def color_block_to_paths(color_block, svg): + paths = [] + # We could emit just a single path with one subpath per point list, but + # emitting multiple paths makes it easier for the user to manipulate them. + for point_list in color_block_to_point_lists(color_block): + color = color_block.color.visible_on_white.to_hex_str() + paths.append(inkex.etree.Element( + SVG_PATH_TAG, + {'style': simplestyle.formatStyle( + {'stroke': color, + 'stroke-width': "0.4", + 'fill': 'none'}), + 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), + 'transform': get_correction_transform(svg), + 'embroider_manual_stitch': 'true', + 'embroider_trim_after': 'true', + })) + + # no need to trim at the end of a thread color + if paths: + paths[-1].attrib.pop('embroider_trim_after') + + return paths + + +def render_stitch_plan(svg, stitch_plan): + layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + if layer is None: + layer = inkex.etree.Element(SVG_GROUP_TAG, + {'id': '__inkstitch_stitch_plan__', + INKSCAPE_LABEL: _('Stitch Plan'), + INKSCAPE_GROUPMODE: 'layer'}) + else: + # delete old stitch plan + del layer[:] + + # make sure the layer is visible + layer.set('style', 'display:inline') + + for i, color_block in enumerate(stitch_plan): + group = inkex.etree.SubElement(layer, + SVG_GROUP_TAG, + {'id': '__color_block_%d__' % i, + INKSCAPE_LABEL: "color block %d" % (i + 1)}) + group.extend(color_block_to_paths(color_block, svg)) + + svg.append(layer) diff --git a/lib/svg/tags.py b/lib/svg/tags.py new file mode 100644 index 00000000..fee59957 --- /dev/null +++ b/lib/svg/tags.py @@ -0,0 +1,12 @@ +import inkex + + +SVG_PATH_TAG = inkex.addNS('path', 'svg') +SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') +SVG_DEFS_TAG = inkex.addNS('defs', 'svg') +SVG_GROUP_TAG = inkex.addNS('g', 'svg') + +INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') +INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') + +EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/svg/units.py b/lib/svg/units.py new file mode 100644 index 00000000..015da60e --- /dev/null +++ b/lib/svg/units.py @@ -0,0 +1,105 @@ +import simpletransform + +from ..utils import cache + +# modern versions of Inkscape use 96 pixels per inch as per the CSS standard +PIXELS_PER_MM = 96 / 25.4 + +# cribbed from inkscape-silhouette +def parse_length_with_units( str ): + + ''' + Parse an SVG value which may or may not have units attached + This version is greatly simplified in that it only allows: no units, + units of px, mm, and %. Everything else, it returns None for. + There is a more general routine to consider in scour.py if more + generality is ever needed. + ''' + + u = 'px' + s = str.strip() + if s[-2:] == 'px': + s = s[:-2] + elif s[-2:] == 'mm': + u = 'mm' + s = s[:-2] + elif s[-2:] == 'pt': + u = 'pt' + s = s[:-2] + elif s[-2:] == 'pc': + u = 'pc' + s = s[:-2] + elif s[-2:] == 'cm': + u = 'cm' + s = s[:-2] + elif s[-2:] == 'in': + u = 'in' + s = s[:-2] + elif s[-1:] == '%': + u = '%' + s = s[:-1] + try: + v = float( s ) + except: + raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) + + return v, u + + +def convert_length(length): + value, units = parse_length_with_units(length) + + if not units or units == "px": + return value + + if units == 'pt': + value /= 72 + units = 'in' + + if units == 'pc': + value /= 6 + units = 'in' + + if units == 'cm': + value *= 10 + units = 'mm' + + if units == 'mm': + value = value / 25.4 + units = 'in' + + if units == 'in': + # modern versions of Inkscape use CSS's 96 pixels per inch. When you + # open an old document, inkscape will add a viewbox for you. + return value * 96 + + raise ValueError(_("Unknown unit: %s") % units) + + +@cache +def get_doc_size(svg): + doc_width = convert_length(svg.get('width')) + doc_height = convert_length(svg.get('height')) + + return doc_width, doc_height + +@cache +def get_viewbox_transform(node): + # somewhat cribbed from inkscape-silhouette + doc_width, doc_height = get_doc_size(node) + + viewbox = node.get('viewBox').strip().replace(',', ' ').split() + + dx = -float(viewbox[0]) + dy = -float(viewbox[1]) + transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) + + try: + sx = doc_width / float(viewbox[2]) + sy = doc_height / float(viewbox[3]) + scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) + transform = simpletransform.composeTransform(transform, scale_transform) + except ZeroDivisionError: + pass + + return transform diff --git a/lib/threads/catalog.py b/lib/threads/catalog.py index cebae4ff..d9981dc6 100644 --- a/lib/threads/catalog.py +++ b/lib/threads/catalog.py @@ -3,8 +3,10 @@ from os.path import dirname, realpath import sys from glob import glob from collections import Sequence + from .palette import ThreadPalette + class _ThreadCatalog(Sequence): """Holds a set of ThreadPalettes.""" diff --git a/lib/threads/palette.py b/lib/threads/palette.py index e1f47c7f..785fb082 100644 --- a/lib/threads/palette.py +++ b/lib/threads/palette.py @@ -1,9 +1,10 @@ from collections import Set -from .color import ThreadColor from colormath.color_objects import sRGBColor, LabColor from colormath.color_conversions import convert_color from colormath.color_diff import delta_e_cie1994 +from .color import ThreadColor + def compare_thread_colors(color1, color2): # K_L=2 indicates textiles diff --git a/lib/utils/io.py b/lib/utils/io.py index e87b9881..be1fdf24 100644 --- a/lib/utils/io.py +++ b/lib/utils/io.py @@ -2,6 +2,7 @@ import os import sys from cStringIO import StringIO + def save_stderr(): # GTK likes to spam stderr, which inkscape will show in a dialog. null = open(os.devnull, 'w') diff --git a/messages.po b/messages.po index a9fae6ff..7b2ca815 100644 --- a/messages.po +++ b/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2018-04-29 21:45-0400\n" +"POT-Creation-Date: 2018-05-01 21:21-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,17 +17,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.3\n" -#, python-format -msgid "parseLengthWithUnits: unknown unit %s" -msgstr "" - -#, python-format -msgid "Unknown unit: %s" -msgstr "" - -msgid "Stitch Plan" -msgstr "" - msgid "Auto-Fill" msgstr "" @@ -333,6 +322,17 @@ msgid "" "file to lexelby@github." msgstr "" +msgid "Stitch Plan" +msgstr "" + +#, python-format +msgid "parseLengthWithUnits: unknown unit %s" +msgstr "" + +#, python-format +msgid "Unknown unit: %s" +msgstr "" + msgid "Color" msgstr "" -- cgit v1.3.1