diff --git a/extensions/fablabchemnitz/chain_paths/chain_paths.py b/extensions/fablabchemnitz/chain_paths/chain_paths.py index 0a1805d..89d73d9 100644 --- a/extensions/fablabchemnitz/chain_paths/chain_paths.py +++ b/extensions/fablabchemnitz/chain_paths/chain_paths.py @@ -37,265 +37,272 @@ from optparse import SUPPRESS_HELP class ChainPaths(inkex.EffectExtension): - def __init__(self): - inkex.Effect.__init__(self) + def __init__(self): + inkex.Effect.__init__(self) - # For handling an SVG viewbox attribute, we will need to know the - # values of the document's width and height attributes as well - # as establishing a transform from the viewbox to the display. - self.chain_epsilon = 0.01 - self.snap_ends = True - self.close_loops = True - self.segments_done = {} - self.min_missed_distance_sq = None - self.chained_count = 0 + # For handling an SVG viewbox attribute, we will need to know the + # values of the document's width and height attributes as well + # as establishing a transform from the viewbox to the display. + self.chain_epsilon = 0.01 + self.snap_ends = True + self.close_loops = True + self.segments_done = {} + self.min_missed_distance_sq = None + self.chained_count = 0 - self.arg_parser.add_argument('-V', '--version', type=inkex.Boolean, default=False, help = 'Just print version number ("' + __version__ + '") and exit.') - self.arg_parser.add_argument('-s', '--snap_ends', type=inkex.Boolean, default=True, help='snap end-points together when connecting') - self.arg_parser.add_argument('-c', '--close_loops', type=inkex.Boolean, default=True, help='close loops (start/end of the same path)') - self.arg_parser.add_argument('-l', '--limit', type=int, default=2000, help='Maximum items to process') - self.arg_parser.add_argument('-u', '--units', default="mm", help="measurement unit for epsilon") - self.arg_parser.add_argument('-e', '--chain_epsilon', type=float, default=0.01, help="Max. distance to connect [mm]") - self.arg_parser.add_argument('-d', '--debug', type=inkex.Boolean, default=False, help='Debug') + self.arg_parser.add_argument('-V', '--version', type=inkex.Boolean, default=False, help = 'Just print version number ("' + __version__ + '") and exit.') + self.arg_parser.add_argument('-s', '--snap_ends', type=inkex.Boolean, default=True, help='snap end-points together when connecting') + self.arg_parser.add_argument('-c', '--close_loops', type=inkex.Boolean, default=True, help='close loops (start/end of the same path)') + self.arg_parser.add_argument('-l', '--limit', type=int, default=2000, help='Maximum items to process') + self.arg_parser.add_argument('-u', '--units', default="mm", help="measurement unit for epsilon") + self.arg_parser.add_argument('-e', '--chain_epsilon', type=float, default=0.01, help="Max. distance to connect [mm]") + self.arg_parser.add_argument('-d', '--debug', type=inkex.Boolean, default=False, help='Debug') - def version(self): - return __version__ - def author(self): - return __author__ + def version(self): + return __version__ + def author(self): + return __author__ - def calc_unit_factor(self, units='mm'): - """ return the scale factor for all dimension conversions. - - The document units are always irrelevant as - everything in inkscape is expected to be in 90dpi pixel units - """ - dialog_units = self.svg.unittouu(str(1.0)+units) - self.unit_factor = 1.0 / dialog_units - return self.unit_factor + def calc_unit_factor(self, units='mm'): + """ return the scale factor for all dimension conversions. + - The document units are always irrelevant as + everything in inkscape is expected to be in 90dpi pixel units + """ + dialog_units = self.svg.unittouu(str(1.0)+units) + self.unit_factor = 1.0 / dialog_units + return self.unit_factor - def reverse_segment(self, seg): - r = [] - for s in reversed(seg): - # s has 3 elements: handle1, point, handle2 - # Swap handles. - s.reverse() - r.append(s) - return r + def reverse_segment(self, seg): + r = [] + for s in reversed(seg): + # s has 3 elements: handle1, point, handle2 + # Swap handles. + s.reverse() + r.append(s) + return r - def set_segment_done(self, so, id, n, msg=''): - if not id in self.segments_done: - self.segments_done[id] = {} - self.segments_done[id][n] = True - if so.debug: inkex.utils.debug("done {} {} {}".format(id, n, msg)) + def set_segment_done(self, so, id, n, msg=''): + if not id in self.segments_done: + self.segments_done[id] = {} + self.segments_done[id][n] = True + if so.debug: inkex.utils.debug("done {} {} {}".format(id, n, msg)) - def is_segment_done(self, id, n): - if not id in self.segments_done: - return False - if n in self.segments_done[id]: - return True - return False + def is_segment_done(self, id, n): + if not id in self.segments_done: + return False + if n in self.segments_done[id]: + return True + return False - def link_segments(self, seg1, seg2): - if self.snap_ends: - seg = seg1[:-1] - p1 = seg1[-1] - p2 = seg2[0] - # fuse p1 and p2 to create one new point: - # first handle from p1, point coordinates averaged, second handle from p2 - seg.append([ [ p1[0][0] , p1[0][1] ], - [ (p1[1][0] + p2[1][0]) * .5, (p1[1][1] + p2[1][1]) * .5 ], - [ p2[2][0] , p2[2][1] ] ]) - seg.extend(seg2[1:]) - else: - seg = seg1[:] - seg.extend(seg2[:]) - self.chained_count += 1 - return seg + def link_segments(self, seg1, seg2): + if self.snap_ends: + seg = seg1[:-1] + p1 = seg1[-1] + p2 = seg2[0] + # fuse p1 and p2 to create one new point: + # first handle from p1, point coordinates averaged, second handle from p2 + seg.append([ + [ p1[0][0] , p1[0][1] ], + [(p1[1][0] + p2[1][0]) * .5, (p1[1][1] + p2[1][1]) * .5], + [ p2[2][0] , p2[2][1] ] + ]) + seg.extend(seg2[1:]) + else: + seg = seg1[:] + seg.extend(seg2[:]) + self.chained_count += 1 + return seg - def near_ends(self, end1, end2): - """ requires self.eps_sq to be the square of the near distance """ - dx = end1[0] - end2[0] - dy = end1[1] - end2[1] - d_sq = dx * dx + dy * dy - if d_sq > self.eps_sq: - if self.min_missed_distance_sq is None: - self.min_missed_distance_sq = d_sq - elif self.min_missed_distance_sq > d_sq: - self.min_missed_distance_sq = d_sq - return False - else: - return True + def near_ends(self, end1, end2): + """ requires self.eps_sq to be the square of the near distance """ + dx = end1[0] - end2[0] + dy = end1[1] - end2[1] + d_sq = dx * dx + dy * dy + if d_sq > self.eps_sq: + if self.min_missed_distance_sq is None: + self.min_missed_distance_sq = d_sq + elif self.min_missed_distance_sq > d_sq: + self.min_missed_distance_sq = d_sq + return False + else: + return True - def effect(self): - so = self.options + def effect(self): + so = self.options - if so.version: - print(__version__) - sys.exit(0) + if so.version: + print(__version__) + sys.exit(0) - self.calc_unit_factor(so.units) + self.calc_unit_factor(so.units) - if so.snap_ends is not None: self.snap_ends = so.snap_ends - if so.close_loops is not None: self.close_loops = so.close_loops - if so.chain_epsilon is not None: self.chain_epsilon = so.chain_epsilon - if self.chain_epsilon < 0.001: self.chain_epsilon = 0.001 # keep a minimum. - self.eps_sq = self.chain_epsilon * self.unit_factor * self.chain_epsilon * self.unit_factor + if so.snap_ends is not None: self.snap_ends = so.snap_ends + if so.close_loops is not None: self.close_loops = so.close_loops + if so.chain_epsilon is not None: self.chain_epsilon = so.chain_epsilon + if self.chain_epsilon < 0.001: self.chain_epsilon = 0.001 # keep a minimum. + self.eps_sq = self.chain_epsilon * self.unit_factor * self.chain_epsilon * self.unit_factor - selected = self.svg.selected.items() + selected = self.svg.selected.items() - itemsCount = len(selected) - if not itemsCount: - inkex.errormsg("Please select one or more objects.") - return + itemsCount = len(selected) + if not itemsCount: + inkex.errormsg("Please select one or more objects.") + return - #selected = dict(reversed(list(selected))) #reverse - if so.limit > 0 and itemsCount > so.limit: - inkex.utils.debug("Maximum items to process is set to {}. You selected {} items. We continue with processing until limit is reached.".format(so.limit, itemsCount)) + #selected = dict(reversed(list(selected))) #reverse + if so.limit > 0 and itemsCount > so.limit: + inkex.utils.debug("Maximum items to process is set to {}. You selected {} items. We continue with processing until limit is reached.".format(so.limit, itemsCount)) - segments = [] - workedon = 0 - for id, node in selected: - if node.tag != inkex.addNS('path', 'svg'): - inkex.errormsg("Object id {} is not a path. Try\n - Path->Object to Path\n - Object->Ungroup".format(node.get('id'))) - return - if so.debug: inkex.utils.debug("id={}, tag=".format(idnode.get('id'), node.tag)) - path_d = CubicSuperPath(Path(node.get('d'))) - sub_idx = -1 - for sub in path_d: - sub_idx += 1 - # sub = [[[200.0, 300.0], [200.0, 300.0], [175.0, 290.0]], [[175.0, 265.0], [220.37694, 256.99876], [175.0, 240.0]], [[175.0, 215.0], [200.0, 200.0], [200.0, 200.0]]] - # this is a path of three points. All the bezier handles are included. the Structure is: - # [[handle0_OUT, point0, handle0_1], [handle1_0, point1, handle1_2], [handle2_1, point2, handle2_OUT]] - # the _OUT handles at the end of the path are ignored. The data structure has them identical to their points. - # - if so.debug: inkex.utils.debug(" sub={}".format(sub)) - end1 = [sub[ 0][1][0], sub[ 0][1][1]] - end2 = [sub[-1][1][0], sub[-1][1][1]] + segments = [] + workedon = 0 + for id, node in selected: + if node.tag != inkex.addNS('path', 'svg'): + inkex.errormsg("Object id {} is not a path. Try\n - Path->Object to Path\n - Object->Ungroup".format(node.get('id'))) + return + #check if node has a transform. If yes, print a warning + if node.get('transform') is not None: + inkex.utils.debug("Warning: node {} has transform {}. Use 'Apply Transforms' extension before to handle this.".format(node.get('id'), node.get('transform'))) + return + + if so.debug: inkex.utils.debug("id={}, tag=".format(idnode.get('id'), node.tag)) + path_d = CubicSuperPath(Path(node.get('d'))) + sub_idx = -1 + for sub in path_d: + sub_idx += 1 + # sub = [[[200.0, 300.0], [200.0, 300.0], [175.0, 290.0]], [[175.0, 265.0], [220.37694, 256.99876], [175.0, 240.0]], [[175.0, 215.0], [200.0, 200.0], [200.0, 200.0]]] + # this is a path of three points. All the bezier handles are included. the Structure is: + # [[handle0_OUT, point0, handle0_1], [handle1_0, point1, handle1_2], [handle2_1, point2, handle2_OUT]] + # the _OUT handles at the end of the path are ignored. The data structure has them identical to their points. + # + if so.debug: inkex.utils.debug(" sub={}".format(sub)) + end1 = [sub[ 0][1][0], sub[ 0][1][1]] + end2 = [sub[-1][1][0], sub[-1][1][1]] - # Remove trivial self reversal when building candidate segments list. - if ((len(sub) == 3) and self.near_ends(end1, end2)): - if so.debug: inkex.utils.debug("dropping segment from self-reversing path, length: {}".format(len(sub))) - sub.pop() - end2 = [sub[-1][1][0], sub[-1][1][1]] + # Remove trivial self reversal when building candidate segments list. + if ((len(sub) == 3) and self.near_ends(end1, end2)): + if so.debug: inkex.utils.debug("dropping segment from self-reversing path, length: {}".format(len(sub))) + sub.pop() + end2 = [sub[-1][1][0], sub[-1][1][1]] - segments.append({'id': id, 'n': sub_idx, 'end1': end1, 'end2':end2, 'seg': sub}) - if node.get(inkex.addNS('type', 'sodipodi')): - del node.attrib[inkex.addNS('type', 'sodipodi')] - workedon += 1 - if workedon >= so.limit and so.limit > 0: - break + segments.append({'id': id, 'n': sub_idx, 'end1': end1, 'end2':end2, 'seg': sub}) + if node.get(inkex.addNS('type', 'sodipodi')): + del node.attrib[inkex.addNS('type', 'sodipodi')] + workedon += 1 + if workedon >= so.limit and so.limit > 0: + break - if so.debug: inkex.utils.debug("-------- seen: ") - for s in segments: - if so.debug: inkex.utils.debug("{}, {}, {}, {}".format(s['id'], s['n'], s['end1'], s['end2'])) + if so.debug: inkex.utils.debug("-------- seen: ") + for s in segments: + if so.debug: inkex.utils.debug("{}, {}, {}, {}".format(s['id'], s['n'], s['end1'], s['end2'])) - # chain the segments - obsoleted = 0 - remaining = 0 + # chain the segments + obsoleted = 0 + remaining = 0 - workedon = 0 - for id, node in selected: - path_d = CubicSuperPath(Path(node.get('d'))) - # ATTENTION: for parsePath() it is the same, if first and last point coincide, or if the path is really closed. - path_closed = True if re.search(r'z\s*$', node.get('d')) else False - new = [] - cur_idx = -1 - for chain in path_d: - cur_idx += 1 - if not self.is_segment_done(id, cur_idx): - # quadratic algorithm: we check both ends of the current segment. - # If one of them is near another known end from the segments list, we - # chain this segment to the current segment and remove it from the - # list, - # end1-end1 or end2-end2: The new segment is reversed. - # end1-end2: The new segment is prepended to the current segment. - # end2-end1: The new segment is appended to the current segment. - self.set_segment_done(so, id, cur_idx, "output") # do not cross with ourselves. - end1 = [chain[ 0][1][0], chain[ 0][1][1]] - end2 = [chain[-1][1][0], chain[-1][1][1]] + workedon = 0 + for id, node in selected: + path_d = CubicSuperPath(Path(node.get('d'))) + # ATTENTION: for parsePath() it is the same, if first and last point coincide, or if the path is really closed. + path_closed = True if re.search(r'z\s*$', node.get('d')) else False + new = [] + cur_idx = -1 + for chain in path_d: + cur_idx += 1 + if not self.is_segment_done(id, cur_idx): + # quadratic algorithm: we check both ends of the current segment. + # If one of them is near another known end from the segments list, we + # chain this segment to the current segment and remove it from the + # list, + # end1-end1 or end2-end2: The new segment is reversed. + # end1-end2: The new segment is prepended to the current segment. + # end2-end1: The new segment is appended to the current segment. + self.set_segment_done(so, id, cur_idx, "output") # do not cross with ourselves. + end1 = [chain[ 0][1][0], chain[ 0][1][1]] + end2 = [chain[-1][1][0], chain[-1][1][1]] - # Remove trivial self revesal when doing the actual chain operation. - if ((len(chain) == 3) and self.near_ends(end1, end2)): - chain.pop() - end2 = [chain[-1][1][0], chain[-1][1][1]] + # Remove trivial self revesal when doing the actual chain operation. + if ((len(chain) == 3) and self.near_ends(end1, end2)): + chain.pop() + end2 = [chain[-1][1][0], chain[-1][1][1]] - segments_idx = 0 - while segments_idx < len(segments): - seg = segments[segments_idx] - if self.is_segment_done(seg['id'], seg['n']): - segments_idx += 1 - continue + segments_idx = 0 + while segments_idx < len(segments): + seg = segments[segments_idx] + if self.is_segment_done(seg['id'], seg['n']): + segments_idx += 1 + continue - if (self.near_ends(end1, seg['end1']) or - self.near_ends(end2, seg['end2'])): - seg['seg'] = self.reverse_segment(seg['seg']) - seg['end1'], seg['end2'] = seg['end2'], seg['end1'] - if so.debug: inkex.utils.debug("reversed seg {}, {}".format(seg['id'], seg['n'])) + if (self.near_ends(end1, seg['end1']) or + self.near_ends(end2, seg['end2'])): + seg['seg'] = self.reverse_segment(seg['seg']) + seg['end1'], seg['end2'] = seg['end2'], seg['end1'] + if so.debug: inkex.utils.debug("reversed seg {}, {}".format(seg['id'], seg['n'])) - if self.near_ends(end1, seg['end2']): - # prepend seg to chain - self.set_segment_done(so, seg['id'], seg['n'], 'prepended to {} {}'.format(id, cur_idx)) - chain = self.link_segments(seg['seg'], chain) - end1 = [chain[0][1][0], chain[0][1][1]] - segments_idx = 0 # this chain changed. re-visit all candidate - continue + if self.near_ends(end1, seg['end2']): + # prepend seg to chain + self.set_segment_done(so, seg['id'], seg['n'], 'prepended to {} {}'.format(id, cur_idx)) + chain = self.link_segments(seg['seg'], chain) + end1 = [chain[0][1][0], chain[0][1][1]] + segments_idx = 0 # this chain changed. re-visit all candidate + continue - if self.near_ends(end2, seg['end1']): - # append seg to chain - self.set_segment_done(so, seg['id'], seg['n'], 'appended to {} {}'.format(id, cur_idx)) - chain = self.link_segments(chain, seg['seg']) - end2 = [chain[-1][1][0], chain[-1][1][1]] - segments_idx = 0 # this chain changed. re-visit all candidate - continue + if self.near_ends(end2, seg['end1']): + # append seg to chain + self.set_segment_done(so, seg['id'], seg['n'], 'appended to {} {}'.format(id, cur_idx)) + chain = self.link_segments(chain, seg['seg']) + end2 = [chain[-1][1][0], chain[-1][1][1]] + segments_idx = 0 # this chain changed. re-visit all candidate + continue - segments_idx += 1 + segments_idx += 1 - # Now all joinable segments are joined. - # Finally, we can check, if the resulting path is a closed path: - # Closing a path here, isolates it from the rest. - # But as we prefer to make the chain as long as possible, we close late. - if self.near_ends(end1, end2) and not path_closed and self.close_loops: - if so.debug: inkex.utils.debug("closing closeable loop {}".format(id)) - if self.snap_ends: - # move first point to mid position - x1n = (chain[0][1][0] + chain[-1][1][0]) * 0.5 - y1n = (chain[0][1][1] + chain[-1][1][1]) * 0.5 - chain[0][1][0], chain[0][1][1] = x1n, y1n - # merge handle of the last point to the handle of the first point - dx0e = chain[-1][0][0] - chain[-1][1][0] - dy0e = chain[-1][0][1] - chain[-1][1][1] - if so.debug: inkex.utils.debug("handle diff: {} {}".format(dx0e, dy0e)) - # FIXME: this does not work. cubicsuperpath.formatPath() ignores this handle. - chain[0][0][0], chain[0][0][1] = x1n+dx0e, y1n+dy0e - # drop last point - chain.pop() - end2 = [chain[-1][1][0], chain[-1][1][1]] - path_closed = True - self.chained_count +=1 + # Now all joinable segments are joined. + # Finally, we can check, if the resulting path is a closed path: + # Closing a path here, isolates it from the rest. + # But as we prefer to make the chain as long as possible, we close late. + if self.near_ends(end1, end2) and not path_closed and self.close_loops: + if so.debug: inkex.utils.debug("closing closeable loop {}".format(id)) + if self.snap_ends: + # move first point to mid position + x1n = (chain[0][1][0] + chain[-1][1][0]) * 0.5 + y1n = (chain[0][1][1] + chain[-1][1][1]) * 0.5 + chain[0][1][0], chain[0][1][1] = x1n, y1n + # merge handle of the last point to the handle of the first point + dx0e = chain[-1][0][0] - chain[-1][1][0] + dy0e = chain[-1][0][1] - chain[-1][1][1] + if so.debug: inkex.utils.debug("handle diff: {} {}".format(dx0e, dy0e)) + # FIXME: this does not work. cubicsuperpath.formatPath() ignores this handle. + chain[0][0][0], chain[0][0][1] = x1n+dx0e, y1n+dy0e + # drop last point + chain.pop() + end2 = [chain[-1][1][0], chain[-1][1][1]] + path_closed = True + self.chained_count +=1 - new.append(chain) + new.append(chain) - if not len(new): - # node.clear() - if node.getparent() is not None: - node.delete() - obsoleted += 1 - if so.debug: inkex.utils.debug("Path node obsoleted: {}".format(id)) - else: - remaining += 1 - # BUG: All previously closed loops are open after we convert them back with cubicsuperpath.formatPath() - p_fmt = str(Path(CubicSuperPath(new).to_path().to_arrays())) - if path_closed: p_fmt += " z" - if so.debug: inkex.utils.debug("new path: {}".format(p_fmt)) - node.set('d', p_fmt) - workedon += 1 - if workedon >= so.limit and so.limit > 0: - break + if not len(new): + # node.clear() + if node.getparent() is not None: + node.delete() + obsoleted += 1 + if so.debug: inkex.utils.debug("Path node obsoleted: {}".format(id)) + else: + remaining += 1 + # BUG: All previously closed loops are open after we convert them back with cubicsuperpath.formatPath() + p_fmt = str(Path(CubicSuperPath(new).to_path().to_arrays())) + if path_closed: p_fmt += " z" + if so.debug: inkex.utils.debug("new path: {}".format(p_fmt)) + node.set('d', p_fmt) + workedon += 1 + if workedon >= so.limit and so.limit > 0: + break - # statistics: - if so.debug: inkex.utils.debug("Path nodes obsoleted: {}\nPath nodes remaining: {}".format(obsoleted, remaining)) - if self.min_missed_distance_sq is not None: - if so.debug: inkex.utils.debug("min_missed_distance: {} > {}".format(math.sqrt(float(self.min_missed_distance_sq))/self.unit_factor, self.chain_epsilon)+str(so.units)) - if so.debug: inkex.utils.debug("Successful link operations: {}".format(self.chained_count)) + # statistics: + if so.debug: inkex.utils.debug("Path nodes obsoleted: {}\nPath nodes remaining: {}".format(obsoleted, remaining)) + if self.min_missed_distance_sq is not None: + if so.debug: inkex.utils.debug("min_missed_distance: {} > {}".format(math.sqrt(float(self.min_missed_distance_sq))/self.unit_factor, self.chain_epsilon)+str(so.units)) + if so.debug: inkex.utils.debug("Successful link operations: {}".format(self.chained_count)) if __name__ == '__main__': - ChainPaths().run() + ChainPaths().run()