# Authors: see git history # # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. from itertools import combinations import inkex import networkx as nx from shapely.geometry import MultiPoint, Point from shapely.ops import nearest_points from ...elements import SatinColumn from ...svg import get_correction_transform from ...svg.tags import INKSCAPE_LABEL from ...utils.threading import check_stop_flag def find_path(graph, starting_node, ending_node): """Find a path through the graph that sews every edge.""" # This is done in two steps. First, we find the shortest path from the # start to the end. We remove it from the graph, and proceed to step 2. # # Then, we traverse the path node by node. At each node, we follow any # branchings with a depth-first search. We travel down each branch of # the tree, inserting each seen branch into the tree. When the DFS # hits a dead-end, as it back-tracks, we also add the seen edges _again_. # Repeat until there are no more edges left in the graph. # # Visiting the edges again on the way back allows us to set up # "underpathing". path = _get_shortest_path(graph, starting_node, ending_node) check_stop_flag() # Copy the graph so that we can remove the edges as we visit them. # This also converts the directed graph into an undirected graph in the # case that "preserve_order" is set. graph = nx.Graph(graph) graph.remove_edges_from(list(zip(path[:-1], path[1:]))) final_path = [] prev = None for node in path: check_stop_flag() if prev is not None: final_path.append((prev, node)) prev = node for n1, n2, edge_type in list(nx.dfs_labeled_edges(graph, node)): if n1 == n2: # dfs_labeled_edges gives us (start, start, "forward") for # the starting node for some reason continue if edge_type == "forward": final_path.append((n1, n2)) graph.remove_edge(n1, n2) elif edge_type == "reverse": final_path.append((n2, n1)) elif edge_type == "nontree": # a "nontree" happens when there exists an edge from n1 to n2 # but n2 has already been visited. It's a dead-end that runs # into part of the graph that we've already traversed. We # do still need to make sure that edge is sewn, so we travel # down and back on this edge. # # It's possible for a given "nontree" edge to be listed more # than once so we'll deduplicate. if (n1, n2) in graph.edges: final_path.append((n1, n2)) final_path.append((n2, n1)) graph.remove_edge(n1, n2) return final_path def _get_shortest_path(graph, starting_node, ending_node): try: path = nx.shortest_path(graph, starting_node, ending_node) except nx.exception.NetworkXNoPath: # They did something strange, let's add a jump from start to end # so at least we don't have to end with an error _insert_jump(graph, graph.nodes[starting_node]['point'], graph.nodes[ending_node]['point']) path = nx.shortest_path(graph, starting_node, ending_node) return path def add_jumps(graph, elements, preserve_order): """Add jump stitches between elements as necessary. Jump stitches are added to ensure that all elements can be reached. Only the minimal number and length of jumps necessary will be added. """ if preserve_order: _add_ordered_jumps(graph, elements) else: _add_unordered_jumps(graph, elements) return graph def _add_ordered_jumps(graph, elements): # For each sequential pair of elements, find the shortest possible jump # stitch between them and add it. The directions of these new edges # will enforce stitching the elements in order. last_element = None for element1, element2 in zip(elements[:-1], elements[1:]): # Try to fix missing links if last_element is not None: success = _insert_smallest_jump(graph, last_element, element2) if success: last_element = None check_stop_flag() success = _insert_smallest_jump(graph, element1, element2) if not success: last_element = element1 # add jumps between subpath too, we do not care about directions here for element in elements: if isinstance(element, SatinColumn): # don't try this for satin columns continue check_stop_flag() geoms = list(element.as_multi_line_string().geoms) i = 0 for line1 in geoms: for line2 in geoms[i+1:]: if line1.distance(line2) == 0: continue node1, node2 = nearest_points(line1, line2) _insert_jump(graph, node1, node2) i += 1 def _insert_smallest_jump(graph, element1, element2): potential_edges = [] nodes1 = get_nodes_on_element(graph, element1) nodes2 = get_nodes_on_element(graph, element2) for node1 in nodes1: for node2 in nodes2: point1 = graph.nodes[node1]['point'] point2 = graph.nodes[node2]['point'] potential_edges.append((point1, point2)) if potential_edges: edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1])) graph.add_edge(str(edge[0]), str(edge[1]), jump=True) return True else: # return False if we haven't successfully inserted a jump # so we can try again later return False def _insert_jump(graph, node1, node2): graph.add_node(str(node1), point=node1) graph.add_node(str(node2), point=node2) graph.add_edge(str(node1), str(node2), jump=True) graph.add_edge(str(node2), str(node1), jump=True) def _add_unordered_jumps(graph, elements): # networkx makes this super-easy! k_edge_agumentation tells us what edges # we need to add to ensure that the graph is fully connected. We give it a # set of possible edges that it can consider adding (avail). Each edge has # a weight, which we'll set as the length of the jump stitch. The # algorithm will minimize the total length of jump stitches added. for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): check_stop_flag() graph.add_edge(*jump, jump=True) def possible_jumps(graph): """All possible jump stitches in the graph with their lengths. Returns: a generator of tuples: (node1, node2, length) """ for component1, component2 in combinations(nx.connected_components(graph), 2): points1 = MultiPoint([graph.nodes[node]['point'] for node in component1]) points2 = MultiPoint([graph.nodes[node]['point'] for node in component2]) start_point, end_point = nearest_points(points1, points2) yield (str(start_point), str(end_point), start_point.distance(end_point)) def get_starting_and_ending_nodes(graph, elements, preserve_order, starting_point, ending_point): """Find or choose the starting and ending graph nodes. If points were passed, we'll find the nearest graph nodes. Since we split every path up into 1mm-chunks, we'll be at most 1mm away which is good enough. If we weren't given starting and ending points, we'll pic kthe far left and right nodes. returns: (starting graph node, ending graph node) """ nodes = [] nodes.append(find_node(graph, starting_point, min, preserve_order, elements[0])) nodes.append(find_node(graph, ending_point, max, preserve_order, elements[-1])) return nodes def find_node(graph, point, extreme_function, constrain_to_satin=False, satin=None): if constrain_to_satin: nodes = get_nodes_on_element(graph, satin) else: nodes = graph.nodes() if not nodes: return list(graph.nodes)[0] if point is None: return extreme_function(nodes, key=lambda node: graph.nodes[node]['point'].x) else: point = Point(*point) return min(nodes, key=lambda node: graph.nodes[node]['point'].distance(point)) def get_nodes_on_element(graph, element): nodes = set() for start_node, end_node, element_for_edge in graph.edges(data='element'): if element_for_edge is element: nodes.add(start_node) nodes.add(end_node) return nodes def remove_original_elements(elements, commands_only=False): for element in elements: for command in element.commands: command_group = command.use.getparent() if command_group is not None and command_group.get('id').startswith('command_group'): remove_from_parent(command_group) else: remove_from_parent(command.connector) remove_from_parent(command.use) if not commands_only: remove_from_parent(element.node) def remove_from_parent(node): if node.getparent() is not None: node.getparent().remove(node) def create_new_group(parent, insert_index, label, correction_transform=True): group = inkex.Group(attrib={ INKSCAPE_LABEL: label, }) if correction_transform: group.transform = get_correction_transform(parent, child=True) parent.insert(insert_index, group) return group def preserve_original_groups(elements, original_parent_nodes, transform=True): """Ensure that all elements are contained in the original SVG group elements. When preserve_order is True, no elements will be reordered in the XML tree. This makes it possible to preserve original SVG group membership. We'll ensure that each newly-created element is added to the group that contained the original element that spawned it. """ for element, parent in zip(elements, original_parent_nodes): if parent is None: continue element_transform = '' if transform: element_transform = get_correction_transform(parent, child=True) parent.append(element.node) element.node.set('transform', element_transform) element.node.apply_transform() def add_elements_to_group(elements, group): for element in elements: group.append(element.node) def apply_transform_recursivley(element, group_transform=inkex.Transform()): """Apply transform, helps to keep the stroke width""" if element.TAG == 'g': group_transform = group_transform @ element.transform element.pop('transform') for child in element: apply_transform_recursivley(child, group_transform) else: element.transform = group_transform @ element.transform element.apply_transform()