#!/usr/bin/env python3 ''' Copyright (C) 2020 Scott Pakin, scott-ink@pakin.org 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 the Free Software Foundation; either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ''' from collections import defaultdict import inkex from inkex.paths import Arc, Curve, Horz, Line, Move, Quadratic, Smooth, TepidQuadratic, Vert, ZoneClose class SnapObjectPoints(inkex.EffectExtension): "Snap the points on multiple paths towards each other." def add_arguments(self, pars): pars.add_argument('--max_dist', type=float, default=25.0, help='Maximum distance to be considered a "nearby" point') pars.add_argument('--controls', type=inkex.Boolean, default=True, help='Snap control points') pars.add_argument('--ends', type=inkex.Boolean, default=True, help='Snap endpoints') pars.add_argument('--first_only', type=inkex.Boolean, default=True, help='Modify only the first selected path') def _bin_points(self): "Associate each path ID with a list of control points and a list of endpoints." cpoints = defaultdict(list) epoints = defaultdict(list) for node in self.svg.selection.filter(inkex.PathElement).values(): for cmd in node.path.to_absolute().proxy_iterator(): pid = node.get_id() cpoints[pid].extend(cmd.control_points) epoints[pid].append(cmd.end_point) return cpoints, epoints def _find_nearest(self, pid, x0, y0, other_points): '''Find the nearest neighbor to a given point, and return the midpoint of the given point and its neighbor.''' max_dist2 = self.options.max_dist**2 # Work with squares instead of wasting time on square roots. bx, by = x0, y0 # Best new point best_dist2 = max_dist2 # Minimal distance observed from (x0, y0) for k, pts in other_points.items(): if k == pid: continue # Don't compare to our own points. for vec in pts: x1, y1 = vec.x, vec.y dist2 = (x1 - x0)**2 + (y1 - y0)**2 # Squared distance if dist2 > best_dist2: continue # Not the nearest point best_dist2 = dist2 bx, by = x1, y1 return (x0 + bx)/2, (y0 + by)/2 def _simplify_paths(self): 'Make all commands absolute, and replace Vert and Horz commands with Line.' for node in self.svg.selection.filter(inkex.PathElement).values(): path = node.path.to_absolute() new_path = [] prev = inkex.Vector2d() prev_prev = inkex.Vector2d() first = inkex.Vector2d() for i, cmd in enumerate(path): if i == 0: first = cmd.end_point(first, prev) prev, prev_prev = first, first if isinstance(cmd, Vert): cmd = cmd.to_line(prev) elif isinstance(cmd, Horz): cmd = cmd.to_line(prev) new_path.append(cmd) if isinstance(cmd, (Curve, Quadratic, Smooth, TepidQuadratic)): prev_prev = list(cmd.control_points(first, prev, prev_prev))[-2] prev = cmd.end_point(first, prev) node.path = new_path def effect(self): """Snap control points to other objects' control points and endpoints to other objects' endpoints.""" # This function uses an O(n^2) algorithm, which shouldn't be too slow # for typical point counts. # # As a preprocessing step, we first simplify the paths to reduce the # number of special cases we'll need to deal with. Then, we associate # each path with all of its control points and endpoints. if len(self.svg.selection.filter(inkex.PathElement)) < 2: raise inkex.utils.AbortExtension(_('Snap Object Points requires that at least two paths be selected.')) self._simplify_paths() cpoints, epoints = self._bin_points() # Process in turn each command on each path. for node in self.svg.selection.filter(inkex.PathElement).values(): pid = node.get_id() path = node.path new_path = [] for cmd in path: args = cmd.args new_args = list(args) na = len(args) if isinstance(cmd, (Curve, Line, Move, Quadratic, Smooth, TepidQuadratic)): # Zero or more control points followed by an endpoint. if self.options.controls: for i in range(0, na - 2, 2): new_args[i], new_args[i + 1] = self._find_nearest(pid, args[i], args[i + 1], cpoints) if self.options.ends: new_args[na - 2], new_args[na - 1] = self._find_nearest(pid, args[na - 2], args[na - 1], epoints) elif isinstance(cmd, ZoneClose): # No arguments at all. pass elif isinstance(cmd, Arc): # Non-coordinates followed by an endpoint. if self.options.ends: new_args[na - 2], new_args[na - 1] = self._find_nearest(pid, args[na - 2], args[na - 1], epoints) else: # Unexpected command. inkex.errormsg(_('Unexpected path command "%s"' % cmd.name)) new_path.append(cmd.__class__(*new_args)) node.path = new_path if self.options.first_only: break if __name__ == '__main__': SnapObjectPoints().run()