From 32e62d2c2b0cbe8a9b0548962c22ce4bb11d6f9d Mon Sep 17 00:00:00 2001 From: Mario Voigt Date: Wed, 9 Dec 2020 09:52:03 +0100 Subject: [PATCH] Added "Snap Object Points" --- extensions/fablabchemnitz/snap_objects.inx | 21 ++++ extensions/fablabchemnitz/snap_objects.py | 137 +++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 extensions/fablabchemnitz/snap_objects.inx create mode 100755 extensions/fablabchemnitz/snap_objects.py diff --git a/extensions/fablabchemnitz/snap_objects.inx b/extensions/fablabchemnitz/snap_objects.inx new file mode 100644 index 00000000..b1854565 --- /dev/null +++ b/extensions/fablabchemnitz/snap_objects.inx @@ -0,0 +1,21 @@ + + + Snap Object Points + fablabchemnitz.de.snap_objects + 25 + true + true + false + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/snap_objects.py b/extensions/fablabchemnitz/snap_objects.py new file mode 100755 index 00000000..de931bc7 --- /dev/null +++ b/extensions/fablabchemnitz/snap_objects.py @@ -0,0 +1,137 @@ +#!/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 SnapObjects(inkex.Effect): + "Snap the points on multiple paths towards each other." + + def __init__(self): + inkex.Effect.__init__(self) + self.arg_parser.add_argument('--max_dist', type=float, default=25.0, help='Maximum distance to be considered a "nearby" point') + self.arg_parser.add_argument('--controls', type=inkex.Boolean, default=True, help='Snap control points') + self.arg_parser.add_argument('--ends', type=inkex.Boolean, default=True, help='Snap endpoints') + self.arg_parser.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__': + SnapObjects().run()