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)
|
||||
|
||||
79
tools/nodes/arrangement/branches.py
Normal file
79
tools/nodes/arrangement/branches.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Identify connected field calculations by their nearest geometry consumers."""
|
||||
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
|
||||
def calculation_blocks(nodes, edges):
|
||||
"""Group fields with equal consumers, then attach their local interface reads."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
parents = {i: set() for i in range(len(nodes))}
|
||||
children = {i: set() for i in parents}
|
||||
for edge in edges:
|
||||
parents[ids[edge.to_node]].add(ids[edge.from_node])
|
||||
children[ids[edge.from_node]].add(ids[edge.to_node])
|
||||
order = list(TopologicalSorter(parents).static_order())
|
||||
core = {
|
||||
i
|
||||
for i, n in enumerate(nodes)
|
||||
if n.bl_idname == "NodeGroupOutput"
|
||||
or any(s.type == "GEOMETRY" and s.is_linked for s in n.outputs)
|
||||
or getattr(n, "paired_output", None) is not None
|
||||
}
|
||||
inputs = {i for i, n in enumerate(nodes) if n.bl_idname == "NodeGroupInput"}
|
||||
core -= inputs
|
||||
fields = set(range(len(nodes))) - core - inputs
|
||||
destinations = {}
|
||||
for i in reversed(order):
|
||||
destinations[i] = frozenset().union(
|
||||
*(frozenset([j]) if j in core else destinations[j] for j in children[i])
|
||||
)
|
||||
groups = defaultdict(set)
|
||||
for i in fields:
|
||||
groups[destinations[i]].add(i)
|
||||
blocks = []
|
||||
for targets, members in groups.items():
|
||||
pending = set(members)
|
||||
while pending:
|
||||
component = set()
|
||||
queue = [min(pending)]
|
||||
while queue:
|
||||
i = queue.pop()
|
||||
if i not in pending:
|
||||
continue
|
||||
pending.remove(i)
|
||||
component.add(i)
|
||||
queue.extend((parents[i] | children[i]) & pending)
|
||||
target = min(targets) if targets else None
|
||||
ports = list(
|
||||
dict.fromkeys(
|
||||
e.to_socket.name
|
||||
for e in edges
|
||||
if ids[e.from_node] in component and ids[e.to_node] == target
|
||||
)
|
||||
)
|
||||
if ports:
|
||||
title = nodes[target].name + " · " + ", ".join(ports)
|
||||
else:
|
||||
outlets = [i for i in component if children[i] - component]
|
||||
source = (
|
||||
max(outlets, key=lambda i: (len(children[i] - component), i))
|
||||
if outlets
|
||||
else max(component)
|
||||
)
|
||||
title = "共享 · " + nodes[source].name + " → " + str(len(targets)) + " 处计算"
|
||||
blocks.append(dict(title=title, members=sorted(component), targets=sorted(targets)))
|
||||
block_of = {i: k for k, b in enumerate(blocks) for i in b["members"]}
|
||||
# Keep one local original read; additional consumers receive local copies later.
|
||||
for i in sorted(inputs):
|
||||
owners = [block_of[j] for j in sorted(children[i]) if j in block_of]
|
||||
if any(j in core for j in children[i]):
|
||||
core.add(i)
|
||||
elif owners:
|
||||
k = owners[0]
|
||||
blocks[k]["members"].append(i)
|
||||
block_of[i] = k
|
||||
else:
|
||||
core.add(i)
|
||||
|
||||
return blocks, core
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Socket endpoints and conservative curve footprints for placement and preview."""
|
||||
"""Socket endpoints for the node preview."""
|
||||
|
||||
from .flow import socket_links
|
||||
|
||||
@@ -18,22 +18,3 @@ def link_endpoints(link, positions, sizes):
|
||||
siblings = socket_links(link.to_socket)
|
||||
y -= (siblings.index(link) - (len(siblings) - 1) / 2) * 8
|
||||
return start, (x, y)
|
||||
|
||||
|
||||
def curve_footprint(start, end):
|
||||
"""Reserve eight convex hulls covering a horizontal-tangent Bezier curve."""
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
bend = max(40, abs(x2 - x1) * 0.42)
|
||||
curves = [(start, (x1 + bend, y1), (x2 - bend, y2), end)]
|
||||
for _ in range(3):
|
||||
divided = []
|
||||
for points in curves:
|
||||
rows = [points]
|
||||
for _ in range(3):
|
||||
rows.append([((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
||||
for a, b in zip(rows[-1], rows[-1][1:])])
|
||||
divided.extend((tuple(row[0] for row in rows), tuple(row[-1] for row in reversed(rows))))
|
||||
curves = divided
|
||||
return [(min(x for x, _ in curve), max(x for x, _ in curve),
|
||||
max(y for _, y in curve), min(y for _, y in curve)) for curve in curves]
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Lay out expression dependencies with ordered operands and reserved wire passages."""
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import linprog
|
||||
|
||||
from .flow import peer_links
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldLayout:
|
||||
positions: dict
|
||||
width: float
|
||||
height: float
|
||||
routes: list
|
||||
|
||||
|
||||
def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
|
||||
"""Order dependency layers, reserve long-link passages, and solve socket heights."""
|
||||
roots = {node: node for node in nodes}
|
||||
|
||||
def root(node):
|
||||
while roots[node] != node:
|
||||
node = roots[node]
|
||||
return node
|
||||
|
||||
for sources in peers:
|
||||
members = [node for node in sources if node in roots]
|
||||
if sum(sizes[node].height for node in members) + gap * (len(members) - 1) > target_height:
|
||||
continue
|
||||
for node in members[1:]:
|
||||
roots[root(node)] = root(members[0])
|
||||
# Stack exclusive calculation chains without disturbing ordered operands.
|
||||
operands = {node for sources in peers if len(sources) > 1 for node in sources}
|
||||
chain_heights = {node: sizes[node].height for node in nodes}
|
||||
simple = {"ShaderNodeMath", "ShaderNodeVectorMath", "ShaderNodeClamp"}
|
||||
for node in nodes:
|
||||
sources = [parent for parent in parents[node] if parent in roots]
|
||||
if node.bl_idname not in simple or node in operands or len(sources) != 1:
|
||||
continue
|
||||
source = sources[0]
|
||||
if source in operands or {link.to_node for socket in source.outputs for link in socket.links} != {node}:
|
||||
continue
|
||||
first, last = root(source), root(node)
|
||||
height = chain_heights[first] + gap + chain_heights[last]
|
||||
if first != last and height <= target_height:
|
||||
roots[last] = first
|
||||
chain_heights[first] = height
|
||||
groups = defaultdict(list)
|
||||
for node in nodes:
|
||||
groups[root(node)].append(node)
|
||||
for sources in peers:
|
||||
members = [node for node in sources if node in roots]
|
||||
if members and all(root(node) == root(members[0]) for node in members):
|
||||
groups[root(members[0])].sort(key=lambda node: sources.index(node) if node in sources else len(sources))
|
||||
blocks = list(groups.values())
|
||||
block_of = {node: index for index, block in enumerate(blocks) for node in block}
|
||||
children = {index: set() for index in range(len(blocks))}
|
||||
for node in nodes:
|
||||
for parent in parents[node]:
|
||||
if parent in block_of and block_of[parent] != block_of[node]:
|
||||
children[block_of[parent]].add(block_of[node])
|
||||
ranks = {}
|
||||
for block in TopologicalSorter(children).static_order():
|
||||
ranks[block] = max((ranks[child] + 1 for child in children[block]), default=0)
|
||||
depth = max(ranks.values())
|
||||
ranks = {block: depth - rank for block, rank in ranks.items()}
|
||||
heights = [sum(sizes[node].height for node in block) + gap * (len(block) - 1) for block in blocks]
|
||||
offsets = {}
|
||||
for index, block in enumerate(blocks):
|
||||
cursor = -heights[index] / 2
|
||||
for node in block:
|
||||
offsets[node] = cursor
|
||||
cursor += sizes[node].height + gap
|
||||
layers = defaultdict(list)
|
||||
for block, rank in ranks.items():
|
||||
layers[rank].append(block)
|
||||
operand_columns = {rank for node in nodes for link in peer_links(node)
|
||||
if link.from_node in block_of
|
||||
for rank in range(ranks[block_of[link.from_node]], ranks[block_of[node]] + 1)}
|
||||
# Keep an operand block and its junction together when spreading tall layers.
|
||||
columns, column = defaultdict(list), 0
|
||||
for rank in sorted(layers):
|
||||
used = 0
|
||||
for block in layers[rank]:
|
||||
if rank not in operand_columns and used and used + gap + heights[block] > target_height:
|
||||
column += 1
|
||||
used = 0
|
||||
columns[column].append(block)
|
||||
ranks[block] = column
|
||||
used += heights[block] + (gap if used else 0)
|
||||
column += 1
|
||||
layers = columns
|
||||
depth = column - 1
|
||||
links = [link for node in nodes for socket in node.inputs for link in socket.links if link.from_node in block_of]
|
||||
edges, passages, vertical = [], [], []
|
||||
for link in links:
|
||||
first, last = block_of[link.from_node], block_of[link.to_node]
|
||||
if first == last:
|
||||
vertical.append(link)
|
||||
continue
|
||||
start_offset = offsets[link.from_node] + sizes[link.from_node].sockets[link.from_socket]
|
||||
end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket]
|
||||
previous, previous_offset, path = first, start_offset, []
|
||||
for rank in range(ranks[first] + 1, ranks[last]):
|
||||
point = len(heights)
|
||||
heights.append(16)
|
||||
ranks[point] = rank
|
||||
layers[rank].append(point)
|
||||
edges.append((previous, point, previous_offset, 0))
|
||||
previous, previous_offset = point, 0
|
||||
path.append(point)
|
||||
edges.append((previous, last, previous_offset, end_offset))
|
||||
if path:
|
||||
passages.append((link, path))
|
||||
incoming, outgoing = defaultdict(list), defaultdict(list)
|
||||
for first, last, a, b in edges:
|
||||
outgoing[first].append((last, a, b))
|
||||
incoming[last].append((first, b, a))
|
||||
centers = {}
|
||||
|
||||
def stack(layer):
|
||||
height = sum(heights[item] for item in layer) + sum(separation(a, b) for a, b in zip(layer, layer[1:]))
|
||||
cursor = -height / 2
|
||||
for index, item in enumerate(layer):
|
||||
centers[item] = cursor + heights[item] / 2
|
||||
cursor += heights[item] + (separation(item, layer[index + 1]) if index + 1 < len(layer) else 0)
|
||||
|
||||
def separation(first, last):
|
||||
return gap if first < len(blocks) and last < len(blocks) else gap / 2
|
||||
|
||||
for layer in layers.values():
|
||||
stack(layer)
|
||||
for _ in range(4):
|
||||
for ranks_in_order, adjacent in ((range(depth + 1), incoming), (range(depth, -1, -1), outgoing)):
|
||||
for rank in ranks_in_order:
|
||||
layer = layers[rank]
|
||||
def desired(item):
|
||||
neighbors = adjacent[item]
|
||||
return sum(centers[other] + their_offset - own_offset
|
||||
for other, own_offset, their_offset in neighbors) / len(neighbors) if neighbors else centers[item]
|
||||
layer.sort(key=lambda item: (desired(item), item))
|
||||
stack(layer)
|
||||
|
||||
count = len(heights)
|
||||
variable_count = count + 1 + len(edges)
|
||||
inequalities, bounds, equalities, values = [], [], [], []
|
||||
|
||||
def constraint(terms, maximum):
|
||||
row = np.zeros(variable_count)
|
||||
for index, coefficient in terms:
|
||||
row[index] += coefficient
|
||||
inequalities.append(row)
|
||||
bounds.append(maximum)
|
||||
|
||||
for index, height in enumerate(heights):
|
||||
constraint([(index, -1)], -height / 2)
|
||||
constraint([(index, 1), (count, -1)], -height / 2)
|
||||
for layer in layers.values():
|
||||
for first, last in zip(layer, layer[1:]):
|
||||
constraint([(first, 1), (last, -1)], -(heights[first] + heights[last]) / 2 - separation(first, last))
|
||||
for index, (first, last, a, b) in enumerate(edges):
|
||||
slack = count + 1 + index
|
||||
constraint([(first, 1), (last, -1), (slack, -1)], b - a)
|
||||
constraint([(last, 1), (first, -1), (slack, -1)], a - b)
|
||||
for node in nodes:
|
||||
inputs = [link.from_socket for link in peer_links(node)]
|
||||
if len(inputs) < 2 or any(socket.node not in block_of for socket in inputs):
|
||||
continue
|
||||
output = next(socket for socket in node.outputs if socket in sizes[node].sockets)
|
||||
first, last = inputs[0], inputs[-1]
|
||||
row = np.zeros(variable_count)
|
||||
row[block_of[node]] += 2
|
||||
row[block_of[first.node]] -= 1
|
||||
row[block_of[last.node]] -= 1
|
||||
equalities.append(row)
|
||||
values.append(offsets[first.node] + sizes[first.node].sockets[first]
|
||||
+ offsets[last.node] + sizes[last.node].sockets[last]
|
||||
- 2 * (offsets[node] + sizes[node].sockets[output]))
|
||||
objective = np.zeros(variable_count)
|
||||
objective[count] = len(edges) + 1
|
||||
objective[count + 1:] = 1
|
||||
result = linprog(objective, A_ub=np.array(inequalities), b_ub=bounds,
|
||||
A_eq=np.array(equalities) if equalities else None, b_eq=values or None,
|
||||
bounds=[(None, None)] * count + [(0, None)] * (1 + len(edges)), method="highs")
|
||||
if not result.success:
|
||||
raise ValueError(f"Expression layers could not be placed: {result.message}")
|
||||
centers = result.x[:count]
|
||||
positions, point_positions, right = {}, {}, 0
|
||||
vertical_blocks = {block_of[link.from_node] for link in vertical}
|
||||
for rank in range(depth + 1):
|
||||
layer = layers[rank]
|
||||
width = max(max(sizes[node].width for node in blocks[item]) if item < len(blocks) else 16 for item in layer)
|
||||
padding = gap / 2 if any(item in vertical_blocks for item in layer) else 0
|
||||
if rank:
|
||||
right += gap
|
||||
right += width + padding * 2
|
||||
for item in layer:
|
||||
if item < len(blocks):
|
||||
for node in blocks[item]:
|
||||
positions[node] = (right - padding - sizes[node].width, -centers[item] - offsets[node])
|
||||
else:
|
||||
point_positions[item] = (right, -centers[item])
|
||||
routes = []
|
||||
for link, path in passages:
|
||||
points = [point_positions[item] for item in path]
|
||||
points = [point for index, point in enumerate(points) if index == 0 or index == len(points)-1
|
||||
or not abs(points[index-1][1] - point[1]) < 1e-6 or not abs(points[index+1][1] - point[1]) < 1e-6]
|
||||
routes.append((link, points))
|
||||
for link in vertical:
|
||||
source, target = link.from_node, link.to_node
|
||||
x1, y1 = positions[source]
|
||||
x2, y2 = positions[target]
|
||||
middle = (y1 - sizes[source].height + y2) / 2
|
||||
routes.append((link, [(x1 + sizes[source].width + gap / 2, y1 - sizes[source].sockets[link.from_socket]),
|
||||
(x1 + sizes[source].width + gap / 2, middle),
|
||||
(x2 - gap / 2, middle), (x2 - gap / 2, y2 - sizes[target].sockets[link.to_socket])]))
|
||||
return FieldLayout(positions, right, result.x[count], routes)
|
||||
@@ -1,13 +1,8 @@
|
||||
"""Infer geometry flow and socket relationships for layout and preview."""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
from pathlib import Path
|
||||
|
||||
from .dimensions import visible_socket
|
||||
|
||||
SEMANTICS = json.loads(Path(__file__).with_name("semantics.json").read_text(encoding="utf-8"))
|
||||
SHARED_GEOMETRY_USES = 3
|
||||
|
||||
|
||||
@@ -15,39 +10,12 @@ def socket_links(socket):
|
||||
return sorted(socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)
|
||||
|
||||
|
||||
|
||||
def origin_socket(socket):
|
||||
while socket.node.bl_idname == "NodeReroute":
|
||||
socket = socket.node.inputs[0].links[0].from_socket
|
||||
return socket
|
||||
|
||||
|
||||
|
||||
def graph(node_group):
|
||||
def key(node):
|
||||
if node.bl_idname in {"NodeGroupInput", "NodeReroute"}:
|
||||
return (1, tuple(sorted((link.to_node.name, link.to_socket.identifier,
|
||||
socket_links(link.to_socket).index(link))
|
||||
for socket in node.outputs for link in socket.links)))
|
||||
return (0, node.name)
|
||||
|
||||
nodes = sorted(node_group.nodes, key=key)
|
||||
parents = {node: [] for node in nodes}
|
||||
children = {node: set() for node in nodes}
|
||||
for link in node_group.links:
|
||||
if link.from_node not in parents[link.to_node]:
|
||||
parents[link.to_node].append(link.from_node)
|
||||
children[link.from_node].add(link.to_node)
|
||||
for sources in parents.values():
|
||||
sources.sort(key=key)
|
||||
order = list(TopologicalSorter(parents).static_order())
|
||||
ancestors = {}
|
||||
for node in order:
|
||||
ancestors[node] = set(parents[node]) | set().union(*(ancestors[p] for p in parents[node]))
|
||||
return parents, children, order, ancestors
|
||||
|
||||
|
||||
|
||||
def geometry_paths(node_group):
|
||||
"""Trace every geometry input and split paths at forks and merges."""
|
||||
output = next((node for node in node_group.nodes if node.bl_idname == "NodeGroupOutput" and node.is_active_output), None)
|
||||
@@ -114,37 +82,6 @@ def geometry_paths(node_group):
|
||||
return parents, paths
|
||||
|
||||
|
||||
|
||||
def shared_geometry_outputs(nodes):
|
||||
"""Find geometry outputs reused by several distinct operations."""
|
||||
shared = []
|
||||
for node in nodes:
|
||||
for socket in node.outputs:
|
||||
if socket.type != "GEOMETRY":
|
||||
continue
|
||||
targets, pending = set(), [link.to_node for link in socket.links]
|
||||
while pending:
|
||||
target = pending.pop()
|
||||
if target.bl_idname == "NodeReroute":
|
||||
pending.extend(link.to_node for link in target.outputs[0].links)
|
||||
else:
|
||||
targets.add(target)
|
||||
if len(targets) >= SHARED_GEOMETRY_USES:
|
||||
shared.append(socket)
|
||||
return shared
|
||||
|
||||
|
||||
|
||||
def peer_links(node):
|
||||
"""Read ordered data operands from the semantic table, excluding controls."""
|
||||
rule = SEMANTICS.get(node.bl_idname)
|
||||
if rule is None or ("operations" in rule and node.operation not in rule["operations"]):
|
||||
return []
|
||||
return [link for socket in list(node.inputs)[rule["peer_inputs_from"]:]
|
||||
if visible_socket(socket) and socket.type != "CUSTOM"
|
||||
for link in socket_links(socket)]
|
||||
|
||||
|
||||
def node_roles(node_group):
|
||||
"""Classify by contribution to the result, not by available socket types."""
|
||||
core, _ = geometry_paths(node_group)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Frame complex field calculations by their consuming inputs."""
|
||||
|
||||
from .flow import origin_socket
|
||||
|
||||
FRAME_PADDING = 32
|
||||
FRAME_HEADING = 48
|
||||
FRAME_MIN_NODES = 12
|
||||
|
||||
|
||||
def remove_field_frames(node_group):
|
||||
"""Detach generated containers before calculating absolute positions again."""
|
||||
frames = {node for node in node_group.nodes if node.get("arrangement_frame")}
|
||||
for node in node_group.nodes:
|
||||
if node.parent in frames:
|
||||
position = tuple(node.location_absolute)
|
||||
node.parent = None
|
||||
node.location = position
|
||||
for frame in frames:
|
||||
node_group.nodes.remove(frame)
|
||||
|
||||
|
||||
def create_field_frames(node_group, bodies, core):
|
||||
"""Keep a complex calculation and its internal wire passages in one frame."""
|
||||
for members, (left, right, top, bottom) in bodies:
|
||||
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) < FRAME_MIN_NODES:
|
||||
continue
|
||||
members = set(members)
|
||||
outlets = [link for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members]
|
||||
targets = []
|
||||
pending = list(outlets)
|
||||
visited = set()
|
||||
while pending:
|
||||
link = pending.pop()
|
||||
if link.to_node in core:
|
||||
targets.append(link)
|
||||
elif link.to_node not in visited:
|
||||
visited.add(link.to_node)
|
||||
pending.extend(edge for socket in link.to_node.outputs for edge in socket.links)
|
||||
target = min(targets, key=lambda link: (link.to_node.location_absolute.x,
|
||||
link.to_node.name, link.to_socket.name), default=None)
|
||||
pending = {node for node in node_group.nodes if node.bl_idname == "NodeReroute"
|
||||
and node.get("arrangement_branch") and origin_socket(node.outputs[0]).node in members}
|
||||
while ready := {node for node in pending if all(link.to_node in members for link in node.outputs[0].links)}:
|
||||
members.update(ready)
|
||||
pending.difference_update(ready)
|
||||
frame = node_group.nodes.new("NodeFrame")
|
||||
frame["arrangement_frame"] = True
|
||||
frame.name = f"{target.to_node.name} · {target.to_socket.name}" if target else "Field Calculation"
|
||||
frame.label = frame.name
|
||||
frame.shrink = False
|
||||
frame.location = (left - FRAME_PADDING, top + FRAME_HEADING)
|
||||
frame.width = right - left + FRAME_PADDING * 2
|
||||
frame.height = top - bottom + FRAME_HEADING + FRAME_PADDING
|
||||
frame.use_custom_color = True
|
||||
frame.color = (0.12, 0.16, 0.18)
|
||||
for node in members:
|
||||
node.parent = frame
|
||||
407
tools/nodes/arrangement/layout.py
Normal file
407
tools/nodes/arrangement/layout.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Plan calculation bodies and shared wire passages in the same column layout."""
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
from statistics import median
|
||||
|
||||
|
||||
def plan_layout(nodes, edges, size, blocks, core):
|
||||
"""Return node positions and connection paths without modifying Blender data."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
edge_ids = [(ids[e.from_node], ids[e.to_node]) for e in edges]
|
||||
edge_types = [e.from_socket.type for e in edges]
|
||||
block_of = {i: k for k, block in enumerate(blocks) for i in block["members"]}
|
||||
|
||||
def bounds(positions):
|
||||
return (
|
||||
min(x for x, y in positions.values()),
|
||||
min(y for x, y in positions.values()),
|
||||
max(x + size[i].width for i, (x, y) in positions.items()),
|
||||
max(y + size[i].height for i, (x, y) in positions.items()),
|
||||
)
|
||||
|
||||
def endpoint(edge, positions):
|
||||
a, b = ids[edge.from_node], ids[edge.to_node]
|
||||
return (
|
||||
(positions[a][0] + size[a].width, positions[a][1] + size[a].sockets[edge.from_socket]),
|
||||
(positions[b][0], positions[b][1] + size[b].sockets[edge.to_socket]),
|
||||
)
|
||||
|
||||
source_keys = [(a, edges[e].from_socket.identifier) for e, (a, b) in enumerate(edge_ids)]
|
||||
|
||||
def simplify(path):
|
||||
result = []
|
||||
for point in path:
|
||||
if result and math.dist(result[-1], point) < 1e-6:
|
||||
continue
|
||||
while (
|
||||
len(result) > 1
|
||||
and abs(result[-2][1] - result[-1][1]) < 1e-6
|
||||
and abs(point[1] - result[-1][1]) < 1e-6
|
||||
):
|
||||
result.pop()
|
||||
result.append(point)
|
||||
return result
|
||||
|
||||
def leaf(i):
|
||||
return dict(
|
||||
members={i},
|
||||
w=size[i].width,
|
||||
h=size[i].height,
|
||||
positions={i: (0.0, 0.0)},
|
||||
routes={},
|
||||
inputs={
|
||||
e: (0.0, size[i].sockets[edges[e].to_socket]) for e, (a, b) in enumerate(edge_ids) if b == i
|
||||
},
|
||||
outputs={
|
||||
e: (size[i].width, size[i].sockets[edges[e].from_socket])
|
||||
for e, (a, b) in enumerate(edge_ids)
|
||||
if a == i
|
||||
},
|
||||
)
|
||||
|
||||
def plan_scope(items, rank_map=None):
|
||||
"""Reserve relay rows at every crossed column, including nested boundaries."""
|
||||
items = {("item", k): v for k, v in enumerate(items)}
|
||||
owner = {i: k for k, item in items.items() for i in item["members"]}
|
||||
members = set(owner)
|
||||
incoming = [e for e, (a, b) in enumerate(edge_ids) if b in members and a not in members]
|
||||
outgoing = [e for e, (a, b) in enumerate(edge_ids) if a in members and b not in members]
|
||||
if rank_map is not None:
|
||||
incoming = []
|
||||
outgoing = []
|
||||
boundary = {}
|
||||
for e in incoming:
|
||||
boundary["in", e] = ("in", source_keys[e])
|
||||
for e in outgoing:
|
||||
boundary["out", e] = ("out", source_keys[e])
|
||||
for key in set(boundary.values()):
|
||||
items[key] = dict(members=set(), w=0.0, h=32.0, positions={}, routes={}, inputs={}, outputs={})
|
||||
links = []
|
||||
for e, (a, b) in enumerate(edge_ids):
|
||||
if a not in members and b not in members:
|
||||
continue
|
||||
if rank_map is not None and (a not in members or b not in members):
|
||||
continue
|
||||
u = owner[a] if a in members else boundary["in", e]
|
||||
v = owner[b] if b in members else boundary["out", e]
|
||||
if u == v:
|
||||
continue
|
||||
sy = items[u]["outputs"][e][1] if a in members else 16.0
|
||||
ty = items[v]["inputs"][e][1] if b in members else 16.0
|
||||
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
|
||||
links.append((e, u, v, sy, ty, weight))
|
||||
parents = {k: set() for k in items}
|
||||
children = {k: set() for k in items}
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
parents[v].add(u)
|
||||
children[u].add(v)
|
||||
topo = list(TopologicalSorter(parents).static_order())
|
||||
earliest = {}
|
||||
for k in topo:
|
||||
earliest[k] = max((earliest[p] + 1 for p in parents[k]), default=0)
|
||||
depth = max(earliest.values(), default=0)
|
||||
ranks = {}
|
||||
for k in reversed(topo):
|
||||
ranks[k] = min((ranks[c] - 1 for c in children[k]), default=depth)
|
||||
for k in items:
|
||||
if k[0] == "in":
|
||||
ranks[k] = 0
|
||||
if rank_map is not None:
|
||||
ranks = {k: rank_map[min(item["members"])] for k, item in items.items()}
|
||||
# Relay vertices are space reservations, not routes fitted to placed nodes.
|
||||
chains = {}
|
||||
arcs = {}
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
chain = [u]
|
||||
for rank in range(ranks[u] + 1, ranks[v]):
|
||||
key = ("relay", source_keys[e], rank)
|
||||
if key not in items:
|
||||
items[key] = dict(
|
||||
members=set(), w=0.0, h=36.0, positions={}, routes={}, inputs={}, outputs={}
|
||||
)
|
||||
ranks[key] = rank
|
||||
chain.append(key)
|
||||
chain.append(v)
|
||||
chains[e] = chain
|
||||
for j, (a, b) in enumerate(zip(chain, chain[1:])):
|
||||
ay = sy if j == 0 else items[a]["h"] / 2
|
||||
by = ty if j == len(chain) - 2 else items[b]["h"] / 2
|
||||
arcs[a, b, ay, by] = max(arcs.get((a, b, ay, by), 0), weight)
|
||||
layers = defaultdict(list)
|
||||
for k in items:
|
||||
layers[ranks[k]].append(k)
|
||||
for rank in range(min(ranks.values()), max(ranks.values()) + 1):
|
||||
layers[rank]
|
||||
families = {}
|
||||
for k, item in items.items():
|
||||
if item["members"]:
|
||||
i = min(item["members"])
|
||||
families[k] = ("block", block_of[i]) if i in block_of else ("core", i)
|
||||
else:
|
||||
families[k] = k
|
||||
for e, chain in chains.items():
|
||||
a, b = edge_ids[e]
|
||||
if a in block_of and block_of.get(a) == block_of.get(b):
|
||||
for k in chain[1:-1]:
|
||||
families[k] = ("block", block_of[a])
|
||||
neighbors = defaultdict(list)
|
||||
for (a, b, ay, by), weight in arcs.items():
|
||||
if a[0] == "relay" and b[0] == "relay":
|
||||
weight *= 30.0
|
||||
neighbors[a].append((b, ay, by, weight))
|
||||
neighbors[b].append((a, by, ay, weight))
|
||||
gap = 64.0
|
||||
|
||||
def separation(a, b):
|
||||
if families[a] != families[b] and (families[a][0] == "block" or families[b][0] == "block"):
|
||||
return 180.0
|
||||
return gap if items[a]["members"] and items[b]["members"] else 36.0
|
||||
|
||||
y = {}
|
||||
for layer in layers.values():
|
||||
cursor = 0.0
|
||||
for k in layer:
|
||||
y[k] = cursor
|
||||
cursor += items[k]["h"] + gap
|
||||
|
||||
def pack(layer, desired, weights):
|
||||
offsets = {}
|
||||
cursor = 0.0
|
||||
previous = None
|
||||
for k in layer:
|
||||
if previous is not None:
|
||||
cursor += items[previous]["h"] + separation(previous, k)
|
||||
offsets[k] = cursor
|
||||
previous = k
|
||||
pools = []
|
||||
for k in layer:
|
||||
pools.append(([k], weights[k], (desired[k] - offsets[k]) * weights[k]))
|
||||
while len(pools) > 1 and pools[-2][2] / pools[-2][1] > pools[-1][2] / pools[-1][1]:
|
||||
b = pools.pop()
|
||||
a = pools.pop()
|
||||
pools.append((a[0] + b[0], a[1] + b[1], a[2] + b[2]))
|
||||
for keys, weight, total in pools:
|
||||
for k in keys:
|
||||
y[k] = total / weight + offsets[k]
|
||||
|
||||
for sweep in range(16):
|
||||
for rank in sorted(layers, reverse=bool(sweep % 2)):
|
||||
layer = layers[rank]
|
||||
desired = {}
|
||||
weights = {}
|
||||
for k in layer:
|
||||
adjacent = [
|
||||
(y[b] + by - ay, weight)
|
||||
for b, ay, by, weight in neighbors[k]
|
||||
if (ranks[b] < rank) != bool(sweep % 2)
|
||||
]
|
||||
if not adjacent:
|
||||
adjacent = [(y[b] + by - ay, weight) for b, ay, by, weight in neighbors[k]]
|
||||
weights[k] = sum(w for value, w in adjacent) or 1.0
|
||||
desired[k] = sum(value * w for value, w in adjacent) / weights[k] if adjacent else y[k]
|
||||
family_centers = defaultdict(list)
|
||||
for k in layer:
|
||||
family_centers[families[k]].append(desired[k] + items[k]["h"] / 2)
|
||||
centers = {key: sum(values) / len(values) for key, values in family_centers.items()}
|
||||
layer.sort(
|
||||
key=lambda k: (
|
||||
centers[families[k]],
|
||||
repr(families[k]),
|
||||
desired[k] + items[k]["h"] / 2,
|
||||
repr(k),
|
||||
)
|
||||
)
|
||||
pack(layer, desired, weights)
|
||||
# Coordinate relaxation holds the planned row order and never moves obstacles
|
||||
# in response to a routed wire. All channels remain participants in the solve.
|
||||
for sweep in range(24):
|
||||
for rank in sorted(layers, reverse=bool(sweep % 2)):
|
||||
desired = {}
|
||||
weights = {}
|
||||
for k in layers[rank]:
|
||||
adj = neighbors[k]
|
||||
weights[k] = sum(w for b, ay, by, w in adj) or 1.0
|
||||
desired[k] = (
|
||||
sum((y[b] + by - ay) * w for b, ay, by, w in adj) / weights[k] if adj else y[k]
|
||||
)
|
||||
pack(layers[rank], desired, weights)
|
||||
top = min(y.values(), default=0.0)
|
||||
y = {k: value - top for k, value in y.items()}
|
||||
widths = {rank: max((items[k]["w"] for k in layer), default=0.0) for rank, layer in layers.items()}
|
||||
x = {}
|
||||
cursor = 0.0
|
||||
for rank in sorted(layers):
|
||||
x[rank] = cursor
|
||||
cursor += widths[rank] + 200.0
|
||||
width = cursor - 200.0
|
||||
if rank_map is not None:
|
||||
x = {rank: planned["columns"][rank] for rank in layers}
|
||||
widths = {rank: planned["column_widths"][rank] for rank in layers}
|
||||
height = max(y[k] + items[k]["h"] for k in items)
|
||||
result = dict(members=members, w=width, h=height, positions={}, routes={}, inputs={}, outputs={})
|
||||
for k, item in items.items():
|
||||
ox, oy = x[ranks[k]], y[k]
|
||||
result["positions"].update({i: (ox + px, oy + py) for i, (px, py) in item["positions"].items()})
|
||||
for e, path in item["routes"].items():
|
||||
result["routes"].setdefault(e, []).append([(ox + px, oy + py) for px, py in path])
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
path = []
|
||||
chain = chains[e]
|
||||
for j, (a, b) in enumerate(zip(chain, chain[1:])):
|
||||
first_y = y[a] + (sy if j == 0 else items[a]["h"] / 2)
|
||||
last_y = y[b] + (ty if j == len(chain) - 2 else items[b]["h"] / 2)
|
||||
first_x = x[ranks[a]] + items[a]["w"]
|
||||
last_x = x[ranks[b]]
|
||||
path.append((first_x, first_y))
|
||||
if items[a]["w"] < widths[ranks[a]]:
|
||||
path.append((x[ranks[a]] + widths[ranks[a]] + 60.0, first_y))
|
||||
path.append((last_x, last_y))
|
||||
result["routes"].setdefault(e, []).append(simplify(path))
|
||||
for e, key in ((e, boundary["in", e]) for e in incoming):
|
||||
result["inputs"][e] = (0.0, y[key] + 16.0)
|
||||
for e, key in ((e, boundary["out", e]) for e in outgoing):
|
||||
result["outputs"][e] = (width, y[key] + 16.0)
|
||||
for e, pieces in result["routes"].items():
|
||||
pieces.sort(key=lambda path: path[0][0])
|
||||
result["routes"][e] = simplify([point for path in pieces for point in path])
|
||||
result["ranks"] = {i: ranks[k] for i, k in owner.items()}
|
||||
result["columns"] = x
|
||||
result["column_widths"] = widths
|
||||
return result
|
||||
|
||||
planned = plan_scope([leaf(i) for i in range(len(nodes))])
|
||||
seed = planned["positions"]
|
||||
for block in blocks:
|
||||
local = plan_scope([leaf(i) for i in block["members"]], rank_map=planned["ranks"])
|
||||
preferred = (
|
||||
sum(seed[i][1] + size[i].height / 2 for i in block["members"]) / len(block["members"])
|
||||
- local["h"] / 2
|
||||
)
|
||||
seed.update({i: (x, y + preferred) for i, (x, y) in local["positions"].items()})
|
||||
planned["routes"].update(
|
||||
{e: [(x, y + preferred) for x, y in path] for e, path in local["routes"].items()}
|
||||
)
|
||||
atoms = []
|
||||
node_atom = {}
|
||||
for members in [b["members"] for b in blocks] + [[i] for i in sorted(core)]:
|
||||
member_set = set(members)
|
||||
internal = [e for e, (a, b) in enumerate(edge_ids) if a in member_set and b in member_set]
|
||||
left, top, right, bottom = bounds({i: seed[i] for i in members})
|
||||
for e in internal:
|
||||
top = min(top, *(p[1] for p in planned["routes"][e]))
|
||||
bottom = max(bottom, *(p[1] for p in planned["routes"][e]))
|
||||
k = len(atoms)
|
||||
atoms.append(
|
||||
dict(
|
||||
members=member_set,
|
||||
x=left,
|
||||
w=right - left,
|
||||
h=bottom - top,
|
||||
preferred=top,
|
||||
internal=internal,
|
||||
bus=False,
|
||||
)
|
||||
)
|
||||
node_atom.update({i: k for i in members})
|
||||
|
||||
external = [e for e, (a, b) in enumerate(edge_ids) if node_atom[a] != node_atom[b]]
|
||||
buses = {}
|
||||
edge_bus = {}
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
ra, rb = planned["ranks"][a], planned["ranks"][b]
|
||||
if rb == ra + 1:
|
||||
continue
|
||||
key = source_keys[e]
|
||||
if key not in buses:
|
||||
buses[key] = []
|
||||
buses[key].append(e)
|
||||
for key, links in buses.items():
|
||||
a = edge_ids[links[0]][0]
|
||||
ra = planned["ranks"][a]
|
||||
left = planned["columns"][ra + 1] - 60.0
|
||||
right = max(planned["columns"][planned["ranks"][edge_ids[e][1]]] - 60.0 for e in links)
|
||||
ys = [p[1] for e in links for p in planned["routes"][e][1:-1]]
|
||||
k = len(atoms)
|
||||
atoms.append(
|
||||
dict(
|
||||
members=set(),
|
||||
x=left,
|
||||
w=right - left,
|
||||
h=24.0,
|
||||
preferred=median(ys) - 12.0,
|
||||
internal=[],
|
||||
bus=True,
|
||||
)
|
||||
)
|
||||
edge_bus.update({e: k for e in links})
|
||||
|
||||
# Shapes and long-wire reservations are packed in the same interval constraint
|
||||
# graph. No obstacle test or routing result can change these constraints later.
|
||||
atom_order = sorted(range(len(atoms)), key=lambda k: (atoms[k]["preferred"] + atoms[k]["h"] / 2, k))
|
||||
above = defaultdict(list)
|
||||
below = defaultdict(list)
|
||||
for j, a in enumerate(atom_order):
|
||||
for b in atom_order[j + 1 :]:
|
||||
u, v = atoms[a], atoms[b]
|
||||
if u["x"] < v["x"] + v["w"] + 32 and v["x"] < u["x"] + u["w"] + 32:
|
||||
gap = 32.0 if u["bus"] or v["bus"] else 220.0
|
||||
distance = u["h"] + gap
|
||||
above[b].append((a, distance))
|
||||
below[a].append((b, distance))
|
||||
ay = {}
|
||||
for k in atom_order:
|
||||
ay[k] = max([atoms[k]["preferred"], *(ay[a] + distance for a, distance in above[k])])
|
||||
neighbors = defaultdict(list)
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
u, v = node_atom[a], node_atom[b]
|
||||
sy = seed[a][1] + size[a].sockets[edges[e].from_socket] - atoms[u]["preferred"]
|
||||
ty = seed[b][1] + size[b].sockets[edges[e].to_socket] - atoms[v]["preferred"]
|
||||
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
|
||||
if e in edge_bus:
|
||||
weight *= 0.15
|
||||
sequence = [(u, sy), (v, ty)] if e not in edge_bus else [(u, sy), (edge_bus[e], 12.0), (v, ty)]
|
||||
for (k, ky), (j, jy) in zip(sequence, sequence[1:]):
|
||||
neighbors[k].append((j, ky, jy, weight))
|
||||
neighbors[j].append((k, jy, ky, weight))
|
||||
for sweep in range(80):
|
||||
for k in atom_order if sweep % 2 else reversed(atom_order):
|
||||
adjacent = neighbors[k]
|
||||
preferred = (
|
||||
sum((ay[j] + jy - ky) * w for j, ky, jy, w in adjacent) / sum(w for j, ky, jy, w in adjacent)
|
||||
if adjacent
|
||||
else ay[k]
|
||||
)
|
||||
low = max((ay[j] + d for j, d in above[k]), default=-1e9)
|
||||
high = min((ay[j] - d for j, d in below[k]), default=1e9)
|
||||
ay[k] = max(low, min(high, preferred))
|
||||
shift = min(ay.values())
|
||||
ay = {k: value - shift for k, value in ay.items()}
|
||||
positions = {i: (seed[i][0], seed[i][1] + ay[k] - atoms[k]["preferred"]) for i, k in node_atom.items()}
|
||||
routes = {}
|
||||
for k, atom in enumerate(atoms):
|
||||
for e in atom["internal"]:
|
||||
routes[e] = [(x, y + ay[k] - atom["preferred"]) for x, y in planned["routes"][e]]
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
start, end = endpoint(edges[e], positions)
|
||||
ra, rb = planned["ranks"][a], planned["ranks"][b]
|
||||
exit_x = planned["columns"][ra] + planned["column_widths"][ra] + 60.0
|
||||
entry_x = planned["columns"][rb] - 60.0
|
||||
if e in edge_bus:
|
||||
k = edge_bus[e]
|
||||
level = ay[k] + 12.0
|
||||
path = [start, (exit_x, start[1]), (atoms[k]["x"], level), (entry_x, level), end]
|
||||
else:
|
||||
path = (
|
||||
[start, end]
|
||||
if size[a].width == planned["column_widths"][ra]
|
||||
else [start, (exit_x, start[1]), end]
|
||||
)
|
||||
routes[e] = simplify(path)
|
||||
for block in blocks:
|
||||
block["box"] = bounds({i: positions[i] for i in block["members"]})
|
||||
return positions, routes
|
||||
@@ -1,275 +0,0 @@
|
||||
"""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
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Route shared results on peripheral tracks with local consumer branches."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from ..common.nodes import replace_link_source
|
||||
from .connections import curve_footprint, socket_position, link_endpoints
|
||||
from .dimensions import estimate_node_dimensions
|
||||
from .flow import geometry_paths, origin_socket, peer_links
|
||||
from .frames import FRAME_PADDING, FRAME_HEADING, FRAME_MIN_NODES
|
||||
from .zones import node_zones
|
||||
|
||||
TRACK_GAP = 96
|
||||
PORT_RUN = 40
|
||||
FRAME_CLEARANCE = 160
|
||||
|
||||
|
||||
def route_references(node_group, bodies, *, peripheral_nodes=frozenset()):
|
||||
"""Keep execution links direct and distribute shared results outside field regions."""
|
||||
nodes = list(node_group.nodes)
|
||||
repeats = [members for entry, _, members in node_zones(node_group)
|
||||
if entry.bl_idname == "GeometryNodeRepeatInput"]
|
||||
sizes = {node: estimate_node_dimensions(node) for node in nodes}
|
||||
positions = {node: tuple(node.location_absolute) for node in nodes}
|
||||
region_of = {node: index for index, (members, _) in enumerate(bodies) for node in members}
|
||||
producer_regions = {index for index, (members, _) in enumerate(bodies) if members & peripheral_nodes}
|
||||
boxes = [(node, (positions[node][0], positions[node][0] + sizes[node].width,
|
||||
positions[node][1], positions[node][1] - sizes[node].height))
|
||||
for node in nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}]
|
||||
padding = FRAME_CLEARANCE - TRACK_GAP
|
||||
regions = [(left - FRAME_PADDING - padding, right + FRAME_PADDING + padding,
|
||||
top + FRAME_HEADING + padding, bottom - FRAME_PADDING - padding)
|
||||
for _, (left, right, top, bottom) in bodies]
|
||||
frames = {index for index, (members, _) in enumerate(bodies)
|
||||
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES}
|
||||
_, paths = geometry_paths(node_group)
|
||||
execution = {(first, last) for path in paths for first, last in zip(path, path[1:])}
|
||||
references = defaultdict(list)
|
||||
for node in nodes:
|
||||
if node.bl_idname == "NodeReroute" and not node.get("arrangement_input"):
|
||||
continue
|
||||
for socket in node.inputs:
|
||||
for link in list(socket.links):
|
||||
if link.from_node.get("arrangement_input"):
|
||||
continue
|
||||
source = origin_socket(link.from_socket)
|
||||
if any(source.node in members and node in members for members in repeats):
|
||||
continue
|
||||
if source.node.bl_idname == "NodeGroupInput":
|
||||
continue
|
||||
if (source.node, node) in execution:
|
||||
continue
|
||||
if source.node in region_of and region_of[source.node] == region_of.get(node):
|
||||
continue
|
||||
start = socket_position(source, positions, sizes)
|
||||
end = link_endpoints(link, positions, sizes)[1]
|
||||
if link in peer_links(node):
|
||||
end = (end[0], start[1])
|
||||
distance = end[0] - start[0]
|
||||
if socket.type != "GEOMETRY":
|
||||
references[source, False].append((link, end))
|
||||
continue
|
||||
if 0 <= distance <= 480:
|
||||
continue
|
||||
if distance > 0 and (len(source.links) == 1 or distance <= 800):
|
||||
footprint = curve_footprint(start, end)
|
||||
endpoint_regions = {region_of[member] for member in (source.node, node) if member in region_of}
|
||||
obstacles = [box for member, box in boxes if member not in {source.node, node}]
|
||||
obstacles += [(left + padding, right - padding, top - padding, bottom + padding)
|
||||
for index, (left, right, top, bottom) in enumerate(regions)
|
||||
if index in frames and index not in endpoint_regions]
|
||||
if all(not any(a < right and b > left and bottom < top and upper > lower
|
||||
for left, right, top, lower in footprint)
|
||||
for a, b, upper, bottom in obstacles):
|
||||
if link.from_socket != source:
|
||||
replace_link_source(node_group, link, source)
|
||||
continue
|
||||
references[source, distance < 0].append((link, end))
|
||||
references = {key: deliveries for key, deliveries in references.items()
|
||||
if key[0].type == "GEOMETRY" or len({link.to_node for link, _ in deliveries}) > 1}
|
||||
tracks = []
|
||||
replaced = {link for deliveries in references.values() for link, _ in deliveries}
|
||||
wire_corridors = []
|
||||
for link in node_group.links:
|
||||
start, end = link_endpoints(link, positions, sizes)
|
||||
if link.from_socket.type == "GEOMETRY" and link not in replaced:
|
||||
wire_corridors.extend(curve_footprint(start, end))
|
||||
if link.from_socket.type == "GEOMETRY" and abs(start[1] - end[1]) < 1 and abs(start[0] - end[0]) > PORT_RUN:
|
||||
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1]))
|
||||
|
||||
def route(source, point):
|
||||
node = node_group.nodes.new("NodeReroute")
|
||||
node["arrangement_reference"] = True
|
||||
node.location = (point[0] - 8, point[1] + 8)
|
||||
node_group.links.new(source, node.inputs[0])
|
||||
return node.outputs[0]
|
||||
|
||||
ordered = sorted(references, key=lambda item: (item[0].type != "GEOMETRY", item[0].node.name, item[0].identifier, item[1]))
|
||||
for source, backward in ordered:
|
||||
deliveries = references[source, backward]
|
||||
start = socket_position(source, positions, sizes)
|
||||
peripheral = len(deliveries) > 1
|
||||
forward_pipe = peripheral and (source.type != "GEOMETRY" or source.node in peripheral_nodes)
|
||||
level_entry = forward_pipe and source.type != "GEOMETRY"
|
||||
source_region = region_of.get(source.node)
|
||||
source_members = bodies[source_region][0] if source_region is not None else {source.node}
|
||||
|
||||
def path(level):
|
||||
arrivals = [(end[0] - max(PORT_RUN, abs(level - end[1]) * 0.8), link, end)
|
||||
for link, end in deliveries]
|
||||
launch = min(x for x, _, _ in arrivals)
|
||||
run = max(PORT_RUN, abs(level - start[1]) * 0.8)
|
||||
launch = start[0] + run if forward_pipe or launch >= start[0] + run else min(launch, start[0] - run)
|
||||
return launch, arrivals
|
||||
|
||||
endpoints = {source.node, *(link.to_node for link, _ in deliveries)}
|
||||
obstacles = [box for node, box in boxes if node not in endpoints]
|
||||
obstacles += [box for index, box in enumerate(regions) if not forward_pipe or index != source_region]
|
||||
boundaries = [box for _, box in boxes] + regions if peripheral else obstacles
|
||||
candidates = {edge for y in [start[1], *(end[1] for _, end in deliveries)]
|
||||
for edge in (y - TRACK_GAP, y + TRACK_GAP)}
|
||||
if level_entry:
|
||||
candidates.add(start[1])
|
||||
candidates.update({
|
||||
*(edge for _, _, top, bottom in boundaries for edge in (top + TRACK_GAP, bottom - TRACK_GAP)),
|
||||
*(edge for _, _, y in tracks for edge in (y + TRACK_GAP, y - TRACK_GAP))})
|
||||
|
||||
def clear(level):
|
||||
launch, arrivals = path(level)
|
||||
if forward_pipe and launch > min(x for x, _, _ in arrivals):
|
||||
return False
|
||||
if any(abs(level - y) < TRACK_GAP for y in [*([start[1]] if not level_entry else []), *(end[1] for _, end in deliveries)]):
|
||||
return False
|
||||
left = min(launch, *(x for x, _, _ in arrivals))
|
||||
right = max(launch, *(x for x, _, _ in arrivals))
|
||||
if peripheral:
|
||||
scope_left = min(left, start[0], *(end[0] for _, end in deliveries))
|
||||
scope_right = max(right, start[0], *(end[0] for _, end in deliveries))
|
||||
scope = [box for box in [*(box for node, box in boxes if node not in source_members and node not in peripheral_nodes),
|
||||
*(box for index, box in enumerate(regions) if index != source_region and index not in producer_regions)]
|
||||
if box[0] < scope_right and box[1] > scope_left]
|
||||
if scope and min(box[3] for box in scope) - TRACK_GAP < level < max(box[2] for box in scope) + TRACK_GAP:
|
||||
return False
|
||||
return (all(not bottom - TRACK_GAP < level < top + TRACK_GAP
|
||||
for a, b, top, bottom in obstacles if a < right and b > left)
|
||||
and all(abs(level - previous) >= TRACK_GAP - 0.01
|
||||
for a, b, previous in tracks if a < right and b > left))
|
||||
|
||||
available = [y for y in candidates if clear(y)]
|
||||
if not available:
|
||||
raise ValueError(f"No forward reference channel for {source.node.name}")
|
||||
def cost(level):
|
||||
length = abs(level - start[1]) + sum(abs(level - end[1]) for _, end in deliveries)
|
||||
launch, arrivals = path(level)
|
||||
length += max(0, start[0] - launch) * 2
|
||||
ramps = [(start, (launch, level))]
|
||||
ramps += [((x, level), end) for x, _, end in arrivals]
|
||||
footprints = [curve_footprint(a, b) for a, b in ramps]
|
||||
crossings = sum(any(a < right and b > left and bottom < upper and top > lower
|
||||
for left, right, upper, lower in footprint)
|
||||
for node, (a, b, top, bottom) in boxes if node not in endpoints
|
||||
for footprint in footprints)
|
||||
overlaps = sum(min(right, b) - max(left, a) > TRACK_GAP
|
||||
and max(lower, bottom) - min(upper, top) < TRACK_GAP
|
||||
for footprint in footprints for left, right, upper, lower in footprint
|
||||
if upper - lower < (right - left) * 0.25
|
||||
for a, b, top, bottom in wire_corridors
|
||||
if top - bottom < (b - a) * 0.25)
|
||||
return overlaps * 4000 + crossings * 400 + length, abs(level - start[1]), -level
|
||||
|
||||
level = min(available, key=cost)
|
||||
launch, arrivals = path(level)
|
||||
root = route(source, (launch, level))
|
||||
trunk, previous_x = root, launch
|
||||
for x, link, end in sorted(arrivals, key=lambda item: item[0]):
|
||||
if x - previous_x >= PORT_RUN * 2:
|
||||
trunk = route(trunk, (x, level))
|
||||
previous_x = x
|
||||
actual_end = link_endpoints(link, positions, sizes)[1]
|
||||
branch = route(trunk, (end[0] - PORT_RUN, end[1])) if end != actual_end else trunk
|
||||
replace_link_source(node_group, link, branch)
|
||||
tracks.append((min(launch, *(x for x, _, _ in arrivals)),
|
||||
max(launch, *(x for x, _, _ in arrivals)), level))
|
||||
wire_corridors.extend(curve_footprint(start, (launch, level)))
|
||||
for x, _, end in arrivals:
|
||||
wire_corridors.extend(curve_footprint((x, level), end))
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Reserve horizontal space for field calculations beside their geometry consumers."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from .zones import node_zones, repeat_regions
|
||||
|
||||
|
||||
def clear_repeat_regions(node_group, positions, sizes, bodies, *, gap):
|
||||
"""Move unrelated calculation bodies and nodes outside reserved repeat areas."""
|
||||
regions = repeat_regions(node_group, positions, sizes)
|
||||
grouped = set().union(*(members for members, _ in bodies)) if bodies else set()
|
||||
groups = [members for members, _ in bodies] + [{node} for node in positions if node not in grouped]
|
||||
for members in groups:
|
||||
obstacles = [box for owned, box in regions if not members & owned]
|
||||
left = min(positions[node][0] for node in members)
|
||||
right = max(positions[node][0] + sizes[node].width for node in members)
|
||||
top = max(positions[node][1] for node in members)
|
||||
bottom = min(positions[node][1] - sizes[node].height for node in members)
|
||||
if not any(left < b and right > a and top > d and bottom < c for a, b, c, d in obstacles):
|
||||
continue
|
||||
occupied = obstacles + [(x, x + sizes[node].width, y, y - sizes[node].height)
|
||||
for node, (x, y) in positions.items() if node not in members]
|
||||
occupied = [box for box in occupied if left < box[1] + gap and right > box[0] - gap]
|
||||
offsets = {edge for _, _, upper, lower in occupied
|
||||
for edge in (upper + gap - bottom, lower - gap - top)}
|
||||
targets = {link.to_node for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members and link.to_node in positions}
|
||||
above = (right - left < 1000 and len(targets) == 1
|
||||
and bottom > positions[next(iter(targets))][1])
|
||||
offset = min((offset for offset in offsets
|
||||
if all(top + offset <= lower - gap + 1e-5 or bottom + offset >= upper + gap - 1e-5
|
||||
for _, _, upper, lower in occupied)),
|
||||
key=lambda offset: (above and bottom + offset <= positions[next(iter(targets))][1], abs(offset)))
|
||||
for node in members:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x, y + offset)
|
||||
for index, (members, _) in enumerate(bodies):
|
||||
bodies[index] = (members, (min(positions[node][0] for node in members),
|
||||
max(positions[node][0] + sizes[node].width for node in members),
|
||||
max(positions[node][1] for node in members),
|
||||
min(positions[node][1] - sizes[node].height for node in members)))
|
||||
|
||||
|
||||
def enclose_repeat_branches(node_group, positions, sizes, bodies, *, gap):
|
||||
"""Leave real horizontal space between repeat boundaries and their calculations."""
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
continue
|
||||
interior = (members - {entry, end}) & positions.keys()
|
||||
if not interior:
|
||||
continue
|
||||
for boundary, direction in ((entry, -1), (end, 1)):
|
||||
start = positions[boundary][0]
|
||||
target = (min(positions[node][0] for node in interior) - gap - sizes[entry].width
|
||||
if direction < 0 else
|
||||
max(positions[node][0] + sizes[node].width for node in interior) + gap)
|
||||
distance = direction * (target - start)
|
||||
if distance <= 0:
|
||||
continue
|
||||
moving = {node for node, (x, _) in positions.items()
|
||||
if node not in members and direction * (x - start) >= 0} | {boundary}
|
||||
for body, _ in bodies:
|
||||
if body & moving and not body & members:
|
||||
moving.update(body)
|
||||
for node in moving:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x + direction * distance, y)
|
||||
for index, (members, _) in enumerate(bodies):
|
||||
bodies[index] = (members, (min(positions[node][0] for node in members),
|
||||
max(positions[node][0] + sizes[node].width for node in members),
|
||||
max(positions[node][1] for node in members),
|
||||
min(positions[node][1] - sizes[node].height for node in members)))
|
||||
|
||||
|
||||
def reserve_field_sections(node_group, layouts, positions, sizes, *, gap):
|
||||
"""Extend execution stages to make room for substantial incoming calculations."""
|
||||
stages = {node: node for node in positions}
|
||||
for entry, _, members in node_zones(node_group):
|
||||
for node in members & positions.keys():
|
||||
stages[node] = entry
|
||||
sections = defaultdict(list)
|
||||
for region, owners, local, width, height in layouts:
|
||||
if width < 1000 or not owners:
|
||||
continue
|
||||
owner = min(owners, key=lambda node: (positions[node][0], node.name))
|
||||
sections[stages[owner]].append((region, owner, local, width, height))
|
||||
front = max((width for _, _, _, width, _ in layouts), default=0)
|
||||
bands = [min(x for x, _ in positions.values()) - front - gap] * 2
|
||||
for stage in sorted(sections, key=lambda node: (positions[node][0], node.name)):
|
||||
start = positions[stage][0]
|
||||
pending = []
|
||||
for region, owner, local, width, height in sorted(sections[stage], key=lambda item: -item[3]):
|
||||
band = min(range(2), key=lambda index: bands[index])
|
||||
left = bands[band] + gap
|
||||
for node in region:
|
||||
for socket in node.inputs:
|
||||
for link in socket.links:
|
||||
source = link.from_node
|
||||
if source in positions and stages[source] != stage:
|
||||
left = max(left, positions[source][0] + sizes[source].width + gap - local[node][0])
|
||||
offsets = [local[node][1] - sizes[node].sockets[socket]
|
||||
+ sizes[link.to_node].sockets[link.to_socket]
|
||||
+ positions[owner][1] - positions[link.to_node][1]
|
||||
for node in region for socket in node.outputs for link in socket.links
|
||||
if link.to_node in positions]
|
||||
above = max((abs(height + gap + offset) for offset in offsets), default=gap)
|
||||
below = max((abs(-sizes[owner].height - gap + offset) for offset in offsets), default=gap)
|
||||
exit_run = max(gap, min(above, below) * 0.8)
|
||||
finish = max(positions[owner][0] - exit_run, left + width)
|
||||
pending.append((owner, finish + exit_run))
|
||||
bands[band] = finish
|
||||
extra = max(finish - positions[owner][0] for owner, finish in pending)
|
||||
if extra > 0:
|
||||
for node, (x, y) in positions.items():
|
||||
if x >= start:
|
||||
positions[node] = (x + extra, y)
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"GeometryNodeJoinGeometry": {"peer_inputs_from": 0},
|
||||
"GeometryNodeMenuSwitch": {"peer_inputs_from": 1},
|
||||
"GeometryNodeSwitch": {"peer_inputs_from": 1},
|
||||
"ShaderNodeCombineXYZ": {"peer_inputs_from": 0},
|
||||
"FunctionNodeBooleanMath": {
|
||||
"peer_inputs_from": 0,
|
||||
"operations": ["AND", "OR", "NAND", "NOR", "XNOR", "XOR", "IMPLY", "NIMPLY"]
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Place shared calculation sources beside their peripheral distribution channels."""
|
||||
|
||||
|
||||
def place_shared_sources(bodies, peripheral_nodes, positions, sizes, *, gap):
|
||||
"""Place consumer regions first, then keep their producers outside and to the left."""
|
||||
ordinary = [box for members, box in bodies if not members & peripheral_nodes]
|
||||
if not ordinary:
|
||||
ordinary = [(x, x + sizes[node].width, y, y - sizes[node].height)
|
||||
for node, (x, y) in positions.items() if node not in peripheral_nodes]
|
||||
ceiling = max(box[2] for box in ordinary)
|
||||
floor = min(box[3] for box in ordinary)
|
||||
for index, (members, (left, right, top, bottom)) in enumerate(bodies):
|
||||
if not members & peripheral_nodes:
|
||||
continue
|
||||
links = [link for node in members for socket in node.outputs for link in socket.links
|
||||
if link.to_node not in members and link.to_node in positions]
|
||||
if not links:
|
||||
continue
|
||||
height = top - bottom
|
||||
candidates = (ceiling + gap * 2 + height, floor - gap * 2)
|
||||
new_top = min(candidates, key=lambda level: (sum(
|
||||
abs(positions[link.to_node][1] - level + height / 2) for link in links), -level))
|
||||
dy = new_top - top
|
||||
new_left = float("inf")
|
||||
for link in links:
|
||||
source, target = link.from_node, link.to_node
|
||||
source_y = positions[source][1] + dy - sizes[source].sockets[link.from_socket]
|
||||
target_y = positions[target][1] - sizes[target].sockets[link.to_socket]
|
||||
runway = (abs(source_y - target_y) + height * 2) * 0.8 + gap * 3
|
||||
new_left = min(new_left, positions[target][0] - runway
|
||||
- (positions[source][0] - left) - sizes[source].width)
|
||||
dx = new_left - left
|
||||
for node in members:
|
||||
x, y = positions[node]
|
||||
positions[node] = (x + dx, y + dy)
|
||||
bodies[index] = (members, (left + dx, right + dx, top + dy, bottom + dy))
|
||||
ceiling = max(ceiling, top + dy)
|
||||
floor = min(floor, bottom + dy)
|
||||
Reference in New Issue
Block a user