- Introduced `frames.py` to manage field complex calculations and their arrangement within node groups. - Added functionality to create and remove field frames based on node arrangements. - Developed `passages.py` to find unobstructed paths for connections around calculation frames. - Implemented `references.py` to route shared results on peripheral tracks, ensuring efficient distribution of outputs. - Created `sections.py` to reserve space for field calculations adjacent to geometry consumers. - Added `sources.py` to position shared calculation sources effectively beside their distribution channels. - Enhanced outline smoothing with `outline_smoothing.py` to adapt camera depth along original outlines.
682 lines
34 KiB
Python
682 lines
34 KiB
Python
"""Arrange geometry paths, local fields, and shared references after construction."""
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
from graphlib import TopologicalSorter
|
|
|
|
import numpy as np
|
|
from scipy.optimize import LinearConstraint, minimize
|
|
|
|
from .dimensions import estimate_node_dimensions, visible_socket
|
|
from .flow import graph, geometry_paths, origin_socket, peer_links, shared_geometry_outputs, socket_links
|
|
from .connections import curve_footprint, link_endpoints
|
|
from .fields import field_layout
|
|
from .frames import create_field_frames, remove_field_frames
|
|
from .references import route_references
|
|
from .passages import route_field_passages
|
|
from .sections import clear_repeat_regions, enclose_repeat_branches, reserve_field_sections
|
|
from .sources import place_shared_sources
|
|
from .zones import node_zones
|
|
from ..common.nodes import replace_link_source
|
|
|
|
|
|
GEOMETRY_GAP = 100
|
|
FIELD_GAP = 40
|
|
BRANCH_GAP = 160
|
|
SECTION_GAP = 200
|
|
FIELD_HEIGHT = 720
|
|
ROUTE_GAP = 40
|
|
ROUTE_RUN = 120
|
|
|
|
|
|
def _remove_routes(node_group):
|
|
previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"]
|
|
|
|
for node in [node for node in node_group.nodes if node not in previous]:
|
|
for socket in node.inputs:
|
|
links = socket_links(socket)
|
|
if not any(link.from_node in previous for link in links):
|
|
continue
|
|
if not socket.is_multi_input:
|
|
replace_link_source(node_group, links[0], origin_socket(links[0].from_socket))
|
|
continue
|
|
sources = [origin_socket(link.from_socket) for link in links]
|
|
for link in links:
|
|
node_group.links.remove(link)
|
|
seen = set()
|
|
for source in reversed(sources):
|
|
original = source
|
|
if source in seen:
|
|
# Blender coalesces identical direct links; retain repeated inputs.
|
|
duplicate = node_group.nodes.new("NodeReroute")
|
|
node_group.links.new(source, duplicate.inputs[0])
|
|
source = duplicate.outputs[0]
|
|
seen.add(original)
|
|
node_group.links.new(source, socket)
|
|
for node in previous:
|
|
node_group.nodes.remove(node)
|
|
|
|
|
|
def _peer_inputs(node, ancestors):
|
|
sources = list(dict.fromkeys(
|
|
link.from_node for link in peer_links(node)
|
|
if link.from_node.bl_idname != "NodeGroupInput"
|
|
))
|
|
upstream = set().union(*(ancestors[source] for source in sources))
|
|
return [source for source in sources if source not in upstream]
|
|
|
|
|
|
def _localize_peer_inputs(node_group, ancestors, children):
|
|
"""Give shared inputs local ports without moving their public source."""
|
|
for node in list(node_group.nodes):
|
|
links = peer_links(node)
|
|
if len(links) < 2:
|
|
continue
|
|
sources = [link.from_node for link in links]
|
|
upstream = set().union(*(ancestors[source] for source in sources))
|
|
for link in links:
|
|
source = link.from_node
|
|
if source.bl_idname == "NodeReroute":
|
|
source["arrangement_input"] = True
|
|
continue
|
|
# Zone exits keep their own lane when a later peer merge is placed.
|
|
if source.bl_idname not in {"NodeGroupInput", "GeometryNodeRepeatOutput"} and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream:
|
|
continue
|
|
port = node_group.nodes.new("NodeReroute")
|
|
port["arrangement_input"] = True
|
|
node_group.links.new(link.from_socket, port.inputs[0])
|
|
replace_link_source(node_group, link, port.outputs[0])
|
|
|
|
|
|
def _columns(nodes, parents, peers, sizes, gap):
|
|
"""Allocate columns with equal right edges for independent peer inputs."""
|
|
roots = {node: node for node in nodes}
|
|
|
|
def root(node):
|
|
while roots[node] != node:
|
|
node = roots[node]
|
|
return node
|
|
|
|
for sources in peers:
|
|
members = [source for source in sources if source in roots]
|
|
for source in members[1:]:
|
|
roots[root(source)] = root(members[0])
|
|
dependencies = {root(node): [] for node in nodes}
|
|
for node in nodes:
|
|
for parent in parents[node]:
|
|
if parent in roots and root(parent) not in dependencies[root(node)]:
|
|
dependencies[root(node)].append(root(parent))
|
|
levels = {}
|
|
for component in TopologicalSorter(dependencies).static_order():
|
|
levels[component] = max((levels[p] + 1 for p in dependencies[component]), default=0)
|
|
columns = defaultdict(list)
|
|
for node in nodes:
|
|
columns[levels[root(node)]].append(node)
|
|
right, positions = 0, {}
|
|
for level in sorted(columns):
|
|
right += max(sizes[node].width for node in columns[level])
|
|
for node in columns[level]:
|
|
positions[node] = right - sizes[node].width
|
|
right += gap
|
|
return positions, columns
|
|
|
|
|
|
def _field_regions(nodes, core, children, order):
|
|
"""Group connected calculations by the geometry inputs that consume them."""
|
|
targets = {}
|
|
fields = set(nodes) - core
|
|
for node in reversed(order):
|
|
targets[node] = frozenset(link.to_socket for socket in node.outputs for link in socket.links
|
|
if link.to_node in core) | frozenset().union(
|
|
*(targets[child] for child in children[node] if child not in core))
|
|
for node in order:
|
|
if node.get("arrangement_exit"):
|
|
targets[node] = targets[origin_socket(node.outputs[0]).node]
|
|
regions = defaultdict(list)
|
|
for node in order:
|
|
if node in fields:
|
|
regions[targets[node]].append(node)
|
|
result = []
|
|
for sockets, region in regions.items():
|
|
remaining = set(region)
|
|
neighbors = {node: set(children[node]) & remaining for node in region}
|
|
for node, adjacent in list(neighbors.items()):
|
|
for other in list(adjacent):
|
|
neighbors[other].add(node)
|
|
for node in region:
|
|
if node not in remaining:
|
|
continue
|
|
component, pending = set(), [node]
|
|
while pending:
|
|
current = pending.pop()
|
|
if current not in remaining:
|
|
continue
|
|
remaining.remove(current)
|
|
component.add(current)
|
|
pending.extend(neighbors[current])
|
|
result.append(([member for member in region if member in component], {socket.node for socket in sockets}))
|
|
return result
|
|
|
|
|
|
def _localize_module_outputs(node_group, core, children):
|
|
"""Expose reused results at the boundary of their calculation module."""
|
|
shared = set(shared_geometry_outputs(node_group.nodes))
|
|
for link in list(node_group.links):
|
|
source = link.from_node
|
|
if link.to_node not in core or source in core or source.bl_idname == "NodeGroupInput":
|
|
continue
|
|
if link.from_socket in shared or not any(node not in core for node in children[source]):
|
|
continue
|
|
port = node_group.nodes.new("NodeReroute")
|
|
port["arrangement_exit"] = True
|
|
node_group.links.new(link.from_socket, port.inputs[0])
|
|
replace_link_source(node_group, link, port.outputs[0])
|
|
|
|
|
|
def _localize_inputs(node_group):
|
|
"""Give each operation local interface reads, separating geometry from values."""
|
|
controls = [node for node in node_group.nodes if node.bl_idname == "NodeGroupInput"]
|
|
uses = defaultdict(list)
|
|
for link in node_group.links:
|
|
if link.from_node in controls:
|
|
uses[link.to_node, link.from_socket.type == "GEOMETRY"].append((link, link.from_socket.identifier))
|
|
for inputs in uses.values():
|
|
local = node_group.nodes.new("NodeGroupInput")
|
|
for link, identifier in inputs:
|
|
source = next(socket for socket in local.outputs if socket.identifier == identifier)
|
|
replace_link_source(node_group, link, source)
|
|
for node in controls:
|
|
node_group.nodes.remove(node)
|
|
|
|
|
|
def _presentation(node_group):
|
|
for node in node_group.nodes:
|
|
node.select = False
|
|
for socket in node.outputs:
|
|
socket.hide = not socket.is_linked
|
|
if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}:
|
|
node.hide = all(socket.is_linked for socket in node.inputs if visible_socket(socket))
|
|
|
|
|
|
def _place_geometry_paths(paths, geometry_x, sizes, order):
|
|
"""Reuse fixed horizontal lanes; field sizes never participate in lane choice."""
|
|
paths = [list(path) for path in paths]
|
|
for path in list(paths):
|
|
for node in path:
|
|
paired = getattr(node, "paired_output", None)
|
|
if paired is None or paired not in geometry_x:
|
|
continue
|
|
first = next(nodes for nodes in paths if node in nodes)
|
|
last = next(nodes for nodes in paths if paired in nodes)
|
|
if first is not last:
|
|
first.extend(last)
|
|
paths.remove(last)
|
|
pitch = max(sizes[node].height for path in paths for node in path) + SECTION_GAP
|
|
path_of = {node: index for index, path in enumerate(paths) for node in path}
|
|
preceding = {index: set() for index in range(len(paths))}
|
|
for node in order:
|
|
inputs = list(dict.fromkeys(path_of[link.from_node] for link in peer_links(node)
|
|
if link.from_node in path_of))
|
|
for first, second in zip(inputs, inputs[1:]):
|
|
pending, seen = [first], set()
|
|
while pending:
|
|
current = pending.pop()
|
|
if current not in seen:
|
|
seen.add(current)
|
|
pending.extend(preceding[current])
|
|
if second not in seen:
|
|
preceding[second].add(first)
|
|
lanes, positions, rows = defaultdict(list), {}, {}
|
|
pending = set(preceding)
|
|
while pending:
|
|
ready = [index for index in pending if not preceding[index] & pending]
|
|
index = min(ready, key=lambda index: (-len(paths[index]), geometry_x[paths[index][0]], index))
|
|
path = paths[index]
|
|
left = min(geometry_x[node] for node in path)
|
|
right = max(geometry_x[node] + sizes[node].width for node in path)
|
|
minimum = max((rows[previous] + 1 for previous in preceding[index]), default=-len(paths))
|
|
for lane in sorted(range(minimum, len(paths) * 2), key=lambda value: (abs(value), value)):
|
|
if all(right + FIELD_GAP <= a or left >= b + FIELD_GAP for a, b in lanes[lane]):
|
|
break
|
|
lanes[lane].append((left, right))
|
|
rows[index] = lane
|
|
pending.remove(index)
|
|
for node in path:
|
|
positions[node] = (geometry_x[node], -lane * pitch)
|
|
# Solve the lane heights together so successive merges cannot create a staircase.
|
|
equalities, centers, inequalities, separations = [], [], [], []
|
|
for node in order:
|
|
sources = [link.from_socket for link in peer_links(node)]
|
|
if node not in path_of or len(sources) < 2 or any(socket.node not in path_of for socket in sources):
|
|
continue
|
|
output = next(socket for socket in node.outputs if socket in sizes[node].sockets)
|
|
first, last = sources[0], sources[-1]
|
|
row = np.zeros(len(paths))
|
|
row[path_of[node]] += 2
|
|
row[path_of[first.node]] -= 1
|
|
row[path_of[last.node]] -= 1
|
|
equalities.append(row)
|
|
centers.append(2 * sizes[node].sockets[output] - sizes[first.node].sockets[first] - sizes[last.node].sockets[last])
|
|
spans = [(min(geometry_x[node] for node in path), max(geometry_x[node] + sizes[node].width for node in path))
|
|
for path in paths]
|
|
local_ports = {index for index, path in enumerate(paths)
|
|
if all(node.get("arrangement_input") for node in path)}
|
|
pairs = {(first, second) for second, sources in preceding.items() for first in sources}
|
|
for first in range(len(paths)):
|
|
for second in range(first + 1, len(paths)):
|
|
if first in local_ports or second in local_ports:
|
|
continue
|
|
if spans[first][0] < spans[second][1] + FIELD_GAP and spans[first][1] + FIELD_GAP > spans[second][0]:
|
|
pairs.add((first, second) if rows[first] < rows[second] else (second, first))
|
|
for first, second in sorted(pairs):
|
|
row = np.zeros(len(paths))
|
|
row[first], row[second] = 1, -1
|
|
inequalities.append(row)
|
|
gap = FIELD_GAP if first in local_ports or second in local_ports else SECTION_GAP
|
|
separations.append(max(sizes[node].height for node in paths[first]) + gap)
|
|
weights = np.array([max(1, len(path)) ** 2 for path in paths], dtype=float)
|
|
weights /= weights.max()
|
|
constraints = []
|
|
if equalities:
|
|
constraints.append(LinearConstraint(np.array(equalities), np.array(centers) / pitch, np.array(centers) / pitch))
|
|
if inequalities:
|
|
constraints.append(LinearConstraint(np.array(inequalities), np.array(separations) / pitch, np.inf))
|
|
compact = []
|
|
for node in order:
|
|
if node not in path_of:
|
|
continue
|
|
for link in peer_links(node):
|
|
if link.from_node in path_of and path_of[link.from_node] in local_ports:
|
|
row = np.zeros(len(paths))
|
|
row[path_of[link.from_node]], row[path_of[node]] = 1, -1
|
|
compact.append(row)
|
|
metric = np.diag(weights)
|
|
if compact:
|
|
matrix = np.array(compact)
|
|
metric += matrix.T @ matrix * 4
|
|
solution = minimize(lambda values: values @ metric @ values,
|
|
np.array([-rows[index] for index in range(len(paths))], dtype=float),
|
|
jac=lambda values: 2 * metric @ values, constraints=constraints,
|
|
method="SLSQP", options={"ftol": 1e-12, "maxiter": 300})
|
|
if not solution.success:
|
|
raise ValueError(f"Geometry lane constraints could not be placed: {solution.message}")
|
|
for index, path in enumerate(paths):
|
|
for node in path:
|
|
positions[node] = (positions[node][0], float(solution.x[index] * pitch))
|
|
for node in order:
|
|
if node not in positions:
|
|
continue
|
|
links = [link for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links
|
|
if link.from_node in positions]
|
|
if not links:
|
|
continue
|
|
x = positions[node][0]
|
|
required = []
|
|
for link in links:
|
|
source = link.from_socket
|
|
source_y = positions[source.node][1] - sizes[source.node].sockets[source]
|
|
target_y = positions[node][1] - sizes[node].sockets[link.to_socket]
|
|
required.append(positions[source.node][0] + sizes[source.node].width
|
|
+ max(GEOMETRY_GAP, abs(target_y - source_y) * 0.8))
|
|
extra = max(0, max(required) - x)
|
|
if extra:
|
|
for member, (left, y) in positions.items():
|
|
if left >= x:
|
|
positions[member] = (left + extra, y)
|
|
return positions
|
|
|
|
|
|
def _geometry_corridors(paths, positions, sizes):
|
|
"""Reserve continuous breathing room along every horizontal geometry path."""
|
|
corridors = []
|
|
for path in paths:
|
|
if all(node.get("arrangement_input") for node in path):
|
|
continue
|
|
boxes = [_node_box(node, positions, sizes) for node in path]
|
|
corridors.append((min(box[0] for box in boxes) - GEOMETRY_GAP / 2,
|
|
max(box[1] for box in boxes) + GEOMETRY_GAP / 2,
|
|
max(box[2] for box in boxes), min(box[3] for box in boxes)))
|
|
for port in positions:
|
|
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
|
|
continue
|
|
source = origin_socket(port.outputs[0]).node
|
|
if source not in positions:
|
|
continue
|
|
left = positions[source][0] + sizes[source].width
|
|
right, level = positions[port][0] + 8, positions[port][1] - 8
|
|
if right > left:
|
|
corridors.append((left, right, level + ROUTE_GAP, level - ROUTE_GAP))
|
|
return corridors
|
|
|
|
|
|
def _node_box(node, positions, sizes):
|
|
x, y = positions[node]
|
|
return x, x + sizes[node].width, y, y - sizes[node].height
|
|
|
|
|
|
def _overlap(first, second, gap):
|
|
a, b, top, bottom = first
|
|
c, d, upper, lower = second
|
|
return a < d + gap and b > c - gap and top > lower - gap and bottom < upper + gap
|
|
|
|
|
|
@dataclass
|
|
class BranchSpace:
|
|
"""Keep module bodies and their exit channels in one placement reservation."""
|
|
|
|
bodies: list = field(default_factory=list)
|
|
exits: list = field(default_factory=list)
|
|
routes: list = field(default_factory=list)
|
|
|
|
def connections(self, outlets, placed, sizes):
|
|
exits, routes = [], []
|
|
for links in outlets:
|
|
for link in links:
|
|
start, end = link_endpoints(link, placed, sizes)
|
|
corners = []
|
|
run = max(ROUTE_RUN, abs(end[1] - start[1]) * 0.8)
|
|
if end[0] - start[0] > max(ROUTE_RUN * 4, run + ROUTE_RUN * 2):
|
|
corners = [(start[0] + ROUTE_RUN, start[1]),
|
|
(max(start[0] + ROUTE_RUN, end[0] - run), start[1])]
|
|
points = [start, *dict.fromkeys(corners), end]
|
|
boxes = [box for a, b in zip(points, points[1:]) for box in curve_footprint(a, b)]
|
|
exits.append((link, boxes))
|
|
routes.append((link, list(dict.fromkeys(corners))))
|
|
return exits, routes
|
|
|
|
def fits(self, body, exits):
|
|
if any(_overlap(body, box, ROUTE_GAP) for _, boxes in self.exits for box in boxes):
|
|
return False
|
|
for link, boxes in exits:
|
|
if any(_overlap(box, rectangle, ROUTE_GAP) for nodes, rectangle in self.bodies
|
|
if link.to_node not in nodes for box in boxes):
|
|
return False
|
|
return True
|
|
|
|
def crossings(self, exits):
|
|
return sum(any(_overlap(box, previous, 8) for box in boxes for previous in channel)
|
|
for link, boxes in exits for other, channel in self.exits
|
|
if link.to_node != other.to_node and link.from_socket != other.from_socket)
|
|
|
|
|
|
def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space, *, allow_opposite=False, peripheral=False, repeat_consumer=False):
|
|
"""Place an upstream module together with its forward-facing exit channels."""
|
|
right = min(positions[node][0] for node in destinations) - FIELD_GAP
|
|
baseline = sum(positions[node][1] for node in owners) / len(owners)
|
|
output = next(node for node in core if node.bl_idname == "NodeGroupOutput" and node.is_active_output)
|
|
ceiling = max(positions[node][1] for node in owners)
|
|
floor = min(positions[node][1] - sizes[node].height for node in owners)
|
|
axis_top = positions[output][1]
|
|
axis_bottom = axis_top - sizes[output].height
|
|
core_left = min(positions[node][0] for node in core)
|
|
side = 1 if floor > axis_top else -1 if ceiling < axis_bottom else 0
|
|
if allow_opposite:
|
|
side = 0
|
|
desired = right - width
|
|
front_allowed = all(link.from_node in local
|
|
for node in local for socket in node.inputs for link in socket.links)
|
|
outlets, constraints = [], []
|
|
for node, (x, y) in local.items():
|
|
for socket in node.outputs:
|
|
links = list(socket.links)
|
|
if socket.type != "GEOMETRY" and not peripheral:
|
|
returns = [link for link in links if link.to_node in core]
|
|
if returns:
|
|
outlets.append(returns)
|
|
for link in links:
|
|
if link.to_node in positions and link.to_node not in local:
|
|
target_x, target_y = positions[link.to_node]
|
|
constraints.append((target_x - x - sizes[node].width,
|
|
y - sizes[node].sockets[socket],
|
|
target_y - sizes[link.to_node].sockets[link.to_socket]))
|
|
constraints.sort()
|
|
exit_slope = 0.2 if width < 1000 and not peripheral and len(destinations) == 1 and destinations[0] in core and all(
|
|
socket.type != "GEOMETRY" for node in local for socket in node.outputs
|
|
if any(link.to_node in positions and link.to_node not in local for link in socket.links)
|
|
) else 0.8
|
|
if repeat_consumer and not peripheral:
|
|
exit_slope = 0.4
|
|
candidates = {desired, *(left - BRANCH_GAP - width for left, _, _, _ in occupied),
|
|
*(end + BRANCH_GAP for _, end, _, _ in occupied)}
|
|
best, seen = None, set()
|
|
for left in sorted(candidates, key=lambda x: (abs(x - desired), x)):
|
|
tops = {ceiling + BRANCH_GAP + height, floor - BRANCH_GAP,
|
|
limits[0], limits[1] + height,
|
|
*(top + BRANCH_GAP + height for _, _, top, _ in occupied),
|
|
*(bottom - BRANCH_GAP for _, _, _, bottom in occupied)}
|
|
tops.update(top + BRANCH_GAP + height for _, boxes in space.exits for _, _, top, _ in boxes)
|
|
tops.update(bottom - BRANCH_GAP for _, boxes in space.exits for _, _, _, bottom in boxes)
|
|
if front_allowed:
|
|
tops.add(baseline + height / 2)
|
|
for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)):
|
|
if top > limits[0] or top - height < limits[1]:
|
|
continue
|
|
placed_left = min(left, desired)
|
|
placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(source_y + top - target_y) * exit_slope)
|
|
for end, source_y, target_y in constraints)])
|
|
# Leave clearance at saturated link constraints for solver roundoff.
|
|
placed_left -= 0.01
|
|
in_front = placed_left + width + BRANCH_GAP <= core_left
|
|
if not (in_front and front_allowed) and ((side == 1 and top - height < ceiling + BRANCH_GAP - 1e-5)
|
|
or (side == -1 and top > floor - BRANCH_GAP + 1e-5)):
|
|
continue
|
|
point = (placed_left, top)
|
|
if point in seen:
|
|
continue
|
|
seen.add(point)
|
|
distance = abs(placed_left - desired)
|
|
base_score = (sum(end - placed_left + abs(source_y + top - target_y) * 1.5
|
|
for end, source_y, target_y in constraints) / len(constraints)
|
|
if constraints else distance + abs(top - height / 2 - baseline) * 1.5)
|
|
if best is not None and round(base_score, 6) > best[0]:
|
|
continue
|
|
overlapping = [box for box in occupied
|
|
if placed_left < box[1] + BRANCH_GAP and placed_left + width > box[0] - BRANCH_GAP]
|
|
if any(top > bottom - BRANCH_GAP + 1e-5 and top - height < upper + BRANCH_GAP - 1e-5
|
|
for _, _, upper, bottom in overlapping):
|
|
continue
|
|
placed = positions | {node: (x + placed_left, y + top) for node, (x, y) in local.items()}
|
|
exits, routes = space.connections(outlets, placed, sizes)
|
|
body = (placed_left, placed_left + width, top, top - height)
|
|
if not space.fits(body, exits):
|
|
continue
|
|
score = base_score + space.crossings(exits) * BRANCH_GAP * 4
|
|
candidate = (round(score, 6), round(distance, 6), -top, placed_left, top)
|
|
if best is None or candidate < best:
|
|
best = candidate
|
|
best_exits, best_routes = exits, routes
|
|
if best is None:
|
|
if not allow_opposite:
|
|
return _place_field_region(
|
|
local, width, height, destinations, owners, positions, sizes,
|
|
occupied, limits, core, space, allow_opposite=True, peripheral=peripheral,
|
|
repeat_consumer=repeat_consumer,
|
|
)
|
|
raise ValueError("The field region does not fit its reserved scope")
|
|
_, _, _, left, top = best
|
|
for node, (x, y) in local.items():
|
|
positions[node] = (x + left, y + top)
|
|
body = (left, left + width, top, top - height)
|
|
occupied.append(body)
|
|
space.bodies.append((set(local), body))
|
|
space.exits.extend(best_exits)
|
|
space.routes.extend(best_routes)
|
|
|
|
|
|
def _route_field_links(node_group, layouts, positions):
|
|
"""Materialize the passages reserved between expression columns."""
|
|
for layout in layouts:
|
|
anchor = next(iter(layout.positions))
|
|
dx = positions[anchor][0] - layout.positions[anchor][0]
|
|
dy = positions[anchor][1] - layout.positions[anchor][1]
|
|
for link, points in layout.routes:
|
|
source = link.from_socket
|
|
for x, y in points:
|
|
node = node_group.nodes.new("NodeReroute")
|
|
node["arrangement_branch"] = True
|
|
node.location = (x + dx - 8, y + dy + 8)
|
|
node_group.links.new(source, node.inputs[0])
|
|
source = node.outputs[0]
|
|
replace_link_source(node_group, link, source)
|
|
|
|
|
|
def _route_branch_exits(node_group, routes):
|
|
"""Materialize the channels reserved during placement, sharing trunk corners."""
|
|
deliveries = defaultdict(list)
|
|
for link, points in routes:
|
|
deliveries[link.from_socket].append((link, points))
|
|
for source, branches in deliveries.items():
|
|
corners = {}
|
|
for point in sorted({point for _, points in branches for point in points}):
|
|
node = node_group.nodes.new("NodeReroute")
|
|
node["arrangement_branch"] = True
|
|
node.location = (point[0] - 8, point[1] + 8)
|
|
node_group.links.new(source, node.inputs[0])
|
|
source = corners[point] = node.outputs[0]
|
|
for link, points in branches:
|
|
if points:
|
|
replace_link_source(node_group, link, corners[points[-1]])
|
|
|
|
|
|
def _route_peer_bypasses(node_group, sizes, positions, ancestors):
|
|
"""Use an entry and an exit corner for a branch that bypasses processing."""
|
|
for port in list(node_group.nodes):
|
|
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
|
|
continue
|
|
source = origin_socket(port.outputs[0])
|
|
junction = port.outputs[0].links[0].to_node
|
|
processed = [origin_socket(link.from_socket).node for link in peer_links(junction)
|
|
if source.node in ancestors[origin_socket(link.from_socket).node]]
|
|
if not processed:
|
|
continue
|
|
left = positions[source.node][0] + sizes[source.node].width
|
|
right, level = positions[port][0] + 8, positions[port][1] - 8
|
|
if right - left < ROUTE_GAP * 2:
|
|
continue
|
|
branch_nodes = {node for end in processed for node in ancestors[end] | {end}
|
|
if source.node in ancestors[node] and any(socket.type == "GEOMETRY" for socket in node.outputs)
|
|
and node.bl_idname != "NodeReroute"}
|
|
first = min(positions[node][0] for node in branch_nodes)
|
|
run = max(ROUTE_RUN, abs(level - positions[source.node][1] + sizes[source.node].sockets[source]) * 0.8)
|
|
x = min(right - ROUTE_GAP, max(left + min(run, (right - left) / 2), first - ROUTE_GAP))
|
|
entry = node_group.nodes.new("NodeReroute")
|
|
entry["arrangement_bypass"] = True
|
|
node_group.links.new(source, entry.inputs[0])
|
|
replace_link_source(node_group, port.inputs[0].links[0], entry.outputs[0])
|
|
positions[entry] = (x - 8, level + 8)
|
|
sizes[entry] = estimate_node_dimensions(entry)
|
|
|
|
|
|
def arrange_nodes(node_group):
|
|
"""Fix geometry lanes, pack shallow field regions, and route shared references."""
|
|
remove_field_frames(node_group)
|
|
_remove_routes(node_group)
|
|
parents, children, order, ancestors = graph(node_group)
|
|
_localize_peer_inputs(node_group, ancestors, children)
|
|
parents, children, order, ancestors = graph(node_group)
|
|
geometry_parents, paths = geometry_paths(node_group)
|
|
core = set(geometry_parents)
|
|
_localize_module_outputs(node_group, core, children)
|
|
_localize_inputs(node_group)
|
|
_presentation(node_group)
|
|
parents, children, order, ancestors = graph(node_group)
|
|
sizes = {node: estimate_node_dimensions(node) for node in order}
|
|
for node, size in sizes.items():
|
|
node.width = size.width
|
|
peers = [_peer_inputs(node, ancestors) for node in order]
|
|
regions = _field_regions(node_group.nodes, core, children, order)
|
|
for region, _ in regions:
|
|
outlets = [link.from_node for node in order if node in core for socket in node.inputs
|
|
for link in socket_links(socket) if link.from_node in region]
|
|
peers.append(list(dict.fromkeys(outlets)))
|
|
# Preserve execution order through shared geometry and field calculations.
|
|
preceding = {}
|
|
column_parents = {}
|
|
for node in order:
|
|
sources = set().union(*(preceding[parent] for parent in parents[node]))
|
|
if node in core:
|
|
column_parents[node] = sources
|
|
preceding[node] = {node} if node in core else sources
|
|
geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP)
|
|
positions = _place_geometry_paths(paths, geometry_x, sizes, order)
|
|
space = BranchSpace()
|
|
layouts, field_layouts = [], []
|
|
for region, targets in regions:
|
|
layout = field_layout(region, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
|
|
field_layouts.append(layout)
|
|
layouts.append((region, targets, layout.positions, layout.width, layout.height))
|
|
reserve_field_sections(node_group, layouts, positions, sizes, gap=BRANCH_GAP)
|
|
occupied = _geometry_corridors(paths, positions, sizes)
|
|
reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts)]) * 2 + BRANCH_GAP * 2
|
|
limits = (max(y for _, y in positions.values()) + reserve,
|
|
min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve)
|
|
pending = list(layouts)
|
|
shared_nodes = {socket.node for socket in shared_geometry_outputs(node_group.nodes)}
|
|
peripheral_regions = set()
|
|
for region, _, _, _, _ in layouts:
|
|
uses = defaultdict(set)
|
|
for node in region:
|
|
for socket in node.outputs:
|
|
for link in socket.links:
|
|
if link.to_node not in region:
|
|
uses[origin_socket(socket)].add(link.to_node)
|
|
if any(len(consumers) > 1 and (socket.type != "GEOMETRY" or not consumers & core)
|
|
for socket, consumers in uses.items()):
|
|
peripheral_regions.add(region[0])
|
|
while True:
|
|
peripheral_nodes = {node for region, _, _, _, _ in layouts if region[0] in peripheral_regions for node in region}
|
|
upstream = {region[0] for region, _, _, _, _ in layouts
|
|
if region[0] not in peripheral_regions
|
|
and (consumers := {link.to_node for node in region for socket in node.outputs
|
|
for link in socket.links if link.to_node not in region})
|
|
and consumers <= peripheral_nodes}
|
|
if not upstream:
|
|
break
|
|
peripheral_regions.update(upstream)
|
|
def delivery_order(layout):
|
|
ports = [link.to_socket for node in layout[0] for socket in node.outputs
|
|
for link in socket.links if link.to_node in core]
|
|
return min(((positions[socket.node][0], list(socket.node.inputs).index(socket))
|
|
for socket in ports), default=(float("inf"), 0))
|
|
|
|
repeat_consumers = {node for entry, end, members in node_zones(node_group)
|
|
if entry.bl_idname == "GeometryNodeRepeatInput"
|
|
for node in members & core if node not in {entry, end}}
|
|
while pending:
|
|
ready = [layout for layout in pending if all(
|
|
link.to_node in positions or link.to_node in layout[0]
|
|
for node in layout[0] for socket in node.outputs for link in socket.links)]
|
|
layout = min(ready, key=lambda item: (item[0][0] in peripheral_regions or any(node in shared_nodes for node in item[0]),
|
|
delivery_order(item), order.index(item[0][0])))
|
|
pending.remove(layout)
|
|
region, targets, local, width, height = layout
|
|
consumers = list(dict.fromkeys(link.to_node for node in region for socket in node.outputs
|
|
for link in socket.links if link.to_node not in region and link.to_node in positions))
|
|
destinations = consumers or [node for node in order if node in targets] or [paths[0][-1]]
|
|
owners = [node for node in order if node in targets] or [paths[0][-1]]
|
|
_place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space,
|
|
peripheral=region[0] in peripheral_regions,
|
|
repeat_consumer=any(node in repeat_consumers for node in destinations))
|
|
enclose_repeat_branches(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
|
|
clear_repeat_regions(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
|
|
repeat_members = set().union(*(members for entry, _, members in node_zones(node_group)
|
|
if entry.bl_idname == "GeometryNodeRepeatInput"))
|
|
peripheral_nodes.difference_update(repeat_members)
|
|
place_shared_sources(space.bodies, peripheral_nodes, positions, sizes, gap=BRANCH_GAP)
|
|
_, space.routes = space.connections([[link] for link, _ in space.routes], positions, sizes)
|
|
_route_field_links(node_group, field_layouts, positions)
|
|
_route_branch_exits(node_group, space.routes)
|
|
_route_peer_bypasses(node_group, sizes, positions, ancestors)
|
|
for node, position in positions.items():
|
|
node.location = position
|
|
route_references(node_group, space.bodies, peripheral_nodes=peripheral_nodes)
|
|
while unused := [node for node in node_group.nodes
|
|
if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]:
|
|
for members, _ in space.bodies:
|
|
members.difference_update(unused)
|
|
for node in unused:
|
|
node_group.nodes.remove(node)
|
|
route_field_passages(node_group, space.bodies)
|
|
create_field_frames(node_group, space.bodies, core)
|
|
_presentation(node_group)
|