"""Find curved passages around unrelated calculation frames.""" from heapq import heappop, heappush from math import hypot from ..common.nodes import replace_link_source from .connections import curve_footprint, link_endpoints from .dimensions import estimate_node_dimensions from .flow import origin_socket, peer_links from .frames import FRAME_HEADING, FRAME_MIN_NODES, FRAME_PADDING from .zones import node_zones, repeat_regions PASSAGE_GAP = 96 PASSAGE_SLOPE = 0.2 def _intersects(start, end, boxes): bend = max(40, abs(end[0] - start[0]) * 0.42) left, right = min(start[0], end[0] - bend), max(start[0] + bend, end[0]) bottom, top = sorted((start[1], end[1])) boxes = [box for box in boxes if box[0] < right and box[1] > left and box[3] < top and box[2] > bottom] if not boxes: return False return any(a < right and b > left and bottom < top and upper > lower for left, right, top, lower in curve_footprint(start, end) for a, b, upper, bottom in boxes) def _passage(start, end, boxes, tracks, *, monotonic=False, forward_start=False, bounds=None): """Find a short sequence of unobstructed, slanted Bezier segments.""" points = [start, end] for gap in (PASSAGE_GAP, PASSAGE_GAP * 2, PASSAGE_GAP * 4): points.extend((x, y) for left, right, top, bottom in boxes for x in (left - gap, right + gap) for y in (top + gap, bottom - gap)) points.extend((x, y) for left, right, top, bottom in boxes for x in (left - (top - bottom) - PASSAGE_GAP, right + (top - bottom) + PASSAGE_GAP) for y in (top + PASSAGE_GAP, bottom - PASSAGE_GAP)) points = list(dict.fromkeys(points)) if bounds is not None: points = points[:2] + [point for point in points[2:] if bounds[0] <= point[0] <= bounds[1]] direction = 1 if end[0] >= start[0] else -1 result = _search_passage(points, boxes, tracks, direction, forward_start) if result is not None or monotonic: return result result = _search_passage(points, boxes, tracks, 0, forward_start) if result is not None: return result raise ValueError("No unobstructed passage between calculation regions") def _search_passage(points, boxes, tracks, direction, forward_start): """Prefer a continuous horizontal direction whenever obstacles permit it.""" costs, previous, pending = {0: 0}, {}, [(0, 0)] while pending: cost, index = heappop(pending) if cost != costs[index]: continue if index == 1: path = [] while index: path.append(points[index]) index = previous[index] return list(reversed(path)) x, y = points[index] for target, (u, v) in enumerate(points): if target == index or abs(u - x) < max(40, abs(v - y) * PASSAGE_SLOPE): continue if direction and (u - x) * direction <= 0: continue if forward_start and index == 0 and u <= x: continue if abs(v - y) < 0.01: if u - x < PASSAGE_GAP: continue if any(a < u and b > x and abs(y - level) < PASSAGE_GAP for a, b, level in tracks): continue distance = cost + hypot(u - x, v - y) + max(0, x - u) + PASSAGE_GAP if distance >= costs.get(target, float("inf")) or _intersects((x, y), (u, v), boxes): continue costs[target], previous[target] = distance, index heappush(pending, (distance, target)) return None def route_field_passages(node_group, bodies): """Allow wires into their own calculations and route transit wires around frames.""" frames = [(members, (left - FRAME_PADDING, right + FRAME_PADDING, top + FRAME_HEADING, bottom - FRAME_PADDING)) for members, (left, right, top, bottom) in bodies if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES] positions = {node: tuple(node.location_absolute) for node in node_group.nodes} sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} for entry, end, members in node_zones(node_group): if entry.bl_idname != "GeometryNodeRepeatInput": continue for node in members: if node.bl_idname == "NodeReroute": x, y = positions[node] x = max(positions[entry][0] + sizes[entry].width + 40, min(x, positions[end][0] - 40 - sizes[node].width)) node.location = positions[node] = (x, y) repeats = repeat_regions(node_group, positions, sizes) frames.extend(repeats) for node in node_group.nodes: if node.bl_idname != "NodeReroute": continue x, y = positions[node] if not any(node not in members and left < x + 8 < right and bottom < y - 8 < top for members, (left, right, top, bottom) in repeats): continue blocked = [box for members, box in frames if node not in members and box[0] < x + 8 < box[1]] levels = {edge for _, _, top, bottom in blocked for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP)} level = min((level for level in levels if all(not bottom < level < top for _, _, top, bottom in blocked)), key=lambda level: abs(level - y)) node.location = positions[node] = (x, level + 8) for link in list(node_group.links): if link.from_socket.type != "GEOMETRY" or link.from_node.bl_idname == "NodeReroute": continue start, end = link_endpoints(link, positions, sizes) distance = start[0] - end[0] if any(link.from_node in members and link.to_node in members for members, _ in repeats): continue if distance <= 2000: continue # Bound the right-facing handle before a long return towards the left. run = distance * 0.08 + PASSAGE_GAP offset = max(PASSAGE_GAP * 2, run * PASSAGE_SLOPE) point = (start[0] - run, start[1] + (offset if end[1] > start[1] else -offset)) levels = {start[1] - offset, start[1] + offset, *(edge for _, (_, _, top, bottom) in frames for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP))} clear_levels = [level for level in levels if abs(level - start[1]) >= PASSAGE_GAP and abs(level - end[1]) >= PASSAGE_GAP and all(not (left < point[0] < right and bottom < level < top) for _, (left, right, top, bottom) in frames)] point = (point[0], min(clear_levels, key=lambda level: (abs(level - point[1]), -level))) corner = node_group.nodes.new("NodeReroute") corner["arrangement_reference"] = True corner["arrangement_return"] = True corner.location = (point[0] - 8, point[1] + 8) positions[corner] = tuple(corner.location_absolute) sizes[corner] = estimate_node_dimensions(corner) node_group.links.new(link.from_socket, corner.inputs[0]) replace_link_source(node_group, link, corner.outputs[0]) tracks = [] for link in node_group.links: start, end = link_endpoints(link, positions, sizes) if abs(start[1] - end[1]) < 0.01: tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1])) for link in list(node_group.links): consumers, pending = set(), [link.to_node] while pending: node = pending.pop() if node in consumers: continue consumers.add(node) if node.bl_idname == "NodeReroute": pending.extend(edge.to_node for edge in node.outputs[0].links) source = origin_socket(link.from_socket).node bounds = next(((box[0] + 60, box[1] - 60) for members, box in repeats if source in members and consumers & members), None) obstacles = [box for members, box in frames if source not in members and not consumers & members] if (link.from_socket.type != "GEOMETRY" and not link.from_node.get("arrangement_reference") and not link.to_node.get("arrangement_reference")): obstacles = [box for members, box in repeats if source not in members and not consumers & members] start, end = link_endpoints(link, positions, sizes) if not _intersects(start, end, obstacles): continue try: path = _passage(start, end, obstacles, tracks, bounds=bounds, forward_start=link.from_socket.type != "GEOMETRY" and link.from_node.bl_idname != "NodeReroute") except ValueError as error: raise ValueError(f"Cannot route {source.name} to {link.to_node.name}: {start} -> {end}") from error output, previous = link.from_socket, start for point in path[:-1]: corner = node_group.nodes.new("NodeReroute") corner["arrangement_reference"] = True corner.location = (point[0] - 8, point[1] + 8) node_group.links.new(output, corner.inputs[0]) output = corner.outputs[0] if abs(previous[1] - point[1]) < 0.01: tracks.append((previous[0], point[0], point[1])) previous = point replace_link_source(node_group, link, output) _simplify_passages(node_group, frames, bodies) def _simplify_passages(node_group, frames, bodies): """Shortcut redundant turns without removing shared junctions or crossing frames.""" positions = {node: tuple(node.location_absolute) for node in node_group.nodes} sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} internal = set().union(*(members for _, _, members in node_zones(node_group))) while True: removed = False for corner in list(node_group.nodes): if corner.get("arrangement_return") or corner in internal: continue if not corner.get("arrangement_reference") or len(corner.inputs[0].links) != 1: continue if len(corner.outputs[0].links) != 1: continue incoming = corner.inputs[0].links[0] start, _ = link_endpoints(incoming, positions, sizes) points = [start] chain, current = [], corner while current.get("arrangement_reference") and len(current.outputs[0].links) == 1: chain.append(current) outgoing = current.outputs[0].links[0] if outgoing in peer_links(outgoing.to_node): break middle, end = link_endpoints(outgoing, positions, sizes) points.append(middle) # A shortcut must remove an actual reversal, not a useful pipe approach. forward_entry = (incoming.from_socket.type != "GEOMETRY" and incoming.from_node.bl_idname != "NodeReroute") if (not forward_entry or end[0] > start[0]) and any( (b[0] - a[0]) * (end[0] - start[0]) < 0 for a, b in zip(points, points[1:] + [end])): dx, dy = end[0] - start[0], end[1] - start[1] consumers, pending = set(), [outgoing.to_node] while pending: node = pending.pop() if node in consumers: continue consumers.add(node) if node.bl_idname == "NodeReroute": pending.extend(edge.to_node for edge in node.outputs[0].links) source = origin_socket(incoming.from_socket).node obstacles = [box for members, box in frames if source not in members and not consumers & members] horizontal = abs(dy) < 0.01 blocked = abs(dx) < max(40, abs(dy) * PASSAGE_SLOPE) or (horizontal and dx < PASSAGE_GAP) if horizontal and not blocked: for edge in node_group.links: if edge.from_node in chain or edge.to_node in chain: continue a, b = link_endpoints(edge, positions, sizes) if abs(a[1] - b[1]) < 0.01 and abs(a[1] - start[1]) < PASSAGE_GAP: blocked |= min(a[0], b[0]) < end[0] and max(a[0], b[0]) > start[0] path = [end] if not blocked and not _intersects(start, end, obstacles) else None if path is None and abs(dx) >= max(40, abs(dy) * PASSAGE_SLOPE): tracks = [] for edge in node_group.links: if edge.from_node in chain or edge.to_node in chain: continue a, b = link_endpoints(edge, positions, sizes) if abs(a[1] - b[1]) < 0.01: tracks.append((min(a[0], b[0]), max(a[0], b[0]), a[1])) path = _passage(start, end, obstacles, tracks, monotonic=True) if path is not None: output = incoming.from_socket for point in path[:-1]: node = node_group.nodes.new("NodeReroute") node["arrangement_reference"] = True node.location = (point[0] - 8, point[1] + 8) positions[node] = tuple(node.location_absolute) sizes[node] = estimate_node_dimensions(node) node_group.links.new(output, node.inputs[0]) output = node.outputs[0] replace_link_source(node_group, outgoing, output) for members, _ in bodies: members.difference_update(chain) for node in reversed(chain): node_group.nodes.remove(node) removed = True break current = outgoing.to_node if removed: break if not removed: return