diff --git a/bridge_subpaths.inx b/bridge_subpaths.inx new file mode 100644 index 0000000000000000000000000000000000000000..20cf07f8ad464cca75b19ee1d285d0acac70f262 --- /dev/null +++ b/bridge_subpaths.inx @@ -0,0 +1,43 @@ + + + + + + Bridge Subpaths + org.inkscape.path.bridge_subpaths + + + true + false + + + + + + + path + + + + + + + + diff --git a/bridge_subpaths.py b/bridge_subpaths.py new file mode 100644 index 0000000000000000000000000000000000000000..7f26a332f6d862a8bb1dae91f91ef1a4c82f7795 --- /dev/null +++ b/bridge_subpaths.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +""" +Inkscape extension to join the selected paths. +Tested with Inkscape version 1.4 + +Based on https://github.com/Shriinivas/inkscapejoinpaths/tree/520ccf3d7100cbfa0c15c660b4d109672b35e187 +by Shrinivas Kulkarni (khemadeva@gmail.com) + +SPDX-FileCopyrightText: +SPDX-FileCopyrightText: 2025 Intevation GmbH +SPDX-License-Identifier: GPL-2.0-or-later +""" + +import logging +import inkex +from inkex import PathElement, Path, Boolean +from inkex.paths import ZoneClose +from inkex.bezier import pointdistance +from inkex.localization import inkex_gettext as _ + +logger = logging.getLogger(__file__) + + +class BridgeSubpaths(inkex.EffectExtension): + def add_arguments(self, pars): + pars.add_argument("--delete_orig", type=Boolean, default=False) + pars.add_argument("--sort_subpaths", type=Boolean, default=True) + pars.add_argument("--tab", help="Select Options") + + def effect(self): + paths = self.svg.selection.get(PathElement) + if len(paths) != 1: + inkex.errormsg(_("Please only select one path (with several subpaths).")) + return + + delete_orig = self.options.delete_orig + sort_subpaths = self.options.sort_subpaths + + ordered_paths = self.order_paths(paths, sort_subpaths) + bridged_path = self.bridge_paths(ordered_paths) + + new_path = PathElement() + self.copy_style(paths[0], new_path) + + layer = self.svg.get_current_layer() + bridged_path.transform(-layer.composed_transform()) # Just in case + new_path.path = bridged_path + + layer.add(new_path) + + if delete_orig: + for p in paths: + p.getparent().remove(p) + + def copy_style(self, source_path, target_path): + if source_path.style: + target_path.style = source_path.style + + style_attributes = [ + "fill", + "stroke", + "stroke-width", + "opacity", + "fill-opacity", + "stroke-opacity", + ] + for attr in style_attributes: + if attr in source_path.attrib: + target_path.set(attr, source_path.get(attr)) + + def closest_nodes(self, subpath1, subpath2): + """Return the points from each subpaths which are nearest. + + Using a brute force algorithm, as it works all the time. See + https://www.crunchydata.com/blog/inside-postgis-calculating-distance + for potentially more clever algorithms (that work on edges not + on nodes like this one). + """ + + def min_dist_to_path(basis_node, path): + """Return tuple (point, distance) where distance is smallest.""" + node_distances = map( + lambda node: ( + node, + pointdistance(basis_node.end_point(0, 0), node.end_point(0, 0)), + ), + path, + ) + + return min(node_distances, key=lambda t: t[1]) + + nodes_distances = list( + map(lambda node: (node,) + min_dist_to_path(node, subpath2), subpath1) + ) + + logger.debug(f"{nodes_distances=}") + # two subpath that are closed will get a zero distance ZoneClose + # to ZoneClose, so we eliminate it + nodes_distances_no_z = filter( + lambda t: not isinstance(t[0], ZoneClose), nodes_distances + ) + + return min(nodes_distances_no_z, key=lambda t: t[2]) + + def order_paths(self, pathElems, sort_subpaths): + """Prepare pathElems as subpaths for bridging. + + Internally transfer pathElems to_absolute() so that point operations + like distance calculations work the same way. + """ + sub_paths = [ + sub.transform(p.composed_transform()) + for p in pathElems + for sub in p.path.to_absolute().break_apart() + ] + + if len(sub_paths) == 1: + inkex.errormsg(_("Only found one subpath, nothing to bridge.")) + + if sort_subpaths: + ordered = [sub_paths[0]] + remaining = sub_paths[1:] + while remaining: + end_point = list(ordered[-1].end_points)[-1] + cmp_paths = [] + for ppath in remaining: + pts = list(ppath.end_points) + cmp_paths.append([ppath, pts[0], True]) + cmp_paths.append([ppath, pts[-1], False]) + nearest = min(cmp_paths, key=lambda p: pointdistance(end_point, p[1])) + next_path = nearest[0] if nearest[2] else nearest[0].reverse() + ordered.append(next_path) + remaining.remove(nearest[0]) + return ordered + + return sub_paths + + def bridge_paths(self, paths) -> Path: + bridged = paths[0] + for i, subpath in enumerate(paths[1:]): + n1, n2, d = self.closest_nodes(paths[i], subpath) + p1 = n1.end_point(0, 0) + p2 = n2.end_point(0, 0) + bridged.extend(Path(f"M {p1[0]},{p1[1]} L {p2[0]},{p2[1]}")) + logger.debug(f"added line with {p1=} {p2=} {d=} ") + bridged.extend(subpath) + + return bridged + + +if __name__ == "__main__": + # comment the next 2 lines to enable writing a debuging log for analysis: + # logging.basicConfig( + # filename='inkscape_extension.log', level=logging.DEBUG) + logger.info("logging started") + BridgeSubpaths().run() diff --git a/icons/org.inkscape.path.bridge_subpaths.svg b/icons/org.inkscape.path.bridge_subpaths.svg new file mode 100644 index 0000000000000000000000000000000000000000..17f2a5a1383b50e6c1592cda01c3604f8081f86c --- /dev/null +++ b/icons/org.inkscape.path.bridge_subpaths.svg @@ -0,0 +1,58 @@ + + + + + diff --git a/tests/data/refs/bridge_subpaths__--sort_subpaths__False__--delete_orig__True__--id__path4-7.out b/tests/data/refs/bridge_subpaths__--sort_subpaths__False__--delete_orig__True__--id__path4-7.out new file mode 100644 index 0000000000000000000000000000000000000000..6854851fa55916716c8a0a13c73c6b80b7f32f42 --- /dev/null +++ b/tests/data/refs/bridge_subpaths__--sort_subpaths__False__--delete_orig__True__--id__path4-7.out @@ -0,0 +1,12 @@ + + + + + + + image/svg+xml + + + + + \ No newline at end of file diff --git a/tests/data/refs/bridge_subpaths__--sort_subpaths__True__--delete_orig__False__--id__path4-7.out b/tests/data/refs/bridge_subpaths__--sort_subpaths__True__--delete_orig__False__--id__path4-7.out new file mode 100644 index 0000000000000000000000000000000000000000..efa7bea100434f3e3f9c6d28d0ea1182ef161009 --- /dev/null +++ b/tests/data/refs/bridge_subpaths__--sort_subpaths__True__--delete_orig__False__--id__path4-7.out @@ -0,0 +1,13 @@ + + + + + + + image/svg+xml + + + + + + \ No newline at end of file diff --git a/tests/data/svg/bridge_subpaths.svg b/tests/data/svg/bridge_subpaths.svg new file mode 100644 index 0000000000000000000000000000000000000000..f58d70c68eca641dfef04aaae6da79aeb4605336 --- /dev/null +++ b/tests/data/svg/bridge_subpaths.svg @@ -0,0 +1,56 @@ + + + + + + + + + + image/svg+xml + + + + + + diff --git a/tests/test_bridge_subpaths.py b/tests/test_bridge_subpaths.py new file mode 100644 index 0000000000000000000000000000000000000000..2e865874c7eb0a8ae2da4d1a34de6724336b9c6c --- /dev/null +++ b/tests/test_bridge_subpaths.py @@ -0,0 +1,16 @@ +from patternalongpath import PatternAlongPath +from inkex.tester import ComparisonMixin, TestCase +from inkex.tester.filters import CompareNumericFuzzy, CompareWithPathSpace + +from bridge_subpaths import BridgeSubpaths +from inkex.tester import ComparisonMixin, TestCase + + +class TestBridgeSubpaths(ComparisonMixin, TestCase): + effect_class = BridgeSubpaths + compare_file = "svg/bridge_subpaths.svg" + + comparisons = [ + ("--sort_subpaths=True", "--delete_orig=False", "--id=path4-7"), + ("--sort_subpaths=False", "--delete_orig=True", "--id=path4-7"), + ]