diff --git a/inkscape_driver/eggbot_reorder.inx b/inkscape_driver/eggbot_reorder.inx
index fb89fae3..8217eb3a 100755
--- a/inkscape_driver/eggbot_reorder.inx
+++ b/inkscape_driver/eggbot_reorder.inx
@@ -6,36 +6,28 @@
eggbot_reorder.py
inkex.py
-
- <_param name="Header" type="description" xml:space="preserve">
- This extension will perform simple optimizations
- of selected paths. It will try to change the
+ <_param name="Header" type="description" xml:space="preserve">
+ This extension will perform simple optimizations
+ of selected paths. It will try to change the
order of plotting so as to reduce the amount
of "pen-up" travel that occurs between paths.
-
+
Solving for optimal plot order is a difficult
problem, known in computer science as the
- "traveling salesman problem," or just "TSP."
-
+ "traveling salesman problem," or just "TSP".
+
This routine does not look for the best possible
solution; that can be slow. Instead it tries a
- few quick methods that often reduce pen-up
- travel distance (and time) by 30% or more.
+ few quick methods that often reduce pen-up
+ travel distance (and time) by 30% or more.
- Please note: This extension is still considered
- experimental, and is only provided in case you
- may find it useful. Be sure to save a copy of
+ Please note: This extension is still considered
+ experimental, and is only provided in case you
+ may find it useful. Be sure to save a copy of
your document before running this routine.
-
-
-
all
diff --git a/inkscape_driver/eggbot_reorder.py b/inkscape_driver/eggbot_reorder.py
index b438f052..22d04177 100755
--- a/inkscape_driver/eggbot_reorder.py
+++ b/inkscape_driver/eggbot_reorder.py
@@ -5,6 +5,7 @@
#
# Written by Matthew Beckler for the EggBot project.
# Email questions and comments to matthew at mbeckler dot org
+# Modified by Romain Testuz
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -29,139 +30,232 @@
import simplepath
import simpletransform
-
def dist(x0, y0, x1, y1):
- return math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
-
-
-def find_ordering_naive(objlist):
- """
- Takes a list of (id, (startX, startY, endX, endY)), and finds the best ordering.
- Doesn't handle anything fancy, like reversing the ordering, but it's useful for now.
- Returns a list of JUST THE IDs, in a better order, as well as the original and optimized
- "air distance" which is just the distance traveled in the air. Perhaps we want to make
- these comparison distances into something more relevant such as degrees traveled?
- """
-
- # let's figure out the default in-air length, so we know how much we improved
- air_length_default = 0
- try:
- oldx = objlist[0][1][2]
- oldy = objlist[0][1][3]
- except:
- inkex.errormsg(gettext.gettext(str(objlist[0])))
- sys.exit(1)
- for _, coords in objlist[1:]:
- air_length_default += dist(oldx, oldy, coords[0], coords[1])
- oldx = coords[2]
- oldy = coords[3]
-
- air_length_ordered = 0
- # for now, start with a random one:
- sort_list = []
- random_index = random.randint(0, len(objlist) - 1)
- sort_list.append(objlist[random_index])
- objlist.remove(objlist[random_index])
-
- # for now, do this in the most naive way:
- # for the previous end point, iterate over each remaining path and pick the closest starting point
- while objlist:
- min_distance = 100000000 # TODO put something else here better?
- for path in objlist:
- # instead of having a prevX, prevY, we just look at the last item in sort_list
- this_distance = dist(sort_list[-1][1][2], sort_list[-1][1][3], path[1][0], path[1][1])
- # this is such a common thing to do, you'd think there would be a name for it...
- if this_distance < min_distance:
- min_distance = this_distance
- min_path = path
- air_length_ordered += min_distance
- sort_list.append(min_path)
- objlist.remove(min_path)
-
- # remove the extraneous info from the list order
- sort_order = [id for id, coords in sort_list]
- return sort_order, air_length_default, air_length_ordered
+ return math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2)
+
+def dist_t(x, y):
+ return dist(x[0], x[1], y[0], y[1])
+
+class Path:
+ def __init__(self, id, p1, p2, reversed=False):
+ self.id = id
+ self.p1 = p1
+ self.p2 = p2
+ self.reversed = reversed
+
+ def __eq__(self, other): #ids must be unique
+ return self.id == other.id
+
+ def __str__(self):
+ return "{}: {} {}".format(self.id, self.get_start(), self.get_end())
+
+ def get_start(self):
+ return self.p1 if not self.reversed else self.p2
+
+ def get_end(self):
+ return self.p2 if not self.reversed else self.p1
+
+ def reverse(self):
+ self.reversed = not self.reversed
+
+ def dist_to_start(self, otherPath):
+ return dist_t(self.get_end(), otherPath.get_start())
+
+ def dist_to_end(self, otherPath):
+ return dist_t(self.get_end(), otherPath.get_end())
+
+def find_ordering(objlist, allowReverse):
+ """
+ Takes a list of Paths, and finds the best ordering.
+ Uses a greedy algorithm which can reverse the path direction if necessary
+ Returns a list of (id, reverse), as well as the original and optimized
+ "air distance" which is just the distance traveled in the air. Reverse indicate if the path must be reversed
+ """
+ start = (0.0, 0.0) #Start point TODO 0,0 is not always top left of the page
+
+ # let's figure out the default in-air length (this is not meaningful as we are using the dictionary ordering)
+ air_length_default = 0
+ old = start
+
+ for i, path in enumerate(objlist[1:]):
+ air_length_default += objlist[i-1].dist_to_start(path)
+
+ air_length_ordered = 0
+
+ sort_list = []
+ prev = Path("", start, start)
+
+ # for the previous end point, iterate over each remaining path and pick the closest starting point or ending point if allowed
+ while objlist:
+ min_distance = sys.float_info.max # The biggest number possible
+ for path in objlist:
+ dist_to_start = prev.dist_to_start(path)
+ dist_to_end = prev.dist_to_end(path)
+
+ if dist_to_start < min_distance:
+ min_distance = dist_to_start
+ min_path = path
+
+ if allowReverse and dist_to_end < min_distance:
+ min_distance = dist_to_end
+ path.reverse()
+ min_path = path
+
+ air_length_ordered += min_distance
+ sort_list.append(min_path)
+ objlist.remove(min_path)
+ prev = min_path
+
+ return sort_list, air_length_default, air_length_ordered
def conv(x, y, trans_matrix=None):
- """
- not used currently, but can be used to apply a translation matrix to an (x, y) pair
- I'm sure there is a better way to do this using simpletransform or it's ilk
- """
+ """
+ apply a translation matrix to an (x, y) pair
+ I'm sure there is a better way to do this using simpletransform or it's ilk
+ """
+
+ if trans_matrix:
+ xt = trans_matrix[0][0] * x + trans_matrix[0][1] * y + trans_matrix[0][2]
+ yt = trans_matrix[1][0] * x + trans_matrix[1][1] * y + trans_matrix[1][2]
+ return xt, yt
+ else:
+ return x, y
- if trans_matrix:
- xt = trans_matrix[0][0] * x + trans_matrix[0][1] * y + trans_matrix[0][2]
- yt = trans_matrix[1][0] * x + trans_matrix[1][1] * y + trans_matrix[1][2]
- return xt, yt
- else:
- return x, y
+def reversePath(path):
+ #Input: path in simplepath format
+ #Returns the reversed path in a svg string format
+ #In case of error the path is returned unchanged
+ #Some commands like A, H, V are not supported
+ #Adapted from https://github.com/Pomax/svg-path-reverse/blob/gh-pages/reverse.js
+ #Unpack sublists into a single list
+ flattenedPath = [item for sublist in path for subsublist in sublist for item in subsublist]
+ reversedPath = []
+
+ i = 0
+ while i < len(flattenedPath):
+ term = flattenedPath[i]
+ #At this point the next term must be a letter because the coordinates must have all been read
+ if term == "C":
+ pairs = 3; shift = 2
+ elif term == "Q":
+ pairs = 2; shift = 1
+ elif term == "L":
+ pairs = 1; shift = 1
+ elif term == "M":
+ pairs = 1; shift = 0
+ elif term == "Z":
+ reversedPath[0] = "Z"; i += 1; continue
+ else:
+ inkex.errormsg("Cannot reverse path, unknown command: {} or malformed path: {}".format(term, flattenedPath))
+ return ' '.join(str(e) for e in flattenedPath)#to string
+
+ if pairs == shift:
+ reversedPath.append(term)
+
+ for pair in range(0, pairs):
+ if pair == shift:
+ reversedPath.append(term)
+ i += 1
+ x = flattenedPath[i]
+ i += 1
+ y = flattenedPath[i]
+ reversedPath.append(y)
+ reversedPath.append(x)
+ i += 1
+
+ reversedPath.append("M")
+ reversedPath = list(reversed(reversedPath))
+ if reversedPath[-1] == "M":#Only remove the last element if it's not a Z
+ reversedPath = reversedPath[:-1]
+
+ return ' '.join(str(e) for e in reversedPath) # to string
class EggBotReorderPaths(inkex.Effect):
- def __init__(self):
- inkex.Effect.__init__(self)
-
- def get_start_end(self, node, transform):
- """Given a node, return the start and end points"""
- d = node.get('d')
- sp = simplepath.parsePath(d)
-
- # simplepath converts coordinates to absolute and cleans them up, but
- # these are still some big assumptions here, are they always valid? TODO
- start_x = sp[0][1][0]
- start_y = sp[0][1][1]
- if sp[-1][0] == 'Z':
- # go back to start
- end_x = start_x
- end_y = start_y
- else:
- end_x = sp[-1][1][-2]
- end_y = sp[-1][1][-1]
-
- sx, sy = conv(start_x, start_y, transform)
- ex, ey = conv(end_x, end_y, transform)
- return sx, sy, ex, ey
-
- def effect(self):
- """This is the main entry point"""
-
- # based partially on the restack.py extension
- if self.selected:
-
- # TODO check for non-path elements?
- # TODO it seems like the order of selection is not consistent
-
- # for each selected item - TODO make this be all objects, everywhere
- # I can think of two options:
- # 1. Iterate over all paths in root, then iterate over all layers, and their paths
- # 2. Some magic with xpath? (would this limit us to specific node types?)
-
- objlist = []
- for id_, node in self.selected.iteritems():
- transform = node.get('transform')
- if transform:
- transform = simpletransform.parseTransform(transform)
-
- item = (id_, self.get_start_end(node, transform))
- objlist.append(item)
-
- # sort / order the objects
- sort_order, air_distance_default, air_distance_ordered = find_ordering_naive(objlist)
-
- for id_ in sort_order:
- # There's some good magic here, that you can use an
- # object id to index into self.selected. Brilliant!
- self.current_layer.append(self.selected[id_])
-
- if air_distance_default > 0: # don't divide by zero. :P
- improvement_pct = 100 * ((air_distance_default - air_distance_ordered) / air_distance_default)
- inkex.errormsg(gettext.gettext("Selected paths have been reordered and optimized for quicker EggBot plotting.\n\n"
- "Original air-distance: {0:d}\n"
- "Optimized air-distance: {1:d}\n"
- "Distance reduced by: {2:1.2f}%\n\n"
- "Have a nice day!".format(air_distance_default, air_distance_ordered, improvement_pct)))
- else:
- inkex.errormsg(gettext.gettext("Unable to start. Please select multiple distinct paths. :)"))
+ def __init__(self):
+ inkex.Effect.__init__(self)
+ self.OptionParser.add_option("-r", "--allowReverse", action="store", type="inkbool",
+ dest="allowReverse", default=True, help="Allow path reversal")
+
+ def get_start_end(self, node):
+ """Given a node, return the start and end points"""
+ if node.tag != inkex.addNS('path', 'svg'):
+ inkex.errormsg("Groups are not supported, please ungroup for better results")
+ return (0, 0, 0, 0)
+
+ d = node.get('d')
+ sp = simplepath.parsePath(d)
+
+ # simplepath converts coordinates to absolute and cleans them up, but
+ # these are still some big assumptions here, are they always valid? TODO
+ startX = sp[0][1][0]
+ startY = sp[0][1][1]
+ if sp[-1][0] == 'Z':
+ # go back to start
+ endX = startX
+ endY = startY
+ else:
+ endX = sp[-1][1][-2]
+ endY = sp[-1][1][-1]
+
+ transform = node.get('transform')
+ if transform:
+ transform = simpletransform.parseTransform(transform)
+
+ sx, sy = conv(startX, startY, transform)
+ ex, ey = conv(endX, endY, transform)
+ return (sx, sy, ex, ey)
+
+ def effect(self):
+ """This is the main entry point"""
+
+ # based partially on the restack.py extension
+ if len(self.selected) > 0:
+ # TODO check for non-path elements?
+ # TODO it seems like the order of selection is not consistent
+ # => self.selected is a dict so it has no meaningful order and should not be used to evaluate the original path length
+
+ #fid = open("/home/matthew/debug.txt", "w")
+
+ # for each selected item
+ # I can think of two options:
+ # 1. Iterate over all paths in root, then iterate over all layers, and their paths
+ # 2. Some magic with xpath? (would this limit us to specific node types?)
+
+ objlist = []
+ for id, node in self.selected.iteritems():
+ (sx, sy, ex, ey) = self.get_start_end(node)
+ path = Path(id, (sx, sy), (ex, ey))
+ objlist.append(path)
+
+ # sort / order the objects
+ sort_order, air_distance_default, air_distance_ordered = find_ordering(objlist, self.options.allowReverse)
+
+ reverseCount = 0
+ for path in sort_order:
+ node = self.selected[path.id]
+ if node.tag == inkex.addNS('path', 'svg'):
+ node_sp = simplepath.parsePath(node.get('d'))
+ if(path.reversed):
+ node_sp_string = reversePath(node_sp)
+ reverseCount += 1
+ else:
+ node_sp_string = simplepath.formatPath(node_sp)
+
+ node.set('d', node_sp_string)
+
+ #keep in mind the different selected ids might have different parents
+ self.getParentNode(node).append(node)
+
+ inkex.errormsg("Reversed {} paths.".format(reverseCount))
+ #fid.close()
+
+ if air_distance_default > 0: # don't divide by zero. :P
+ improvement_pct = 100 * (( air_distance_default - air_distance_ordered) / (air_distance_default))
+ inkex.errormsg(gettext.gettext("Selected paths have been reordered and optimized for quicker EggBot plotting.\n\nDefault air-distance: %d\nOptimized air-distance: %d\nDistance reduced by: %1.2d%%\n\nHave a nice day!" % (air_distance_default, air_distance_ordered, improvement_pct)))
+ else:
+ inkex.errormsg(gettext.gettext("Unable to start. Please select multiple distinct paths. :)"))
e = EggBotReorderPaths()