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:
2026-09-13 23:42:12 +08:00
parent 1bf858b17e
commit 09778c4b48
19 changed files with 727 additions and 1637 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13

View File

@@ -0,0 +1,43 @@
## Context
本变更整理本轮已经选定并实现的排列方案。用户确认的基准是“结构与通道联合规划”版本:支线呈现横向计算层次,主线随计算块自然起伏,长连接使用共享通道。
布局由构建工具生成,保存于 `O_AnyImage.blend`。运行时沿用资产加载方式。当前实现已经覆盖资产中的 7 个几何节点组。
## Goals / Non-Goals
**Goals:**
- 通过计算块与连接通道联合规划保持整体秩序和可追溯性。
- 让全部几何节点组使用同一个通用排列入口。
- 保持节点运算、接口、真实连接、几何结果及重复排列行为正确。
## Decisions
### 按最近的几何消费者识别支线
`branches.py` 从依赖图逆向计算字段节点的消费者集合,将集合相同且相连的字段组成计算块。几何处理节点和成对区域入口参与主结构规划。Group Input 按计算块或直接消费节点局部读取,减少跨区域参数线。
### 列内排列与整体通道规划共同形成结果
`layout.py` 依据依赖关系建立列,将跨列连接的中继位置作为空间参与者;通过相邻 socket 的位置关系确定列内顺序和间距。计算块在相同列坐标上整理内部层次,再与共享长线的水平通道一起形成区间约束,求出整体纵向位置。
这一方案保留选定版本的横向阅读习惯。交叉数不是硬性验收指标;画面评估关注是否能区分各条连接及支线归属。
### 规划完成后统一写入 Blender
`arrange.py` 负责清除已有布局容器与中继、准备局部输入、调用规划器,然后创建位置、共享 Reroute 和复杂支线 Frame。替换连线复用 `common.nodes.replace_link_source`,保留多输入 socket 的连接顺序。旧排列器失去调用入口的模块随替换移除。
### 通过现有构建入口发布资产
`tools/nodes/build.py` 对每个 GeometryNodeTree 调用排列器。测试按 `check.py` 中的几何资产清单参数化,覆盖排列前后程序与几何比较、节点矩形重叠、重复排列后的连接及局部输入数量。独立 Blender 进程重新加载保存资产,验证资产完整性和几何求值。
## Risks / Trade-offs
- 长垂线及跨越其他 Frame 的连接仍可能存在 → 保留通道分组和局部预览,以实际追线效果评估后续调整。
- 复杂支线仍可能横向较宽 → 本轮以用户确认的整体秩序为基准,结合主线和支线的全局画面检查。
- 当前节点逻辑与历史试排的图结构不同 → 使用同一规则重新计算当前图,以当前资产的功能一致性验证结果。
## Migration Plan
通用实现替换后构建资产,在独立 Blender 进程完成重新加载检查,并对照完整及局部预览。实现与资产作为同一变更交付;需要回退时同步恢复这两部分。

View File

@@ -0,0 +1,23 @@
## Why
几何主线、计算支线和长连接分阶段调整时,容易造成横向拉长、支线归属不清及连线密集。将已选定的“结构与通道联合规划”方案统一用于全部几何节点资产,保持横向计算层次、清楚的分块和自然起伏的主线。
## What Changes
- 根据最近的几何消费者识别相连的计算支线,按支线局部读取接口值。
- 将计算块与共享连接通道共同纳入空间规划,再创建节点位置、Reroute 和 Frame。
- 保留选定版本的横向层次与共享长连接通道,以实际可追溯性评估画面。
- 用通用排列器替换原有逐层补救流程,应用于全部 7 个几何节点组。
- 验证排列前后运算、接口、连接及几何结果一致,并更新保存资产。
## Capabilities
### New Capabilities
- `joint-geometry-node-layout`: 联合规划几何节点的计算块与连接通道,并统一构建、验证资产布局。
### Modified Capabilities
## Impact
涉及 `tools/nodes/arrangement/`、`tests/tools/nodes/test_arrangement.py` 和 `src/anyimage/assets/O_AnyImage.blend`。沿用现有构建与预览入口,无新增依赖或节点组公开接口变更。

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: 按消费者形成计算块
排列器 SHALL 根据最近的几何消费者集合及字段连通关系划分计算块,并为不同计算块或直接消费节点提供局部接口读取。
#### Scenario: 一组字段服务于相同几何计算
- **WHEN** 相连字段节点具有相同的最近几何消费者集合
- **THEN** 它们作为一个计算块参与排列,复杂计算块用 Frame 表达归属
#### Scenario: 接口值被多个计算区域使用
- **WHEN** 同一接口输出被多个计算区域消费
- **THEN** 各区域通过局部 Group Input 读取相同接口值,接口定义与默认值保持一致
### Requirement: 联合规划计算块与连接通道
排列器 SHALL 在创建最终位置与 Reroute 前,将计算块及共享长连接通道纳入整体空间约束,保留横向计算层次及可区分的块间距。
#### Scenario: 支线之间存在跨列共享连接
- **WHEN** 某个输出跨越多个列连接到其他计算块
- **THEN** 其长连接通道与计算块共同参与空间规划,并根据规划结果创建共享中继
#### Scenario: 几何主线与大型支线共同排列
- **WHEN** 几何处理节点消费大型字段计算块
- **THEN** 两者根据依赖和连接位置共同确定纵向关系,真实节点矩形互不重叠
### Requirement: 布局保持节点行为
排列器 SHALL 保持运算设置、输入默认值、接口、成对区域关系以及去除 Reroute 后的实际连接和多输入顺序;排列前后的代表性几何求值 SHALL 一致。
#### Scenario: 对几何资产执行排列
- **WHEN** 保存排列前后的程序结构及代表性模式的求值结果
- **THEN** 程序结构一致,顶点坐标在测试容差内相等,面连接完全相同
#### Scenario: 再次排列已排列节点组
- **WHEN** 对同一节点组再次执行排列
- **THEN** 实际连接和接口保持一致,局部 Group Input 数量保持稳定
### Requirement: 统一更新全部几何资产
构建工具 SHALL 使用同一排列入口处理资产清单中的全部 GeometryNodeTree,并保存、重新加载及验证更新后的资产。
#### Scenario: 构建节点资产库
- **WHEN** 执行节点资产构建
- **THEN** Image Plane、Image Depth Plane、Image Relief Plane、Image Cutout、Image Depth Cutout、Image Depth Balloon、Image Depth Panorama 共 7 个几何节点组均使用联合规划布局,并通过保存后的资产检查

View File

@@ -0,0 +1,15 @@
## 1. 联合排列实现
- [x] 1.1 按最近几何消费者集合及连通关系识别计算块,并按消费区域局部读取接口值。
- [x] 1.2 实现列内计算层次、中继空间及计算块与共享通道的联合规划。
- [x] 1.3 在规划完成后创建位置、Reroute 和 Frame,保留真实连接与多输入顺序。
- [x] 1.4 替换原排列入口,移除失去用途的旧排列模块及引用。
## 2. 资产与验证
- [x] 2.1 将排列回归扩展到全部 7 个几何资产,比较程序结构及代表性模式的几何结果。
- [x] 2.2 检查真实节点矩形重叠及重复排列后的程序、局部输入数量。
- [x] 2.3 构建并保存 O_AnyImage.blend,在独立 Blender 进程重新加载检查。
- [x] 2.4 导出全部几何资产的交互预览,对照选定风格检查 Cutout、Depth Plane 和 Panorama 的完整布局。
验证记录:上一轮使用 `uv run --no-sync node-group build --skip-tests` 完成构建及保存后检查;在 Blender 4.5.10 LTS 中运行排列与 Image Plane 相关测试,11 项通过。常规 `uv run` 同步遇到占用文件后,使用既有环境构建,并从同版本缓存恢复缺失的 bpy 文件。

Binary file not shown.

View File

@@ -5,18 +5,16 @@ import numpy as np
import pytest
from tools.nodes.groups.image_plane import build_image_plane_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_cutout import build_image_cutout_group
from tools.nodes.build import build_node_groups
from tools.nodes.check import GEOMETRY_NAMES
from tools.nodes.arrangement.arrange import arrange_nodes
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
@pytest.fixture(params=("depth_plane", "cutout"))
@pytest.fixture(params=sorted(GEOMETRY_NAMES))
def node_group(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
if request.param == "depth_plane":
return build_image_depth_plane_group(build_image_plane_group())
return build_image_cutout_group()
return next(group for group in build_node_groups() if group.name == request.param)
def _source(socket):
@@ -130,3 +128,17 @@ def test_arrangement_preserves_program_and_geometry(node_group):
for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()):
np.testing.assert_allclose(after_points, before_points, atol=1e-6)
assert after_faces == before_faces
display_nodes = [node for node in node_group.nodes if node.bl_idname not in {"NodeFrame", "NodeReroute"}]
boxes = []
for node in display_nodes:
size = estimate_node_dimensions(node)
x, y = node.location_absolute
box = (x, y - size.height, x + size.width, y)
assert all(not (box[0] < other[2] and box[2] > other[0]
and box[1] < other[3] and box[3] > other[1]) for other in boxes), node.name
boxes.append(box)
input_count = sum(node.bl_idname == "NodeGroupInput" for node in display_nodes)
arrange_nodes(node_group)
assert _program(node_group) == program
assert sum(node.bl_idname == "NodeGroupInput" for node in node_group.nodes) == input_count

View File

@@ -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)

View 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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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"]
}
}

View File

@@ -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)