Added "Snap Object Points"
This commit is contained in:
parent
7706bc8f0a
commit
32e62d2c2b
21
extensions/fablabchemnitz/snap_objects.inx
Normal file
21
extensions/fablabchemnitz/snap_objects.inx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||||
|
<name>Snap Object Points</name>
|
||||||
|
<id>fablabchemnitz.de.snap_objects</id>
|
||||||
|
<param name="max_dist" type="float" min="1" max="9999" precision="2" gui-text="Maximum snap distance">25</param>
|
||||||
|
<param name="controls" type="bool" gui-text="Snap control points">true</param>
|
||||||
|
<param name="ends" type="bool" gui-text="Snap endpoints">true</param>
|
||||||
|
<param name="first_only" type="bool" gui-text="Modify only the first selected path">false</param>
|
||||||
|
<label>This effect snaps points in each selected object to nearby points in other selected objects.</label>
|
||||||
|
<effect>
|
||||||
|
<object-type>path</object-type>
|
||||||
|
<effects-menu>
|
||||||
|
<submenu name="FabLab Chemnitz">
|
||||||
|
<submenu name="Modify existing Path(s)"/>
|
||||||
|
</submenu>
|
||||||
|
</effects-menu>
|
||||||
|
</effect>
|
||||||
|
<script>
|
||||||
|
<command location="inx" interpreter="python">snap_objects.py</command>
|
||||||
|
</script>
|
||||||
|
</inkscape-extension>
|
137
extensions/fablabchemnitz/snap_objects.py
Executable file
137
extensions/fablabchemnitz/snap_objects.py
Executable file
@ -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()
|
Reference in New Issue
Block a user