From 09778c4b4808413f615930732ca52356ca7907d0 Mon Sep 17 00:00:00 2001 From: nan Date: Sun, 13 Sep 2026 23:42:12 +0800 Subject: [PATCH] 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. --- .../joint-geometry-node-layout/.openspec.yaml | 2 + .../joint-geometry-node-layout/design.md | 43 + .../joint-geometry-node-layout/proposal.md | 23 + .../specs/joint-geometry-node-layout/spec.md | 45 ++ .../joint-geometry-node-layout/tasks.md | 15 + src/anyimage/assets/O_AnyImage.blend | 4 +- tests/tools/nodes/test_arrangement.py | 26 +- tools/nodes/arrangement/arrange.py | 733 +++--------------- tools/nodes/arrangement/branches.py | 79 ++ tools/nodes/arrangement/connections.py | 21 +- tools/nodes/arrangement/fields.py | 221 ------ tools/nodes/arrangement/flow.py | 63 -- tools/nodes/arrangement/frames.py | 58 -- tools/nodes/arrangement/layout.py | 407 ++++++++++ tools/nodes/arrangement/passages.py | 275 ------- tools/nodes/arrangement/references.py | 185 ----- tools/nodes/arrangement/sections.py | 116 --- tools/nodes/arrangement/semantics.json | 10 - tools/nodes/arrangement/sources.py | 38 - 19 files changed, 727 insertions(+), 1637 deletions(-) create mode 100644 openspec/changes/joint-geometry-node-layout/.openspec.yaml create mode 100644 openspec/changes/joint-geometry-node-layout/design.md create mode 100644 openspec/changes/joint-geometry-node-layout/proposal.md create mode 100644 openspec/changes/joint-geometry-node-layout/specs/joint-geometry-node-layout/spec.md create mode 100644 openspec/changes/joint-geometry-node-layout/tasks.md create mode 100644 tools/nodes/arrangement/branches.py delete mode 100644 tools/nodes/arrangement/fields.py delete mode 100644 tools/nodes/arrangement/frames.py create mode 100644 tools/nodes/arrangement/layout.py delete mode 100644 tools/nodes/arrangement/passages.py delete mode 100644 tools/nodes/arrangement/references.py delete mode 100644 tools/nodes/arrangement/sections.py delete mode 100644 tools/nodes/arrangement/semantics.json delete mode 100644 tools/nodes/arrangement/sources.py diff --git a/openspec/changes/joint-geometry-node-layout/.openspec.yaml b/openspec/changes/joint-geometry-node-layout/.openspec.yaml new file mode 100644 index 0000000..c238415 --- /dev/null +++ b/openspec/changes/joint-geometry-node-layout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/joint-geometry-node-layout/design.md b/openspec/changes/joint-geometry-node-layout/design.md new file mode 100644 index 0000000..4d259ad --- /dev/null +++ b/openspec/changes/joint-geometry-node-layout/design.md @@ -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 进程完成重新加载检查,并对照完整及局部预览。实现与资产作为同一变更交付;需要回退时同步恢复这两部分。 diff --git a/openspec/changes/joint-geometry-node-layout/proposal.md b/openspec/changes/joint-geometry-node-layout/proposal.md new file mode 100644 index 0000000..83a0ee7 --- /dev/null +++ b/openspec/changes/joint-geometry-node-layout/proposal.md @@ -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`。沿用现有构建与预览入口,无新增依赖或节点组公开接口变更。 diff --git a/openspec/changes/joint-geometry-node-layout/specs/joint-geometry-node-layout/spec.md b/openspec/changes/joint-geometry-node-layout/specs/joint-geometry-node-layout/spec.md new file mode 100644 index 0000000..7d0dc32 --- /dev/null +++ b/openspec/changes/joint-geometry-node-layout/specs/joint-geometry-node-layout/spec.md @@ -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 个几何节点组均使用联合规划布局,并通过保存后的资产检查 diff --git a/openspec/changes/joint-geometry-node-layout/tasks.md b/openspec/changes/joint-geometry-node-layout/tasks.md new file mode 100644 index 0000000..049b78f --- /dev/null +++ b/openspec/changes/joint-geometry-node-layout/tasks.md @@ -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 文件。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index cd6c9a4..3ca9bd3 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0c3caa511801ab8da4b132caf48af0403999756609d4ee4c2283362f1360b26 -size 9345087 +oid sha256:d06282be4f11b6b3b31b61120dabf23202281eb936d9807fde515ff7fab29f8d +size 8030938 diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py index 058c437..00d5064 100644 --- a/tests/tools/nodes/test_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -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 diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py index b2a796b..e6f75da 100644 --- a/tools/nodes/arrangement/arrange.py +++ b/tools/nodes/arrangement/arrange.py @@ -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) diff --git a/tools/nodes/arrangement/branches.py b/tools/nodes/arrangement/branches.py new file mode 100644 index 0000000..3fd1686 --- /dev/null +++ b/tools/nodes/arrangement/branches.py @@ -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 diff --git a/tools/nodes/arrangement/connections.py b/tools/nodes/arrangement/connections.py index 484f08f..9d60677 100644 --- a/tools/nodes/arrangement/connections.py +++ b/tools/nodes/arrangement/connections.py @@ -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] diff --git a/tools/nodes/arrangement/fields.py b/tools/nodes/arrangement/fields.py deleted file mode 100644 index 5cf7c72..0000000 --- a/tools/nodes/arrangement/fields.py +++ /dev/null @@ -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) diff --git a/tools/nodes/arrangement/flow.py b/tools/nodes/arrangement/flow.py index e904182..c11acbf 100644 --- a/tools/nodes/arrangement/flow.py +++ b/tools/nodes/arrangement/flow.py @@ -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) diff --git a/tools/nodes/arrangement/frames.py b/tools/nodes/arrangement/frames.py deleted file mode 100644 index cdf0162..0000000 --- a/tools/nodes/arrangement/frames.py +++ /dev/null @@ -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 diff --git a/tools/nodes/arrangement/layout.py b/tools/nodes/arrangement/layout.py new file mode 100644 index 0000000..bc8f048 --- /dev/null +++ b/tools/nodes/arrangement/layout.py @@ -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 diff --git a/tools/nodes/arrangement/passages.py b/tools/nodes/arrangement/passages.py deleted file mode 100644 index e962fa2..0000000 --- a/tools/nodes/arrangement/passages.py +++ /dev/null @@ -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 diff --git a/tools/nodes/arrangement/references.py b/tools/nodes/arrangement/references.py deleted file mode 100644 index d945668..0000000 --- a/tools/nodes/arrangement/references.py +++ /dev/null @@ -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)) diff --git a/tools/nodes/arrangement/sections.py b/tools/nodes/arrangement/sections.py deleted file mode 100644 index b445bbf..0000000 --- a/tools/nodes/arrangement/sections.py +++ /dev/null @@ -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) diff --git a/tools/nodes/arrangement/semantics.json b/tools/nodes/arrangement/semantics.json deleted file mode 100644 index f37dab2..0000000 --- a/tools/nodes/arrangement/semantics.json +++ /dev/null @@ -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"] - } -} diff --git a/tools/nodes/arrangement/sources.py b/tools/nodes/arrangement/sources.py deleted file mode 100644 index a10f691..0000000 --- a/tools/nodes/arrangement/sources.py +++ /dev/null @@ -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)