Refactor geometry node arrangement system
- Removed obsolete files: sections.py, semantics.json, and sources.py. - Introduced new modules: branches.py and layout.py for improved calculation block identification and layout planning. - Implemented a unified layout strategy for geometry nodes, ensuring consistent spacing and organization. - Added comprehensive documentation for the new layout design and its goals. - Established a migration plan for transitioning existing assets to the new layout system. - Updated tests to validate the new arrangement logic and ensure compatibility with existing geometry node structures.
This commit is contained in:
@@ -1,32 +1,10 @@
|
||||
"""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
|
||||
"""Materialize the joint calculation and connection plan in geometry node trees."""
|
||||
|
||||
from .branches import calculation_blocks
|
||||
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
|
||||
from .flow import origin_socket, socket_links
|
||||
from .layout import plan_layout
|
||||
from ..common.nodes import prepare_node_group, replace_link_source
|
||||
|
||||
|
||||
def _remove_routes(node_group):
|
||||
@@ -57,625 +35,96 @@ def _remove_routes(node_group):
|
||||
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:
|
||||
def _localize_inputs(node_group, nodes, blocks, core):
|
||||
"""Read interface values locally in each consuming calculation body."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
block_of = {i: k for k, block in enumerate(blocks) for i in block["members"]}
|
||||
for node in list(nodes):
|
||||
if node.bl_idname != "NodeGroupInput":
|
||||
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
|
||||
original = ("core", ids[node]) if ids[node] in core else ("block", block_of[ids[node]])
|
||||
uses = list(node_group.links)
|
||||
direct = [ids[edge.to_node] for edge in uses if edge.from_node == node and ids[edge.to_node] in core]
|
||||
if direct:
|
||||
original = ("core", direct[0])
|
||||
local_reads = {original: node}
|
||||
for edge in uses:
|
||||
if edge.from_node != node:
|
||||
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,
|
||||
target = ids[edge.to_node]
|
||||
owner = ("block", block_of[target]) if target in block_of else ("core", target)
|
||||
if owner not in local_reads:
|
||||
clone = node_group.nodes.new("NodeGroupInput")
|
||||
ids[clone] = len(nodes)
|
||||
nodes.append(clone)
|
||||
if owner[0] == "block":
|
||||
blocks[owner[1]]["members"].append(ids[clone])
|
||||
block_of[ids[clone]] = owner[1]
|
||||
else:
|
||||
core.add(ids[clone])
|
||||
local_reads[owner] = clone
|
||||
source = next(
|
||||
socket
|
||||
for socket in local_reads[owner].outputs
|
||||
if socket.identifier == edge.from_socket.identifier
|
||||
)
|
||||
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)
|
||||
if source != edge.from_socket:
|
||||
replace_link_source(node_group, edge, source)
|
||||
|
||||
|
||||
def arrange_nodes(node_group):
|
||||
"""Fix geometry lanes, pack shallow field regions, and route shared references."""
|
||||
remove_field_frames(node_group)
|
||||
"""Arrange calculation bodies and shared channels before creating reroutes."""
|
||||
for frame in [node for node in node_group.nodes if node.bl_idname == "NodeFrame"]:
|
||||
for child in [node for node in node_group.nodes if node.parent == frame]:
|
||||
position = tuple(child.location_absolute)
|
||||
child.parent = None
|
||||
child.location = position
|
||||
node_group.nodes.remove(frame)
|
||||
_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)
|
||||
nodes = list(node_group.nodes)
|
||||
if not nodes:
|
||||
return
|
||||
blocks, core = calculation_blocks(nodes, list(node_group.links))
|
||||
_localize_inputs(node_group, nodes, blocks, core)
|
||||
prepare_node_group(node_group)
|
||||
for node in nodes:
|
||||
node.select = False
|
||||
if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath", "FunctionNodeIntegerMath"}:
|
||||
node.hide = all(socket.is_linked for socket in node.inputs if visible_socket(socket))
|
||||
sizes = {i: estimate_node_dimensions(node) for i, node in enumerate(nodes)}
|
||||
edges = list(node_group.links)
|
||||
positions, routes = plan_layout(nodes, edges, sizes, blocks, core)
|
||||
for i, (x, y) in positions.items():
|
||||
nodes[i].location = (x, -y)
|
||||
nodes[i].width = sizes[i].width
|
||||
cache = {}
|
||||
for index, path in routes.items():
|
||||
edge = edges[index]
|
||||
source = edge.from_socket
|
||||
for x, y in path[1:-1]:
|
||||
key = (source, round(x, 4), round(y, 4))
|
||||
reroute = cache.get(key)
|
||||
if reroute is None:
|
||||
reroute = node_group.nodes.new("NodeReroute")
|
||||
reroute.location = (x - 8, -y + 8)
|
||||
node_group.links.new(source, reroute.inputs[0])
|
||||
cache[key] = reroute
|
||||
source = reroute.outputs[0]
|
||||
if len(path) > 2:
|
||||
replace_link_source(node_group, edge, source)
|
||||
for block in blocks:
|
||||
if len(block["members"]) < 5:
|
||||
continue
|
||||
left, top, right, bottom = block["box"]
|
||||
frame = node_group.nodes.new("NodeFrame")
|
||||
frame["arrangement_frame"] = True
|
||||
frame.name = frame.label = block["title"]
|
||||
frame.shrink = False
|
||||
frame.location = (left - 28, -top + 48)
|
||||
frame.width = right - left + 56
|
||||
frame.height = bottom - top + 76
|
||||
for i in block["members"]:
|
||||
absolute = nodes[i].location.copy()
|
||||
nodes[i].parent = frame
|
||||
nodes[i].location = absolute - frame.location
|
||||
prepare_node_group(node_group)
|
||||
|
||||
Reference in New Issue
Block a user