Implement arrangement framework for node groups

- Introduced `frames.py` to manage field complex calculations and their arrangement within node groups.
- Added functionality to create and remove field frames based on node arrangements.
- Developed `passages.py` to find unobstructed paths for connections around calculation frames.
- Implemented `references.py` to route shared results on peripheral tracks, ensuring efficient distribution of outputs.
- Created `sections.py` to reserve space for field calculations adjacent to geometry consumers.
- Added `sources.py` to position shared calculation sources effectively beside their distribution channels.
- Enhanced outline smoothing with `outline_smoothing.py` to adapt camera depth along original outlines.
This commit is contained in:
2026-09-12 12:19:07 +08:00
parent dd32ccdc44
commit 5b2dc59f79
57 changed files with 2253 additions and 700 deletions

View File

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

View File

@@ -0,0 +1,21 @@
## Context
排列器以几何主链、取值支线及共享引用组织节点。循环目前额外计算支线宽度并预留两端间距。
## Goals / Non-Goals
目标是让循环执行步骤紧凑可读,并保持前置几何处理的横向先后关系。
## Decisions
- 预览层从完整区域成员中筛选几何执行主链,取值计算复用支线排列流程。
- 横向列依赖保留实际几何来源,局部引用端口保持连接顺序。
- 使用真实 Depth Cutout 和小型循环验证布局、连接及几何求值。
- 简单计算链在有限高度内纵向分段,复杂支线按消费者形成独立 Frame。
- 支线按实际消费输入与连通依赖分组,计算流程保持连续展开。
- 几何引用线同时处理向前与回向连接,水平通道统一避让节点、支线及已有通道。
- 引用线的高差通过有横向展开的斜向曲线过渡。
## Risks / Trade-offs
支线引用循环数据时可能需要回向连线;通过分离主链通道和支线区域保证可读性,并验证几何连接语义保持一致。

View File

@@ -0,0 +1,22 @@
## Why
Repeat 为取值支线预留宽度,导致单个 Set Position 的循环被拉长,前置循环的处理顺序也难以辨认。
## What Changes
- 按循环执行主链排列 Repeat 两端,取值支线独立排列。
- 几何处理节点按真实上游依赖确定先后位置。
- 更新排列测试、节点资产和交互预览。
- 将长计算链紧凑排列,为复杂支线添加 Frame,并分离几何引用线通道。
## Capabilities
### New Capabilities
- `compact-repeat-arrangement`: 紧凑呈现循环执行主链并保持几何处理顺序。
### Modified Capabilities
## Impact
涉及 `tools/nodes/arrangement`、`tools/nodes/preview`、相关测试和 `O_AnyImage.blend`。

View File

@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: Compact repeat execution path
排列器 SHALL 按循环执行的几何步骤安排 Repeat 两端,取值支线独立排列。
#### Scenario: Set Position loop with long value calculations
- **WHEN** 循环只执行 Set Position,输入值来自长计算链
- **THEN** Repeat 两端保持围绕 Set Position 的紧凑排列,预览执行区域按几何主链确定
### Requirement: Geometry processing order
排列器 SHALL 将前置循环安排在后续几何处理之前。
#### Scenario: Depth Cutout boundary smoothing
- **WHEN** 排列 O Image Depth Cutout 的前置边界平滑循环
- **THEN** 循环位于其后续几何消费者左侧,保持原有连接与求值结果
### Requirement: Readable field branches and geometry references
排列器 SHALL 将取值支线紧凑安排在消费者附近,复杂支线使用互不重叠的 Frame,并分离几何引用的水平通道。
#### Scenario: Long calculation chain
- **WHEN** 消费者使用较长的简单计算链
- **THEN** 计算链使用紧凑折叠及分段纵向布局
- **AND** 连续依赖按计算方向展开,独立计算按实际消费输入分组
#### Scenario: Complex depth cutout branches
- **WHEN** 排列 O Image Depth Cutout
- **THEN** 复杂计算支线分别成框,几何引用线保持独立的水平通道,并通过斜向曲线过渡,原有几何求值保持一致
#### Scenario: Geometry trunk alongside its consumer frame
- **WHEN** 几何主干向 Frame 内的计算节点供值
- **THEN** 主干与完整 Frame 边界保持间距,通过斜向分支接入框内消费者
#### Scenario: Long branch near its geometry consumer
- **WHEN** 较大支线需要占用来源和消费者之间的横向空间
- **THEN** 主线在消费者之前预留支线及斜向出口的空间,连续主线保持直接向前连接
#### Scenario: Independent calculation before a geometry consumer
- **WHEN** 支线独立于上游几何结果
- **THEN** 支线可向主线起点之前展开,主线按实际出口位置保持紧凑
#### Scenario: Return connection and neighboring reference tracks
- **WHEN** 几何结果连接左侧消费者
- **THEN** 连线直接向消费者方向过渡,各水平通道按最终范围保留间距
#### Scenario: Geometry fanout without overlapping wires
- **WHEN** 同一几何结果供多个节点消费
- **THEN** 近处消费者直接接入,公共水平通道按从左到右分发,每段公共连线只绘制一次
- **AND** 水平连线保持向前,短 reroute 间距合并,近乎平行的曲线与主线保持间距
#### Scenario: Shared results outside calculation branches
- **WHEN** 几何或其他类型的结果供多个支线消费
- **THEN** 共享结果沿支线外围的公共管道分发,在消费者附近斜向接入
- **AND** 原有主线连接保持执行顺序,公共管道与节点、Frame 及其他管道保留间距
#### Scenario: Geometry transits an unrelated calculation
- **WHEN** 几何连接沿途经过其他支线
- **THEN** 整段曲线绕开不消费该结果的 Frame,仅允许进入自身来源或消费者所属的 Frame
#### Scenario: Peripheral producers feed their shared pipes forward
- **WHEN** 支线计算的结果通过公共管道供多个消费者使用
- **THEN** 共享计算支线与管道一同位于消费者外围,管道入口在结果输出节点右侧

View File

@@ -0,0 +1,46 @@
## 1. 排列
- [x] 1.1 分离循环执行主链与取值支线,修正前置循环位置
- [x] 1.2 更新预览区域和回归测试
## 2. 验证
- [x] 2.1 运行节点资产构建和全量测试
- [x] 2.2 生成并检查交互预览
## 3. 支线与引用
- [x] 3.1 缩短支线,支持简单计算链纵向排列并靠近消费者
- [x] 3.2 为复杂支线生成独立 Frame,并同步预览与坐标处理
- [x] 3.3 统一几何引用线避让,分离水平通道和回向连线
- [x] 3.4 更新回归测试、构建资产并完成全量及视觉验证
## 4. 主干与 Frame 间距
- [x] 4.1 为几何主干保留整个 Frame 的边界间距,覆盖相连消费者的 Frame
- [x] 4.2 验证回归用例、重建资产并更新预览
## 5. 支线消费位置
- [x] 5.1 按较大支线及斜向出口所需空间延长主线,按出口距离选择支线位置
- [x] 5.2 保留直接向前的主线连接,减少几何引用的外围折返
- [x] 5.3 验证连接、求值与布局,重建资产并更新预览
## 6. 收紧主线与分离回向连接
- [x] 6.1 去掉回向连接在来源右侧的多余折返点
- [x] 6.2 按实际出口估算主线间距,独立计算可在起点之前展开
- [x] 6.3 按最终线段范围分配水平通道并扩大不同来源的间距
- [x] 6.4 总线按从左到右分发,近处消费者直接接入,分离近乎平行的曲线并检查同源绿线重复覆盖
- [x] 6.5 重建资产、检查截图区域并完成全量验证
## 7. 共享结果外围管道
- [x] 7.1 合并几何与其他类型的共享引用布线,共用结果沿支线外围就近分发
- [x] 7.2 绿线绕开不消费该结果的 Frame,并验证实际曲线范围
- [x] 7.3 验证主线保留、外围间距与连接语义,重建资产并检查预览
## 8. 共享来源与管道入口
- [x] 8.1 将共享结果的计算支线安排在消费者外围,管道从输出右侧开始
- [x] 8.2 验证入口方向、支线位置及求值,重建资产并更新预览

View File

@@ -46,7 +46,7 @@ flowchart TD
| 节点组 | 深度 Blur 影响边界 | 标量与重建方式 | | 节点组 | 深度 Blur 影响边界 | 标量与重建方式 |
| --- | --- | --- | | --- | --- | --- |
| O Image Depth Cutout | 原 Alpha 轮廓及 Split 后的新边界 | 平滑相机 Z;按新旧 Z 比例缩放原相机点,再进入现有投影 | | O Image Depth Cutout | 原 Alpha 轮廓及内侧两圈,保护 Split 边 | 按当前残差分批自适应平滑相机 Z;按新旧 Z 比例缩放原相机点,再进入现有投影 |
| O Image Depth Plane | 有效性剔除及 Split 后的新边界;保护 UV 原矩形外框 | 平滑相机 Z;按原相机射线重建点,再进入现有投影 | | O Image Depth Plane | 有效性剔除及 Split 后的新边界;保护 UV 原矩形外框 | 平滑相机 Z;按原相机射线重建点,再进入现有投影 |
| O Image Depth Panorama | 球面有效性剔除及 Split 后产生的实际开口 | 平滑有效径向距离;保持球面方向,再进入 Depth Scale 的半径表达式 | | O Image Depth Panorama | 球面有效性剔除及 Split 后产生的实际开口 | 平滑有效径向距离;保持球面方向,再进入 Depth Scale 的半径表达式 |
@@ -58,6 +58,8 @@ Panorama 的经度接缝和极点依照真实球面拓扑求值;UV 接缝本
### 节点与资产组织 ### 节点与资产组织
Depth Cutout 的自适应实现位于 `common/outline_smoothing.py`。公开 `Outline Depth Fix`(默认 16,0–200),每轮重新计算当前相机深度残差,`Boundary Smooth` 提供该轮固定权重 Blur 的步数。残差换算至模型单位后除以置换前、Split 清理后全部边长的中位数,固定通过 0.25–0.5 Smoothstep 得到权重,再乘原轮廓两圈影响范围;不对 Weight 做 Blur。批内 Weight 在 Repeat 输入几何上显式采样,原轮廓对应关系在平面源几何上求值。复用现有切分属性保护 Split 边及交点,不新增命名属性。Cutout 资产及运行时请求版本更新为 21。
公共函数以标量字段为复用边界,三个消费组分别完成相机与球面重建。使用显式域转换及明确源几何采样,所有几何组与嵌套组保持 Capture Attribute 为零。直接连接可复用字段;必要的现有临时命名协议在最后消费者后清理,保留用户属性。 公共函数以标量字段为复用边界,三个消费组分别完成相机与球面重建。使用显式域转换及明确源几何采样,所有几何组与嵌套组保持 Capture Attribute 为零。直接连接可复用字段;必要的现有临时命名协议在最后消费者后清理,保留用户属性。
实施时读取当前工作区基线,保留其他修改。完成源码与测试后运行 `uv run node-group build`,更新 `O_AnyImage.blend` 并独立加载验证接口、求值和节点布局。 实施时读取当前工作区基线,保留其他修改。完成源码与测试后运行 `uv run node-group build`,更新 `O_AnyImage.blend` 并独立加载验证接口、求值和节点布局。

View File

@@ -7,7 +7,7 @@ Alpha 轮廓和有效性剔除留下的网格边界仍可能采到突变深度
- 为 `O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` 统一加入置换前的边界深度平滑。 - 为 `O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` 统一加入置换前的边界深度平滑。
- 复用 `Boundary Smooth` 控制深度 Blur 与现有边界网格平滑的迭代数,保留默认值 `5`、范围 `0–20`;`0` 完全关闭两者。 - 复用 `Boundary Smooth` 控制深度 Blur 与现有边界网格平滑的迭代数,保留默认值 `5`、范围 `0–20`;`0` 完全关闭两者。
- 在有效性剔除及 Split 后的连通网格上平滑深度标量,仅应用于可处理边界及邻近两圈,并保持各顶点原投影方向。 - 在有效性剔除及 Split 后的连通网格上平滑深度标量,仅应用于可处理边界及邻近两圈,并保持各顶点原投影方向。
- Cutout 的原 Alpha 轮廓参与深度平滑;Depth Plane 保留原矩形外框保护;Panorama 按球面径向距离处理实际网格边界。 - Cutout 的原 Alpha 轮廓参与分批自适应深度平滑,Outline Depth Fix 控制批次数、Boundary Smooth 控制每批步数,Ratio 固定 0.25–0.5,保护 Split 切边;Depth Plane 保留原矩形外框保护;Panorama 按球面径向距离处理实际网格边界。
- 复用公共构建函数,补齐行为测试、全景专项验证与节点资产重建。 - 复用公共构建函数,补齐行为测试、全景专项验证与节点资产重建。
## Capabilities ## Capabilities

View File

@@ -2,12 +2,12 @@
### Requirement: Shared Boundary Smooth control ### Requirement: Shared Boundary Smooth control
`O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` SHALL 使用同一 `Boundary Smooth` 输入控制边界深度 Blur 和现有边界网格平滑的迭代数,默认值为 5,范围为 0–20,并保留该输入的既有标识。 `O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` SHALL 保留 `Boundary Smooth` 输入,Depth Cutout 默认值为 2,另外两组为 5;Cutout 范围为 1–20,另外两组为 0–20。Depth Cutout 使用它控制每轮深度 Blur 的步数和现有边界网格平滑次数;另外两组使用它控制深度 Blur 与网格平滑次数。
#### Scenario: Positive iteration count #### Scenario: Positive iteration count
- **WHEN** 用户将任一组的 Boundary Smooth 设为 12 - **WHEN** 用户将任一组的 Boundary Smooth 设为 12
- **THEN** 深度 Blur 和现有边界网格平滑均使用 12 次迭代 - **THEN** 每批深度 Blur 和现有边界网格平滑均使用 12 次迭代
- **AND** 修改器只提供现有 Boundary Smooth 控制项 - **AND** Depth Cutout 的批次数由 Outline Depth Fix 控制
#### Scenario: Disabled smoothing #### Scenario: Disabled smoothing
- **WHEN** Boundary Smooth 为 0 - **WHEN** Boundary Smooth 为 0
@@ -16,7 +16,7 @@
### Requirement: Topology-local scalar smoothing ### Requirement: Topology-local scalar smoothing
系统 SHALL 在有效性剔除及 Split 后、投影前的网格 Point 域对深度标量执行 Weight 为 0.5 的 Blur,并在可处理边界及邻近两圈内应用结果。边界影响权重 SHALL 由边界字段的 2 次 Weight 为 1 的 Blur 与原边界字段取最大值后,排除保护点得到。 系统 SHALL 在有效性剔除及 Split 后、投影前的网格 Point 域对深度标量执行 Blur,并在可处理边界及邻近两圈内应用结果。Depth Plane 和 Panorama 的 Blur Weight 为 0.5;Depth Cutout 使用当前残差计算的自适应权重。边界影响范围 SHALL 由边界字段的 2 次 Weight 为 1 的 Blur 与原边界字段取最大值后,排除保护点得到。
#### Scenario: Isolated boundary spike #### Scenario: Isolated boundary spike
- **WHEN** 原本平缓的表面在一个可处理边界点具有深度尖刺并启用 Boundary Smooth - **WHEN** 原本平缓的表面在一个可处理边界点具有深度尖刺并启用 Boundary Smooth
@@ -33,7 +33,18 @@
### Requirement: Cutout silhouette depth smoothing ### Requirement: Cutout silhouette depth smoothing
Depth Cutout SHALL 将原 Alpha 轮廓和 Split 后的新边界纳入深度平滑,并保留现有后置网格平滑的原轮廓保护规则。 Depth Cutout SHALL 仅将原 Alpha 轮廓及内侧两圈纳入自适应深度平滑,保护 Split 切边及其与原轮廓的交点。后置网格平滑保持既有职责。对外轮廓术语 SHALL 统一使用 Outline,与 Fine Outline 一致。
#### Scenario: Adaptive batches
- **WHEN** Outline Depth Fix 大于 0 且 Boundary Smooth 大于 0
- **THEN** 每轮根据当前相机深度与 Weight 0.5 的一步 Blur 之差,乘 Uniform Scale 后除以置换前、Split 清理后所有网格边长的中位数
- **AND** Ratio 固定以 0.25–0.5 的 Smoothstep 映射到 0–1,并乘轮廓影响范围
- **AND** Weight 不再 Blur,批内固定使用 Boundary Smooth 次,每轮重新计算
- **AND** Outline Depth Fix 默认 16,范围 0–200;0 关闭轮廓深度处理
#### Scenario: Split boundary preservation
- **WHEN** Split 在原轮廓内形成新的切边
- **THEN** 深度平滑保持这些切边的相机采样不变,包括与原轮廓相交的切分点
#### Scenario: Fine Outline with zero thickness #### Scenario: Fine Outline with zero thickness
- **WHEN** Fine Outline 的原轮廓存在异常深度,Thickness 为 0 且 Boundary Smooth 大于 0 - **WHEN** Fine Outline 的原轮廓存在异常深度,Thickness 为 0 且 Boundary Smooth 大于 0
@@ -88,3 +99,21 @@ Cutout 和 Depth Plane SHALL 在深度平滑阶段保持原相机射线,通过
- **WHEN** 执行节点资产构建并在独立 Blender 进程中重新加载保存资产 - **WHEN** 执行节点资产构建并在独立 Blender 进程中重新加载保存资产
- **THEN** 资产与源码构建结果通过相同的行为验证 - **THEN** 资产与源码构建结果通过相同的行为验证
- **AND** 公共参数、节点连接和 Capture 禁用检查通过 - **AND** 公共参数、节点连接和 Capture 禁用检查通过
### Requirement: Simplified Cutout shaping controls
Depth Cutout SHALL 固定前后法线平滑为 100 次,原联动宽范围滤波固定为 512 次;移除 Normal Smooth 输入。系统 SHALL 删除 Edge Round 输入及对应前表面位移、厚度补偿和圆化字段,保留 Split 基础收口。
#### Scenario: Reduced interface
- **WHEN** 新建 Depth Cutout
- **THEN** Outline Depth Fix 默认 16、Boundary Smooth 默认 2
- **AND** 输入中没有 Edge Round、Normal Smooth 或 Outline Smooth Iterations
### Requirement: Cutout Options order and inflation factor
Depth Cutout 的 Options SHALL 按 Cleanup Threshold、Smooth Weight、Reference Depth、Boundary Smooth、Outline Depth Fix、Front Inflation 排列。Front Inflation SHALL 为 0–1 Factor、默认 0;Boundary Smooth SHALL 为 1–20、默认 2。
#### Scenario: New Cutout defaults
- **WHEN** 新建 Depth Cutout
- **THEN** Front Inflation 为 0,Boundary Smooth 不能从界面设为小于 1
- **AND** Outline Depth Fix 保持默认 16

View File

@@ -26,3 +26,29 @@
- [x] 5.1 运行 uv run node-group build,更新 O_AnyImage.blend 和必要资产版本标识,在独立 Blender 进程中加载并验证。 - [x] 5.1 运行 uv run node-group build,更新 O_AnyImage.blend 和必要资产版本标识,在独立 Blender 进程中加载并验证。
- [x] 5.2 运行相关节点及 Operator 测试,确认正常退出;核对源码与资产的一致性、最终差异和旧引用,保留已有其他修改。 - [x] 5.2 运行相关节点及 Operator 测试,确认正常退出;核对源码与资产的一致性、最终差异和旧引用,保留已有其他修改。
## 6. Cutout 轮廓自适应深度平滑
- [x] 6.1 固定 Ratio 0.25–0.5,按当前深度分批更新权重,删除 Weight Blur,保护 Split 切边并沿原相机射线重建。
- [x] 6.2 公开 Outline Smooth Iterations,Boundary Smooth 兼作每批深度步数,更新资产版本及行为测试。
- [x] 6.3 构建验证节点资产,运行相关回归并保存可调实图文件。
## 7. Cutout 输入与轮廓形状精简
- [x] 7.1 固定法线平滑 100、Boundary Smooth 默认 2,Outline Depth Fix 默认 16,彻底删除 Edge Round 形状调整与输入。
- [x] 7.2 更新相关行为测试和版本标识,构建验证资产并刷新可调样例。
## 8. Cutout Options 接口
- [x] 8.1 Front Inflation 改为 0–1 Factor、默认 0;Boundary Smooth 最小值 1;Options 按 Cleanup Threshold、Smooth Weight、Reference Depth、Boundary Smooth、Outline Depth Fix、Front Inflation 排列。
- [x] 8.2 更新接口与默认行为测试、重建验证资产并刷新可调样例。
## 9. Plane 与 Balloon Options 统一
- [x] 9.1 Depth Balloon 前三项为 Cleanup Threshold、Smooth Weight、Reference Depth;Depth Plane 前三项为 Smooth Weight、Reference Depth、Boundary Smooth,Boundary Smooth 默认 2,法线平滑固定 100 并移除输入。
- [x] 9.2 Depth Plane Split Threshold 默认 0.1;验证接口及厚度行为,更新版本并重建验证资产。
## 10. Cutout 求值精简
- [x] 10.1 Front Inflation 为零时旁路膨胀计算,移除共用平滑中的固定选择节点和未使用 Repeat 通道,统一 outline 平滑内部命名。
- [x] 10.2 验证零值及连续膨胀行为、共用平滑结果与性能,重建验证资产。

Binary file not shown.

View File

@@ -1,7 +1,6 @@
"""Apply packed panorama artifacts around the source Empty's camera origin.""" """Apply packed panorama artifacts around the source Empty's camera origin."""
import json import json
import math
from pathlib import Path from pathlib import Path
import bpy import bpy
@@ -18,15 +17,13 @@ def load_panorama_metadata(path):
metadata = json.loads(Path(path).read_text(encoding="utf-8")) metadata = json.loads(Path(path).read_text(encoding="utf-8"))
try: try:
size = tuple(int(value) for value in metadata["image_size"]) size = tuple(int(value) for value in metadata["image_size"])
distance = float(metadata["reference_distance"])
valid = (metadata["projection"] == "equirectangular" and len(size) == 2 valid = (metadata["projection"] == "equirectangular" and len(size) == 2
and size[1] > 0 and size[0] == 2 * size[1] and size[1] > 0 and size[0] == 2 * size[1])
and math.isfinite(distance) and distance > 0)
except (KeyError, TypeError, ValueError) as error: except (KeyError, TypeError, ValueError) as error:
raise ValueError("Panorama metadata is invalid") from error raise ValueError("Panorama metadata is invalid") from error
if not valid: if not valid:
raise ValueError("Panorama metadata is invalid") raise ValueError("Panorama metadata is invalid")
return {"image_size": size, "reference_distance": distance} return {"image_size": size}
def create_panorama_from_result(context, result, operator): def create_panorama_from_result(context, result, operator):
@@ -35,7 +32,7 @@ def create_panorama_from_result(context, result, operator):
try: try:
with material_color_image(input_path=operator.color_path) as color: with material_color_image(input_path=operator.color_path) as color:
metadata = load_panorama_metadata(result.file("depth_metadata")) metadata = load_panorama_metadata(result.file("depth_metadata"))
group = load_node_group("O Image Depth Panorama", asset_revision=14) group = load_node_group("O Image Depth Panorama", asset_revision=15)
depth = load_depth_result_image(result.file("depth"), source, metadata) depth = load_depth_result_image(result.file("depth"), source, metadata)
material = create_image_material(source.data, color, shadeless=True, scene=context.scene, material = create_image_material(source.data, color, shadeless=True, scene=context.scene,
texture_extension="REPEAT") texture_extension="REPEAT")
@@ -52,7 +49,6 @@ def create_panorama_from_result(context, result, operator):
modifier.show_group_selector = False modifier.show_group_selector = False
for name, value in ( for name, value in (
("Subdivide", operator.mesh_detail), ("Depth Image", depth), ("Subdivide", operator.mesh_detail), ("Depth Image", depth),
("Reference Distance", metadata["reference_distance"]),
): ):
set_modifier_input(modifier, modifier_input_identifier(group, name), value) set_modifier_input(modifier, modifier_input_identifier(group, name), value)
finalize_object_result(context, source, obj) finalize_object_result(context, source, obj)

View File

@@ -62,7 +62,7 @@ def create_depth_plane_object(
node_group = load_node_group({ node_group = load_node_group({
"DEPTH": "O Image Depth Plane", "DEPTH": "O Image Depth Plane",
"RELIEF": "O Image Relief Plane", "RELIEF": "O Image Relief Plane",
}[plane_type], asset_revision=14 if plane_type == "DEPTH" else None) }[plane_type], asset_revision=16 if plane_type == "DEPTH" else None)
source_name = source_object.name source_name = source_object.name
bounds = image_empty_bounds(source_object) bounds = image_empty_bounds(source_object)
mesh = create_image_plane_mesh(source_name, bounds) mesh = create_image_plane_mesh(source_name, bounds)

View File

@@ -21,11 +21,11 @@ class SelectionContours:
) )
image_size = tuple(int(value) for value in self.image_size) image_size = tuple(int(value) for value in self.image_size)
if not components or any(not component for component in components): if not components or any(not component for component in components):
raise ValueError("The Selection has no usable contour components") raise ValueError("The Selection has no usable outline components")
if any(len(contour) < 3 for component in components for contour in component): if any(len(contour) < 3 for component in components for contour in component):
raise ValueError("A Selection contour has fewer than three points") raise ValueError("A Selection outline has fewer than three points")
if len(image_size) != 2 or min(image_size) <= 0: if len(image_size) != 2 or min(image_size) <= 0:
raise ValueError("The Selection contour image size is invalid") raise ValueError("The Selection outline image size is invalid")
object.__setattr__(self, "components", components) object.__setattr__(self, "components", components)
object.__setattr__(self, "image_size", image_size) object.__setattr__(self, "image_size", image_size)

View File

@@ -99,7 +99,7 @@ def _assign_float_point_attribute(mesh, name, values):
def load_cutout_node_group(shape): def load_cutout_node_group(shape):
return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=18 if shape == "DEPTH_SOLID" else 11) return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=23 if shape == "DEPTH_SOLID" else 12 if shape == "DEPTH_BALLOON" else 11)
def _connect_cutout_normal_scale(material): def _connect_cutout_normal_scale(material):

View File

@@ -209,7 +209,7 @@ def cutout_generate_normal_property():
def cutout_alpha_threshold_property(): def cutout_alpha_threshold_property():
return bpy.props.FloatProperty( return bpy.props.FloatProperty(
name="Alpha Threshold", name="Alpha Threshold",
description="Minimum Alpha kept in the Cutout contour", description="Minimum Alpha kept in the Cutout outline",
default=0.9, default=0.9,
min=0.0, min=0.0,
max=1.0, max=1.0,

View File

@@ -61,8 +61,8 @@ def run(context, parameters):
alpha = np.asarray(source.getchannel("A"), alpha = np.asarray(source.getchannel("A"),
dtype=np.float32) / 255.0 dtype=np.float32) / 255.0
alpha *= valid alpha *= valid
reference = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0) valid_depth = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0)
if not reference.any(): if not valid_depth.any():
raise ValueError("The panorama contains no valid depth pixels") raise ValueError("The panorama contains no valid depth pixels")
if not np.isfinite(distance).all() or (distance <= 0).any(): if not np.isfinite(distance).all() or (distance <= 0).any():
raise ValueError("Panorama fusion produced invalid distances") raise ValueError("Panorama fusion produced invalid distances")
@@ -71,7 +71,6 @@ def run(context, parameters):
write_float_exr(rgba, outputs[0]) write_float_exr(rgba, outputs[0])
outputs[1].write_text(json.dumps({ outputs[1].write_text(json.dumps({
"projection": "equirectangular", "image_size": [width, height], "projection": "equirectangular", "image_size": [width, height],
"reference_distance": float(np.median(distance[reference])),
}, separators=(",", ":")), encoding="utf-8") }, separators=(",", ":")), encoding="utf-8")
context.check_cancelled() context.check_cancelled()
context.progress(1.0, "Panorama conversion data is ready") context.progress(1.0, "Panorama conversion data is ready")

View File

@@ -31,7 +31,7 @@ def test_depth_cutout_loads_boundary_blur_revision_and_preserves_existing_group(
saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree") saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree")
saved["anyimage_asset_revision"] = 11 saved["anyimage_asset_revision"] = 11
current = load_cutout_node_group("DEPTH_SOLID") current = load_cutout_node_group("DEPTH_SOLID")
assert current != saved and current["anyimage_asset_revision"] == 18 assert current != saved and current["anyimage_asset_revision"] == 23
assert saved in bpy.data.node_groups.values() assert saved in bpy.data.node_groups.values()
assert saved["anyimage_asset_revision"] == 11 assert saved["anyimage_asset_revision"] == 11
assert load_cutout_node_group("DEPTH_SOLID") == current assert load_cutout_node_group("DEPTH_SOLID") == current

View File

@@ -47,7 +47,7 @@ def test_cutout_node_groups_are_loaded_from_the_asset():
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES: for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES:
assert cutout_object.load_cutout_node_group(shape) is loaded assert cutout_object.load_cutout_node_group(shape) is loaded
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape],
asset_revision=18 if shape == "DEPTH_SOLID" else 11) asset_revision=23 if shape == "DEPTH_SOLID" else 12 if shape == "DEPTH_BALLOON" else 11)
assert load.call_count == 4 assert load.call_count == 4

View File

@@ -188,7 +188,7 @@ def test_cutout_presets_load_three_direct_modifier_assets():
assert inputs["Smooth"].default_value == 0 assert inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1 assert inputs["Smooth Weight"].default_value == 1
if shape == "DEPTH_SOLID": if shape == "DEPTH_SOLID":
assert inputs["Boundary Smooth"].default_value == 5 assert inputs["Boundary Smooth"].default_value == 2
assert inputs["Boundary Smooth"].parent.name == "Options" assert inputs["Boundary Smooth"].parent.name == "Options"
else: else:
assert "Boundary Smooth" not in inputs assert "Boundary Smooth" not in inputs

View File

@@ -70,7 +70,7 @@ def artifacts(tmp_path, source):
rgba = np.ones((16, 32, 4), np.float32) rgba = np.ones((16, 32, 4), np.float32)
rgba[..., :3] = 3 rgba[..., :3] = 3
write_float_exr(rgba, tmp_path / "depth.exr") write_float_exr(rgba, tmp_path / "depth.exr")
(tmp_path / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [32, 16], "reference_distance": 3})) (tmp_path / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [32, 16]}))
result = SimpleNamespace(directory=tmp_path, file=lambda key: tmp_path / {"depth": "depth.exr", "depth_metadata": "depth.json"}[key]) result = SimpleNamespace(directory=tmp_path, file=lambda key: tmp_path / {"depth": "depth.exr", "depth_metadata": "depth.json"}[key])
operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()), operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
image_identity=str(source.data.as_pointer()), mesh_detail=2, image_identity=str(source.data.as_pointer()), mesh_detail=2,
@@ -90,7 +90,7 @@ def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo)))) monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo))))
obj = conversion.create_panorama_from_result(bpy.context, result, operator) obj = conversion.create_panorama_from_result(bpy.context, result, operator)
assert obj.modifiers[0].node_group != saved assert obj.modifiers[0].node_group != saved
assert obj.modifiers[0].node_group["anyimage_asset_revision"] == 14 assert obj.modifiers[0].node_group["anyimage_asset_revision"] == 15
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11 assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
assert obj.name == operator.source_object_name and obj.type == "MESH" assert obj.name == operator.source_object_name and obj.type == "MESH"
np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6) np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6)
@@ -162,7 +162,7 @@ def test_failed_response_keeps_source_and_cleans_owned_data(source, artifacts, m
elif failure == "replaced_image": elif failure == "replaced_image":
operator.image_identity = "replaced" operator.image_identity = "replaced"
else: else:
(result.directory / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [16, 8], "reference_distance": 3})) (result.directory / "depth.json").write_text(json.dumps({"projection": "equirectangular", "image_size": [16, 8]}))
with pytest.raises((RuntimeError, ValueError)): with pytest.raises((RuntimeError, ValueError)):
conversion.create_panorama_from_result(bpy.context, result, operator) conversion.create_panorama_from_result(bpy.context, result, operator)
for name, values in before.items(): for name, values in before.items():

View File

@@ -151,7 +151,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE" assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0] depth_modifier = depth_plane.modifiers[0]
assert depth_modifier.node_group != saved assert depth_modifier.node_group != saved
assert depth_modifier.node_group["anyimage_asset_revision"] == 14 assert depth_modifier.node_group["anyimage_asset_revision"] == 16
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11 assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
uniform_scale = depth_modifier[ uniform_scale = depth_modifier[
object_data.modifier_input_identifier( object_data.modifier_input_identifier(

View File

@@ -89,7 +89,7 @@ def test_job_outputs_geometry_combines_alpha_once_and_skips_transparent_views(pa
metadata = json.loads((context.directory / result["depth_metadata"]).read_text()) metadata = json.loads((context.directory / result["depth_metadata"]).read_text())
assert metadata["projection"] == "equirectangular" assert metadata["projection"] == "equirectangular"
assert metadata["image_size"] == [64, 32] assert metadata["image_size"] == [64, 32]
assert metadata["reference_distance"] == pytest.approx(3, rel=1e-5) assert set(metadata) == {"projection", "image_size"}
def test_full_resolution_fusion_preserves_detail_and_periodic_alignment(): def test_full_resolution_fusion_preserves_detail_and_periodic_alignment():

View File

@@ -61,7 +61,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
for name, value in ( for name, value in (
("Depth Image", image), ("Depth Image", image),
("Uniform Scale", size), ("Uniform Scale", size),
("Reference Depth", size), ("Reference Depth", size * (1 + step / 2)),
("Depth Scale", 1.0), ("Depth Scale", 1.0),
("Thickness", 0.0), ("Thickness", 0.0),
("Front Inflation", 0.0), ("Front Inflation", 0.0),

View File

@@ -65,6 +65,8 @@ def create_surface(source, plane_type):
set_value(name, value) set_value(name, value)
if "Boundary Smooth" in inputs: if "Boundary Smooth" in inputs:
set_value("Boundary Smooth", 0) set_value("Boundary Smooth", 0)
if "Split Threshold" in inputs:
set_value("Split Threshold", 0.0)
return obj, set_value, plane, inputs, image return obj, set_value, plane, inputs, image

View File

@@ -44,7 +44,7 @@ def _value(value):
def _program(node_group): def _program(node_group):
nodes = {} nodes = {}
for node in node_group.nodes: for node in node_group.nodes:
if node.bl_idname in {"NodeReroute", "NodeGroupInput"}: if node.bl_idname in {"NodeReroute", "NodeGroupInput", "NodeFrame"}:
continue continue
settings = { settings = {
name: _value(getattr(node, name)) name: _value(getattr(node, name))
@@ -129,6 +129,8 @@ def _surface(node_group):
def test_arrangement_preserves_each_asset_program(node_group): def test_arrangement_preserves_each_asset_program(node_group):
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
program = _program(node_group) program = _program(node_group)
evaluate = _surface(node_group) evaluate = _surface(node_group)
geometry = evaluate() geometry = evaluate()
@@ -138,27 +140,67 @@ def test_arrangement_preserves_each_asset_program(node_group):
for node in node_group.nodes: for node in node_group.nodes:
if node.bl_idname == "NodeGroupInput": if node.bl_idname == "NodeGroupInput":
targets = {link.to_node for socket in node.outputs for link in socket.links} targets = {link.to_node for socket in node.outputs for link in socket.links}
assert len(targets) == 1 while ports := {target for target in targets if target.bl_idname == "NodeReroute"}:
targets.difference_update(ports)
targets.update(link.to_node for port in ports for link in port.outputs[0].links)
assert len(targets) == 1, node.name
assert all(socket.hide for socket in node.outputs if not socket.is_linked) assert all(socket.hide for socket in node.outputs if not socket.is_linked)
for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()): for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()):
np.testing.assert_allclose(after_points, before_points, atol=1e-6) np.testing.assert_allclose(after_points, before_points, atol=1e-6)
assert after_faces == before_faces assert after_faces == before_faces
positions = { positions = {
node.name: tuple(node.location) for node in node_group.nodes node.name: tuple(node.location_absolute) for node in node_group.nodes
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"} if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}
} }
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
assert {name: tuple(node_group.nodes[name].location) for name in positions} == positions assert {name: tuple(node_group.nodes[name].location_absolute) for name in positions} == positions
locations = {node: tuple(node.location_absolute) for node in node_group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
tracks = []
for link in node_group.links:
if (link.from_socket.type != "GEOMETRY" and link.from_node.bl_idname != "NodeReroute"
and link.to_node.get("arrangement_reference")):
start, end = link_endpoints(link, locations, sizes)
assert end[0] >= start[0], (link.from_node.name, link.to_node.name)
if link.from_socket.type != "GEOMETRY":
continue
start, end = link_endpoints(link, locations, sizes)
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)
allowed = {origin_socket(link.from_socket).node.parent, *(node.parent for node in consumers)}
footprint = curve_footprint(start, end)
for frame in node_group.nodes:
if frame.bl_idname != "NodeFrame" or frame in allowed:
continue
x, y = locations[frame]
assert not any(left < x + frame.width - 0.01 and right > x + 0.01
and top > y - frame.height + 0.01 and bottom < y - 0.01
for left, right, top, bottom in footprint), (link.from_node.name, link.to_node.name, frame.name)
if abs(start[1] - end[1]) < 0.01:
assert end[0] - start[0] >= 20, (link.from_node.name, link.to_node.name)
tracks.append((start, end))
for index, (start, end) in enumerate(tracks):
for other_start, other_end in tracks[index + 1:]:
assert (abs(start[1] - other_start[1]) >= 1
or min(end[0], other_end[0]) - max(start[0], other_start[0]) <= 1)
for node in node_group.nodes: for node in node_group.nodes:
sources = [link.from_socket for link in peer_links(node)] sources = [link.from_socket for link in peer_links(node)]
if len(sources) < 2: if len(sources) < 2:
continue continue
levels = [socket.node.location.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources] # Measure branch geometry rather than obstacle-avoidance waypoints.
sources = [origin_socket(socket) if socket.node.get("arrangement_reference") else socket for socket in sources]
levels = [socket.node.location_absolute.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources]
size = estimate_node_dimensions(node) size = estimate_node_dimensions(node)
output = next(socket for socket in node.outputs if socket in size.sockets) output = next(socket for socket in node.outputs if socket in size.sockets)
assert node.location.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name assert node.location_absolute.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name
assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name
@@ -227,12 +269,12 @@ def test_geometry_paths_order_join_branches_and_separate_their_shared_input(bran
program = _program(node_group) program = _program(node_group)
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
levels = [link.from_node.location.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket] levels = [link.from_node.location_absolute.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket]
for link in peer_links(join)] for link in peer_links(join)]
assert levels == sorted(levels, reverse=True) assert levels == sorted(levels, reverse=True)
if branch_lengths == (1, 2, 3): if branch_lengths == (1, 2, 3):
heights = {node: node.location.y for node in geometry_parents} heights = {node: node.location_absolute.y for node in geometry_parents}
field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"] field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"]
calculations = [field.node] calculations = [field.node]
for _ in range(30): for _ in range(30):
@@ -245,10 +287,24 @@ def test_geometry_paths_order_join_branches_and_separate_their_shared_input(bran
program = _program(node_group) program = _program(node_group)
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
assert {node: node.location.y for node in geometry_parents} == heights assert {node: node.location_absolute.y for node in geometry_parents} == heights
assert max(node.location.y for node in calculations) - min( assert max(node.location_absolute.y for node in calculations) - min(
node.location.y - estimate_node_dimensions(node).height for node in calculations node.location_absolute.y - estimate_node_dimensions(node).height for node in calculations
) <= FIELD_HEIGHT ) <= FIELD_HEIGHT * 3
assert max(node.location_absolute.x + node.width for node in calculations) - min(
node.location_absolute.x for node in calculations
) < 4000
frames = {node.parent for node in calculations}
assert len(frames) == 1
frame = frames.pop()
assert frame is not None and frame.bl_idname == "NodeFrame"
from tools.nodes.preview.export import _node_group_data
data = _node_group_data(node_group)
record = next(item for item in data["frames"] if item["title"] == frame.label)
for index in record["nodes"]:
node = data["nodes"][index]
assert record["x"] <= node["x"] < node["x"] + node["w"] <= record["x"] + record["w"] + 0.01
assert record["y"] <= node["y"] < node["y"] + node["h"] <= record["y"] + record["h"] + 0.01
def test_geometry_used_for_field_sampling_stays_with_the_field_branch(): def test_geometry_used_for_field_sampling_stays_with_the_field_branch():
@@ -304,10 +360,10 @@ def test_shared_field_references_branch_near_their_consumers():
for node in consumers[1:]: for node in consumers[1:]:
branch = node.inputs["Offset"].links[0].from_node branch = node.inputs["Offset"].links[0].from_node
assert branch.bl_idname == "NodeReroute" assert branch.bl_idname == "NodeReroute"
assert node.location.x - branch.location.x >= ROUTE_RUN assert node.location_absolute.x - branch.location_absolute.x >= ROUTE_RUN
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
for node in node_group.nodes) for node in node_group.nodes)
assert all(link.to_node.location.x > link.from_node.location.x assert all(link.to_node.location_absolute.x > link.from_node.location_absolute.x
for link in node_group.links for link in node_group.links
if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute") if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute")
@@ -332,9 +388,9 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
node_group.links.new(geometry, join.inputs["Geometry"]) node_group.links.new(geometry, join.inputs["Geometry"])
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
arrange_nodes(node_group) arrange_nodes(node_group)
branches.sort(key=lambda branch: branch[0].location.y, reverse=True) branches.sort(key=lambda branch: branch[0].location_absolute.y, reverse=True)
lane = branches[0 if side == "upper" else -1] lane = branches[0 if side == "upper" else -1]
heights = [node.location.y for branch in branches for node in branch] heights = [node.location_absolute.y for branch in branches for node in branch]
fields = [] fields = []
for target in (lane[1], lane[3]): for target in (lane[1], lane[3]):
value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0] value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0]
@@ -350,20 +406,20 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
program = _program(node_group) program = _program(node_group)
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
assert [node.location.y for branch in branches for node in branch] == heights assert [node.location_absolute.y for branch in branches for node in branch] == heights
boxes = [(min(node.location.x for node in branch), boxes = [(min(node.location_absolute.x for node in branch),
max(node.location.x + estimate_node_dimensions(node).width for node in branch), max(node.location_absolute.x + estimate_node_dimensions(node).width for node in branch),
max(node.location.y for node in branch), max(node.location_absolute.y for node in branch),
min(node.location.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields] min(node.location_absolute.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
core, _ = geometry_paths(node_group) core, _ = geometry_paths(node_group)
core_left = min(node.location.x for node in core) core_left = min(node.location_absolute.x for node in core)
for _, right, top, bottom in boxes: for _, right, top, bottom in boxes:
if right + BRANCH_GAP <= core_left + 0.001: if right + BRANCH_GAP <= core_left + 0.001:
continue continue
if side == "upper": if side == "upper":
assert bottom >= lane[0].location.y + BRANCH_GAP - 0.001 assert bottom >= lane[0].location_absolute.y + BRANCH_GAP - 0.001
else: else:
assert top <= lane[0].location.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001 assert top <= lane[0].location_absolute.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001
first, second = boxes first, second = boxes
assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001 assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001
or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001) or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001)
@@ -371,12 +427,12 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes) assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes)
for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)): for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)):
terminal = branch[-1] terminal = branch[-1]
assert terminal.location.x + terminal.width < target.location.x assert terminal.location_absolute.x + terminal.width < target.location_absolute.x
socket = terminal.outputs["Vector"] socket = terminal.outputs["Vector"]
x1 = terminal.location.x + terminal.width x1 = terminal.location_absolute.x + terminal.width
y1 = terminal.location.y - estimate_node_dimensions(terminal).sockets[socket] y1 = terminal.location_absolute.y - estimate_node_dimensions(terminal).sockets[socket]
x2 = target.location.x x2 = target.location_absolute.x
y2 = target.location.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]] y2 = target.location_absolute.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
bend = max(40, (x2 - x1) * 0.42) bend = max(40, (x2 - x1) * 0.42)
t = np.linspace(0, 1, 200) t = np.linspace(0, 1, 200)
x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2 x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2
@@ -401,10 +457,10 @@ def test_boolean_operands_follow_socket_order_and_center_the_junction():
program = _program(node_group) program = _program(node_group)
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
levels = [node.location.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons] levels = [node.location_absolute.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons]
assert levels[0] > levels[1] assert levels[0] > levels[1]
assert comparisons[0].location.x + comparisons[0].width == comparisons[1].location.x + comparisons[1].width assert comparisons[0].location_absolute.x + comparisons[0].width == comparisons[1].location_absolute.x + comparisons[1].width
assert operation.location.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2) assert operation.location_absolute.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2)
def test_preview_is_read_only_and_escapes_node_names(tmp_path): def test_preview_is_read_only_and_escapes_node_names(tmp_path):
@@ -415,7 +471,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
arrange_nodes(node_group) arrange_nodes(node_group)
node_group.nodes[0].name = '</script><script>alert("node")</script>' node_group.nodes[0].name = '</script><script>alert("node")</script>'
program = _program(node_group) program = _program(node_group)
positions = {node.name: tuple(node.location) for node in node_group.nodes} positions = {node.name: tuple(node.location_absolute) for node in node_group.nodes}
destination = write_node_preview([node_group], tmp_path / "nodes.html") destination = write_node_preview([node_group], tmp_path / "nodes.html")
markup = destination.read_text(encoding="utf-8") markup = destination.read_text(encoding="utf-8")
assert markup.startswith("<!doctype html>") assert markup.startswith("<!doctype html>")
@@ -423,7 +479,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
assert '<script>alert("node")</script>' not in markup assert '<script>alert("node")</script>' not in markup
assert '\\u003c/script>' in markup assert '\\u003c/script>' in markup
assert _program(node_group) == program assert _program(node_group) == program
assert {node.name: tuple(node.location) for node in node_group.nodes} == positions assert {node.name: tuple(node.location_absolute) for node in node_group.nodes} == positions
def test_geometry_bypass_has_two_level_corners_next_to_processing(): def test_geometry_bypass_has_two_level_corners_next_to_processing():
@@ -444,14 +500,14 @@ def test_geometry_bypass_has_two_level_corners_next_to_processing():
exit_port = switch.inputs["False"].links[0].from_node exit_port = switch.inputs["False"].links[0].from_node
entry_port = exit_port.inputs[0].links[0].from_node entry_port = exit_port.inputs[0].links[0].from_node
assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute" assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute"
assert entry_port.location.y == exit_port.location.y assert entry_port.location_absolute.y == exit_port.location_absolute.y
assert source.location.x + source.width < entry_port.location.x < exit_port.location.x < switch.location.x assert source.location_absolute.x + source.width < entry_port.location_absolute.x < exit_port.location_absolute.x < switch.location_absolute.x
assert exit_port.location.x + 16 == pytest.approx(transform.location.x + transform.width) assert exit_port.location_absolute.x + 16 == pytest.approx(transform.location_absolute.x + transform.width)
assert _program(node_group) == program assert _program(node_group) == program
@pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"]) @pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"])
def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_type): def test_preview_zone_includes_repeat_value_branch_with_side_clearance(zone_type):
from tools.nodes.preview.export import _node_group_data from tools.nodes.preview.export import _node_group_data
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -466,11 +522,25 @@ def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_ty
node_group.links.new(geometry, body.inputs["Geometry"]) node_group.links.new(geometry, body.inputs["Geometry"])
geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY") geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY")
node_group.links.new(body.outputs["Geometry"], geometry_input) node_group.links.new(body.outputs["Geometry"], geometry_input)
entry.location, body.location, end.location = (0, 0), (300, 0), (700, 0) sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
node_group.links.new(geometry, sample.inputs["Geometry"])
node_group.links.new(sample.outputs["Value"], body.inputs["Position"])
sample.location_absolute = (-1200, -400)
entry.location_absolute, body.location_absolute, end.location_absolute = (0, 0), (300, 0), (700, 0)
if zone_type == "Repeat":
entry.location_absolute = (-1600, 0)
data = _node_group_data(node_group) data = _node_group_data(node_group)
zone = data["zones"][0] zone = data["zones"][0]
names = {data["nodes"][index]["name"] for index in zone["nodes"]} names = {data["nodes"][index]["name"] for index in zone["nodes"]}
assert names == {entry.name, body.name, end.name} expected = {entry.name, body.name, end.name}
if zone_type == "Repeat":
expected.add(sample.name)
assert names == expected
padding = 60 if zone_type == "Repeat" else 40
boxes = [data["nodes"][index] for index in zone["nodes"]]
assert min(node["x"] for node in boxes) - zone["x"] == padding
assert zone["x"] + zone["w"] - max(node["x"] + node["w"] for node in boxes) == padding
for index in zone["nodes"]: for index in zone["nodes"]:
node = data["nodes"][index] node = data["nodes"][index]
assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"] assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"]
@@ -487,9 +557,108 @@ def test_repeat_geometry_collector_stays_in_the_local_body():
end = entry.paired_output end = entry.paired_output
collector = node_group.nodes["Join Geometry.001"] collector = node_group.nodes["Join Geometry.001"]
assert collector in next(members for start, _, members in node_zones(node_group) if start == entry) assert collector in next(members for start, _, members in node_zones(node_group) if start == entry)
assert entry.location.x < collector.location.x < end.location.x assert entry.location_absolute.x < collector.location_absolute.x < end.location_absolute.x
assert abs(collector.location.y - entry.location.y) <= FIELD_HEIGHT + BRANCH_GAP assert abs(collector.location_absolute.y - entry.location_absolute.y) <= FIELD_HEIGHT + BRANCH_GAP
assert entry.location.y == end.location.y assert entry.location_absolute.y == end.location_absolute.y
@pytest.mark.parametrize("length", [1, 20])
def test_repeat_places_value_nodes_between_its_boundaries(length):
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Compact Repeat", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
entry = node_group.nodes.new("GeometryNodeRepeatInput")
end = node_group.nodes.new("GeometryNodeRepeatOutput")
entry.pair_with_output(end)
body = node_group.nodes.new("GeometryNodeSetPosition")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"])
node_group.links.new(entry.outputs["Geometry"], body.inputs["Geometry"])
node_group.links.new(body.outputs["Geometry"], end.inputs["Geometry"])
node_group.links.new(end.outputs["Geometry"], output.inputs["Geometry"])
sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
node_group.links.new(entry.outputs["Geometry"], sample.inputs["Geometry"])
value = sample.outputs["Value"]
for _ in range(length):
operation = node_group.nodes.new("ShaderNodeVectorMath")
operation.operation = "NORMALIZE"
node_group.links.new(value, operation.inputs[0])
value = operation.outputs[0]
node_group.links.new(value, body.inputs["Position"])
program = _program(node_group)
arrange_nodes(node_group)
from tools.nodes.arrangement.zones import node_zones
members = next(members for start, _, members in node_zones(node_group) if start == entry)
for node in members - {entry, end}:
if node.bl_idname == "NodeReroute":
continue
assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99
# The exit also reserves the short run needed by its lower geometry input.
assert 100 <= end.location_absolute.x - body.location_absolute.x - body.width <= 200
assert entry.location_absolute.y == body.location_absolute.y == end.location_absolute.y
assert _program(node_group) == program
def test_depth_cutout_repeat_precedes_downstream_geometry_through_shared_fields():
from tools.nodes.arrangement.flow import graph
from tools.nodes.arrangement.zones import node_zones
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
arrange_nodes(node_group)
core, _ = geometry_paths(node_group)
_, _, _, ancestors = graph(node_group)
entries = [node for node in core if node.bl_idname == "GeometryNodeRepeatInput"]
assert entries
for entry, end, members in node_zones(node_group):
for node in members - {entry, end}:
if node.bl_idname == "NodeReroute":
assert entry.location_absolute.x <= node.location_absolute.x, node.name
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x + end.width, node.name
continue
assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99, node.name
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99, node.name
spans = []
for entry in entries:
end = entry.paired_output
body = origin_socket(end.inputs["Geometry"].links[0].from_socket).node
assert body.bl_idname == "GeometryNodeSetPosition"
spans.append(end.location_absolute.x - entry.location_absolute.x)
assert spans[-1] > 1000
value = origin_socket(body.inputs["Position"].links[0].from_socket).node
assert 100 <= body.location_absolute.x - value.location_absolute.x - estimate_node_dimensions(value).width <= 1000
for node in core:
if end in ancestors[node]:
assert end.location_absolute.x + end.width < node.location_absolute.x, node.name
assert max(spans) > 1000
from tools.nodes.arrangement.zones import repeat_regions
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import _intersects
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 members, box in repeat_regions(node_group, positions, sizes):
left, right, top, bottom = box
for node in node_group.nodes:
if node in members or node.bl_idname == "NodeFrame":
continue
x, y = positions[node]
assert x + sizes[node].width <= left or x >= right or y <= bottom or y - sizes[node].height >= top, node.name
for link in node_group.links:
source = origin_socket(link.from_socket).node
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)
if source not in members and not consumers & members:
assert not _intersects(*link_endpoints(link, positions, sizes), [box]), (source.name, link.to_node.name)
def test_shared_geometry_uses_one_forward_trunk_with_late_branches(): def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
@@ -516,7 +685,7 @@ def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
for sample in samplers[1:]: for sample in samplers[1:]:
branch = sample.inputs["Geometry"].links[0].from_node branch = sample.inputs["Geometry"].links[0].from_node
assert branch.bl_idname == "NodeReroute" assert branch.bl_idname == "NodeReroute"
assert branch.location.x > shared.location.x + (sample.location.x - shared.location.x) / 2 assert branch.location_absolute.x > shared.location_absolute.x + (sample.location_absolute.x - shared.location_absolute.x) / 2
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1 assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
for node in node_group.nodes if node.inputs and node.inputs[0].is_linked for node in node_group.nodes if node.inputs and node.inputs[0].is_linked
and origin_socket(node.inputs[0].links[0].from_socket).node == shared) and origin_socket(node.inputs[0].links[0].from_socket).node == shared)
@@ -545,7 +714,7 @@ def test_reused_geometry_processing_keeps_its_upstream_result_path():
arrange_nodes(node_group) arrange_nodes(node_group)
roles = node_roles(node_group) roles = node_roles(node_group)
assert all(roles[node] == "geometry" for node in (grid, transform, position, output)) assert all(roles[node] == "geometry" for node in (grid, transform, position, output))
assert grid.location.y == transform.location.y == position.location.y == output.location.y assert grid.location_absolute.y == transform.location_absolute.y == position.location_absolute.y == output.location_absolute.y
assert all(roles[node] == "field" for node in node_group.nodes assert all(roles[node] == "field" for node in node_group.nodes
if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"}) if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"})
@@ -574,59 +743,54 @@ def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input
roles = node_roles(node_group) roles = node_roles(node_group)
assert roles[grid] == roles[prepare] == "field" assert roles[grid] == roles[prepare] == "field"
assert all(roles[node] == "geometry" for node in branches) assert all(roles[node] == "geometry" for node in branches)
assert prepare.location.x + prepare.width < min(node.location.x for node in branches) assert prepare.location_absolute.x + prepare.width < min(node.location_absolute.x for node in branches)
def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends(): def test_shared_source_moves_towards_consumers_when_space_is_available():
from tools.nodes.arrangement.sources import place_shared_sources
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Shared Source Placement", "GeometryNodeTree")
source = group.nodes.new("ShaderNodeMath")
target = group.nodes.new("ShaderNodeMath")
group.links.new(source.outputs[0], target.inputs[0])
positions = {source: (-10000, 0), target: (2000, 0)}
sizes = {node: estimate_node_dimensions(node) for node in positions}
bodies = [({node}, (x, x + sizes[node].width, y, y - sizes[node].height))
for node, (x, y) in positions.items()]
place_shared_sources(bodies, {source}, positions, sizes, gap=160)
assert 0 < positions[source][0] < positions[target][0] - sizes[source].width
def test_repeat_value_branches_leave_geometry_corridors_clear():
from tools.nodes.arrangement.connections import link_endpoints from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.zones import node_zones
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = CUTOUT_BUILDERS["SOLID"]() node_group = CUTOUT_BUILDERS["SOLID"]()
arrange_nodes(node_group) arrange_nodes(node_group)
core, _ = geometry_paths(node_group) core, _ = geometry_paths(node_group)
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
positions = {node: tuple(node.location) for node in node_group.nodes} positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
checked = 0 frames = [node for node in node_group.nodes if node.bl_idname == "NodeFrame"]
for _, _, members in node_zones(node_group): for index, frame in enumerate(frames):
for link in node_group.links: left, top = positions[frame]
if link.from_node not in members or link.to_node not in members: for other in frames[index + 1:]:
continue x, y = positions[other]
if (link.from_node in core) == (link.to_node in core): assert (left + frame.width <= x or x + other.width <= left
continue or top - frame.height >= y or y - other.height >= top)
start, end = link_endpoints(link, positions, sizes)
assert end[0] - start[0] >= abs(end[1] - start[1]) * 0.8 - 0.01, (link.from_node.name, link.to_node.name)
checked += 1
assert checked > 0
entry = node_group.nodes["Repeat Input.001"]
end = entry.paired_output
assert end.location.x - entry.location.x < 6500
source = entry.outputs["Geometry"]
assert len(source.links) == 1
branch = source.links[0].to_node
while branch.bl_idname == "NodeReroute" and len(branch.outputs[0].links) == 1:
branch = branch.outputs[0].links[0].to_node
assert branch.bl_idname == "NodeReroute"
assert len(branch.outputs[0].links) > 1
deliveries = [link for link in branch.outputs[0].links
if link.to_node not in core and link.to_node.bl_idname != "NodeReroute"]
assert deliveries
for link in deliveries:
start, target = link_endpoints(link, positions, sizes)
assert target[0] - start[0] == pytest.approx(max(ROUTE_RUN, abs(target[1] - start[1]) * 0.8), abs=0.01)
for port in core: for port in core:
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY": if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
continue continue
source = origin_socket(port.outputs[0]).node source = origin_socket(port.outputs[0]).node
if source not in core: if source not in core:
continue continue
left = source.location.x + sizes[source].width left = source.location_absolute.x + sizes[source].width
right, level = port.location.x + 8, port.location.y - 8 right, level = port.location_absolute.x + 8, port.location_absolute.y - 8
for node in node_group.nodes: for node in node_group.nodes:
if node in core or node.bl_idname == "NodeReroute": if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"}:
continue continue
if left < node.location.x + sizes[node].width and node.location.x < right: if left < node.location_absolute.x + sizes[node].width and node.location_absolute.x < right:
assert not node.location.y - sizes[node].height < level < node.location.y, node.name assert not node.location_absolute.y - sizes[node].height < level < node.location_absolute.y, node.name
for link in node_group.links: for link in node_group.links:
if link.from_socket.type != "GEOMETRY": if link.from_socket.type != "GEOMETRY":
continue continue
@@ -634,10 +798,10 @@ def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
if abs(start[1] - target[1]) > 0.01: if abs(start[1] - target[1]) > 0.01:
continue continue
for node in node_group.nodes: for node in node_group.nodes:
if node in core or node.bl_idname == "NodeReroute" or node in {link.from_node, link.to_node}: if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"} or node in {link.from_node, link.to_node}:
continue continue
if start[0] < node.location.x + sizes[node].width and node.location.x < target[0]: if start[0] < node.location_absolute.x + sizes[node].width and node.location_absolute.x < target[0]:
assert not node.location.y - sizes[node].height < start[1] < node.location.y, node.name assert not node.location_absolute.y - sizes[node].height < start[1] < node.location_absolute.y, node.name
def test_expression_skip_links_have_passages_between_intermediate_nodes(): def test_expression_skip_links_have_passages_between_intermediate_nodes():
@@ -652,7 +816,7 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
seed = node_group.nodes.new("GeometryNodeInputIndex") seed = node_group.nodes.new("GeometryNodeInputIndex")
previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0] previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0]
fields = [seed, previous.node] fields = [seed, previous.node]
for _ in range(7): for _ in range(24):
operation = node_group.nodes.new("ShaderNodeMath") operation = node_group.nodes.new("ShaderNodeMath")
operation.operation = "ADD" operation.operation = "ADD"
node_group.links.new(previous, operation.inputs[0]) node_group.links.new(previous, operation.inputs[0])
@@ -665,8 +829,11 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
program = _program(node_group) program = _program(node_group)
arrange_nodes(node_group) arrange_nodes(node_group)
assert _program(node_group) == program assert _program(node_group) == program
operations = [node for node in fields if node.bl_idname == "ShaderNodeMath"]
assert all(first.location_absolute.x < last.location_absolute.x
for first, last in zip(operations, operations[1:]))
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes} sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
positions = {node: tuple(node.location) for node in node_group.nodes} positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
assert any(node.get("arrangement_branch") for node in node_group.nodes) assert any(node.get("arrangement_branch") for node in node_group.nodes)
for link in node_group.links: for link in node_group.links:
if origin_socket(link.from_socket).node not in fields: if origin_socket(link.from_socket).node not in fields:
@@ -681,3 +848,443 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
continue continue
left, top = positions[node] left, top = positions[node]
assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name
@pytest.mark.parametrize("offset", [260, 3500])
def test_geometry_reference_tracks_have_slanted_entry_and_exit(offset):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import TRACK_GAP, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Slanted References", "GeometryNodeTree")
for index in range(2):
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (index * offset, index * 3200 if offset > 260 else 0)
for x, y in ((1800, 900), (2600, -900)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x + index * offset, y - (index * 3000 if offset > 260 else 0))
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
tracks = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
dx, dy = end[0] - start[0], end[1] - start[1]
if abs(dy) > 100:
assert abs(dx) >= abs(dy) * 0.79
elif abs(dy) < 0.01 and abs(dx) > 100:
assert dx > 0
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1], origin_socket(link.from_socket)))
assert tracks
for index, (left, right, y, source) in enumerate(tracks):
for a, b, level, other in tracks[index + 1:]:
if source != other and left < b and a < right:
assert abs(y - level) >= TRACK_GAP - 0.01
@pytest.mark.parametrize("source_x", [0, 3000])
def test_geometry_reference_trunk_never_retraces_itself(source_x):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Direct Return", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (source_x, 0)
for x, y in ((0, -800), (600, -1000)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x, y)
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
corners = [node for node in group.nodes if node.bl_idname == "NodeReroute"]
assert corners
if source_x:
assert all(node.location_absolute.x < source.location_absolute.x for node in corners)
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
segments = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01:
assert end[0] >= start[0]
segments.append((start, end))
for index, (start, end) in enumerate(segments):
for other_start, other_end in segments[index + 1:]:
assert (abs(start[1] - other_start[1]) >= 0.01
or min(end[0], other_end[0]) <= max(start[0], other_start[0]))
def test_return_curve_stays_apart_from_the_nearly_horizontal_main_path():
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import TRACK_GAP, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Parallel Return", "GeometryNodeTree")
first = group.nodes.new("GeometryNodeMeshGrid")
source = group.nodes.new("GeometryNodeTransform")
consumer = group.nodes.new("GeometryNodeSampleIndex")
first.location = (0, 0)
source.location = (5000, 0)
consumer.location = (2000, -400)
group.links.new(first.outputs["Mesh"], source.inputs["Geometry"])
group.links.new(source.outputs["Geometry"], consumer.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
main = link_endpoints(source.inputs["Geometry"].links[0], positions, sizes)
start, end = link_endpoints(source.outputs["Geometry"].links[0], positions, sizes)
midpoint_y = (start[1] + end[1]) / 2
assert (midpoint_y <= min(point[1] for point in main) - TRACK_GAP
or midpoint_y >= max(point[1] for point in main) + TRACK_GAP)
def test_geometry_trunk_clears_the_frame_of_its_own_consumers():
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING
from tools.nodes.arrangement.references import FRAME_CLEARANCE, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Frame Trunk Clearance", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumers = []
for x, y in ((6000, 900), (8000, -900)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x, y)
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
consumers.append(target)
body = (5800, 8500, 1000, -1200)
program = _program(group)
route_references(group, [(set(consumers), body)])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
horizontal = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01 and abs(start[0] - end[0]) > 100:
horizontal.append((start, end))
if min(start[0], end[0]) < body[1] + FRAME_PADDING and max(start[0], end[0]) > body[0] - FRAME_PADDING:
assert (start[1] >= body[2] + FRAME_HEADING + FRAME_CLEARANCE - 0.01
or start[1] <= body[3] - FRAME_PADDING - FRAME_CLEARANCE + 0.01)
assert horizontal
def test_independent_selection_and_offset_calculations_get_separate_frames():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Independent Inputs", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
position = group.nodes.new("GeometryNodeSetPosition")
output = group.nodes.new("NodeGroupOutput")
group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"])
group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
branches = []
for socket, seed_type, node_type, operation in (
("Selection", "GeometryNodeInputIndex", "ShaderNodeMath", "ABSOLUTE"),
("Offset", "GeometryNodeInputPosition", "ShaderNodeVectorMath", "NORMALIZE"),
):
value = group.nodes.new(seed_type).outputs[0]
branch = [value.node]
for _ in range(12):
node = group.nodes.new(node_type)
node.operation = operation
group.links.new(value, node.inputs[0])
value = node.outputs[0]
branch.append(node)
group.links.new(value, position.inputs[socket])
branches.append(branch)
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
frames = [branch[0].parent for branch in branches]
assert all(frame is not None for frame in frames) and frames[0] != frames[1]
for branch, frame in zip(branches, frames):
assert all(node.parent == frame for node in branch)
@pytest.mark.parametrize("sample_geometry", [False, True])
def test_main_path_reserves_only_space_needed_after_geometry_source(sample_geometry):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Local Field Delivery", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
transform = group.nodes.new("GeometryNodeTransform")
consumer = group.nodes.new("GeometryNodeSetPosition")
output = group.nodes.new("NodeGroupOutput")
group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"])
group.links.new(transform.outputs["Geometry"], consumer.inputs["Geometry"])
group.links.new(consumer.outputs["Geometry"], output.inputs["Geometry"])
arrange_nodes(group)
original_x = consumer.location_absolute.x
sample = group.nodes.new("GeometryNodeSampleIndex" if sample_geometry else "GeometryNodeInputPosition")
if sample_geometry:
sample.data_type = "FLOAT_VECTOR"
group.links.new(transform.outputs["Geometry"], sample.inputs["Geometry"])
value = sample.outputs[0]
for _ in range(18):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(value, node.inputs[0])
value = node.outputs[0]
group.links.new(value, consumer.inputs["Offset"])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
if sample_geometry:
assert consumer.location_absolute.x > original_x + 700
assert sample.location_absolute.x >= transform.location_absolute.x + transform.width
else:
assert consumer.location_absolute.x == original_x
assert 100 <= consumer.location_absolute.x - value.node.location_absolute.x - value.node.width <= 1000
assert consumer.inputs["Geometry"].links[0].from_node == transform
@pytest.mark.parametrize("socket_type", ["FLOAT", "VECTOR", "BOOLEAN", "RGBA", "ROTATION", "GEOMETRY"])
def test_shared_results_use_one_peripheral_pipe(socket_type):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import route_references, FRAME_CLEARANCE
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Peripheral Results", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeSwitch")
source.input_type = socket_type
consumers = []
for x, y in ((2400, 500), (4800, -500), (7200, 200)):
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = socket_type
node.location = (x, y)
group.links.new(source.outputs["Output"], node.inputs["False"])
consumers.append(node)
program = _program(group)
route_references(group, [(set(consumers), (2200, 7400, 900, -900))])
assert _program(group) == program
assert len(source.outputs["Output"].links) == 1
assert all(node.inputs["False"].links[0].from_node.bl_idname == "NodeReroute" for node in consumers)
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
horizontal = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01:
assert end[0] > start[0]
assert start[1] >= 900 + FRAME_CLEARANCE or start[1] <= -900 - FRAME_CLEARANCE
horizontal.append(link)
assert horizontal
@pytest.mark.parametrize("consumes_frame", [False, True])
def test_geometry_only_enters_frames_that_consume_it(consumes_frame):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Frame Passage", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (2500 if consumes_frame else 5000, 0)
group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"])
members = {group.nodes.new("ShaderNodeMath") for _ in range(12)}
if consumes_frame:
members.add(consumer)
program = _program(group)
route_field_passages(group, [(members, (2000, 3000, 400, -600))])
assert _program(group) == program
if consumes_frame:
assert consumer.inputs["Geometry"].links[0].from_node == source
return
assert consumer.inputs["Geometry"].links[0].from_node.bl_idname == "NodeReroute"
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
for link in group.links:
(x, y), (u, v) = link_endpoints(link, positions, sizes)
assert u > x
bend = max(40, abs(u - x) * 0.42)
for t in np.linspace(0, 1, 65):
px = (1-t)**3*x + 3*(1-t)**2*t*(x+bend) + 3*(1-t)*t*t*(u-bend) + t**3*u
py = (1-t)**3*y + 3*(1-t)**2*t*y + 3*(1-t)*t*t*v + t**3*v
assert not (2000-FRAME_PADDING < px < 3000+FRAME_PADDING
and -600-FRAME_PADDING < py < 400+FRAME_HEADING)
@pytest.mark.parametrize("shared", [False, True])
@pytest.mark.parametrize("obstacle", [False, True])
def test_reference_detour_removes_reversal_but_preserves_shared_junction(shared, obstacle):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Reference Detour", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (5000, -1000)
corner = group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner.location = (6000, 0)
approach = group.nodes.new("NodeReroute")
approach["arrangement_reference"] = True
approach.location = (1000, 0)
group.links.new(source.outputs["Mesh"], approach.inputs[0])
group.links.new(approach.outputs[0], corner.inputs[0])
group.links.new(corner.outputs[0], consumer.inputs["Geometry"])
if shared:
other = group.nodes.new("GeometryNodeSampleIndex")
other.location = (7000, -1000)
group.links.new(corner.outputs[0], other.inputs["Geometry"])
members = {group.nodes.new("ShaderNodeMath") for _ in range(12)} if obstacle else set()
program = _program(group)
route_field_passages(group, [(members, (2000, 3000, 400, -1400))] if obstacle else [])
assert _program(group) == program
if shared:
assert corner in group.nodes.values()
else:
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
assert all(link_endpoints(link, positions, sizes)[1][0] > link_endpoints(link, positions, sizes)[0][0]
for link in group.links)
def test_value_passage_keeps_forward_entrance_when_consumer_is_left():
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Forward Value Entrance", "GeometryNodeTree")
source = group.nodes.new("ShaderNodeMath")
consumer = group.nodes.new("ShaderNodeMath")
consumer.location = (-3000, -1500)
corner = group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner.location = (1000, 0)
group.links.new(source.outputs[0], corner.inputs[0])
group.links.new(corner.outputs[0], consumer.inputs[0])
route_field_passages(group, [])
assert source.outputs[0].links[0].to_node == corner
def test_obstacle_detour_starts_to_the_right_of_a_value_source():
from tools.nodes.arrangement.passages import _passage
path = _passage((0, 0), (100, -2000), [(-300, 300, -300, -1600)], [], forward_start=True)
assert path[0][0] > 0
def test_compact_control_stays_near_switch_without_crossing_geometry_bypass():
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Compact Switch Control", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("GeometryNodeMeshGrid")
transform = group.nodes.new("GeometryNodeTransform")
switch = group.nodes.new("GeometryNodeSwitch")
switch.input_type = "GEOMETRY"
control = group.nodes.new("GeometryNodeMenuSwitch")
control.data_type = "BOOLEAN"
output = group.nodes.new("NodeGroupOutput")
group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"])
group.links.new(source.outputs["Mesh"], switch.inputs["False"])
group.links.new(transform.outputs["Geometry"], switch.inputs["True"])
group.links.new(control.outputs[0], switch.inputs["Switch"])
group.links.new(switch.outputs[0], output.inputs[0])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
start, end = link_endpoints(switch.inputs["Switch"].links[0], positions, sizes)
assert 100 <= end[0] - start[0] <= 160
control_curve = curve_footprint(start, end)
for link in group.links:
if link.from_socket.type != "GEOMETRY":
continue
geometry_curve = curve_footprint(*link_endpoints(link, positions, sizes))
assert not any(a < d and b > c and top > lower and bottom < upper
for a, b, top, bottom in control_curve
for c, d, upper, lower in geometry_curve)
def test_long_geometry_return_does_not_swing_over_later_processing():
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Long Geometry Return", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (17000, 0)
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (0, -100)
group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"])
program = _program(group)
route_field_passages(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
source_right = positions[source][0] + sizes[source].width
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
assert end[0] < start[0]
assert max(box[1] for box in curve_footprint(start, end)) < source_right + 160
def test_reused_value_for_inputs_of_one_node_stays_local():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Local Shared Value", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("ShaderNodeMath")
cube = group.nodes.new("GeometryNodeMeshCube")
output = group.nodes.new("NodeGroupOutput")
for name in ("Vertices X", "Vertices Y", "Vertices Z"):
group.links.new(source.outputs[0], cube.inputs[name])
group.links.new(cube.outputs["Mesh"], output.inputs[0])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
assert len(source.outputs[0].links) == 3
assert all(link.to_node == cube for link in source.outputs[0].links)
def test_shared_calculation_sits_outside_consumers_and_starts_its_pipe_forward():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Peripheral Calculation", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("GeometryNodeInputPosition")
producers = [source]
for _ in range(3):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(source.outputs[0], node.inputs[0])
producers.append(node)
source = node
geometry = group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
for _ in range(3):
value = source.outputs[0]
for _ in range(12):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(value, node.inputs[0])
value = node.outputs[0]
consumer = group.nodes.new("GeometryNodeSetPosition")
group.links.new(geometry, consumer.inputs["Geometry"])
group.links.new(value, consumer.inputs["Offset"])
geometry = consumer.outputs["Geometry"]
output = group.nodes.new("NodeGroupOutput")
group.links.new(geometry, output.inputs["Geometry"])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
frames = [node for node in group.nodes if node.bl_idname == "NodeFrame"]
assert len(frames) == 3
top = max(node.location_absolute.y for node in producers)
bottom = min(node.location_absolute.y - estimate_node_dimensions(node).height for node in producers)
assert (bottom >= max(frame.location_absolute.y for frame in frames)
or top <= min(frame.location_absolute.y - frame.height for frame in frames))
assert len(source.outputs[0].links) == 1
entrance = source.outputs[0].links[0].to_node
assert entrance.bl_idname == "NodeReroute"
assert entrance.location_absolute.x > source.location_absolute.x + estimate_node_dimensions(source).width

View File

@@ -155,7 +155,7 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
from tests.support.planes import create_surface, evaluated as plane_mesh from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, inputs, image = create_surface(source, "DEPTH") obj, set_value, _, inputs, image = create_surface(source, "DEPTH")
assert inputs["Boundary Smooth"].default_value == 5 assert inputs["Boundary Smooth"].default_value == 2
assert inputs["Boundary Smooth"].parent.name == "Options" assert inputs["Boundary Smooth"].parent.name == "Options"
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4) pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
yy, xx = np.mgrid[:8, :16] yy, xx = np.mgrid[:8, :16]
@@ -216,15 +216,18 @@ def test_every_split_node_enables_boundary_smoothing_by_default(source):
continue continue
split_groups.append(group.name) split_groups.append(group.name)
smooth = inputs["Boundary Smooth"] smooth = inputs["Boundary Smooth"]
assert smooth.default_value == 5 assert smooth.default_value == (5 if group.name == "O Image Depth Panorama" else 2)
assert (smooth.min_value, smooth.max_value) == (0, 20) assert (smooth.min_value, smooth.max_value) == (1 if "Outline Depth Fix" in inputs else 0, 20)
assert smooth.parent.name == "Options" assert smooth.parent.name == "Options"
assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys() assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys()
depth_mix = next(n for n in group.nodes if n.bl_idname == "ShaderNodeMix" depth_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute"
and n.data_type == "FLOAT" and n.inputs[3].is_linked and n.data_type == "FLOAT" and n.inputs["Iterations"].is_linked
and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute") and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"]
depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node assert len(depth_blurs) == 1
assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth" if "Outline Depth Fix" in inputs:
assert inputs["Outline Depth Fix"].default_value == 16
assert origin_socket(depth_blurs[0].inputs["Weight"].links[0].from_socket).node.bl_idname == "GeometryNodeSampleIndex"
continue
depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch" depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked
and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"] and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"]

View File

@@ -52,7 +52,7 @@ def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
@pytest.mark.parametrize("resolution", [12, 24]) @pytest.mark.parametrize("resolution", [12, 24])
@pytest.mark.parametrize("aspect", [.5, 2]) @pytest.mark.parametrize("aspect", [.5, 2])
def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect): def test_split_profile_tapers_within_boundary_band(resolution, aspect):
obj, set_value = surface(step=6, resolution=resolution) obj, set_value = surface(step=6, resolution=resolution)
for vertex in obj.data.vertices: for vertex in obj.data.vertices:
vertex.co.x *= aspect vertex.co.x *= aspect
@@ -73,16 +73,11 @@ def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
band = edge_band(faces, cut) band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band] outside = [i for i in range(len(points)) if i not in band]
assert outside assert outside
results = [] values = profile_values(obj)
for amount in (0, .2, .6): np.testing.assert_array_equal(values[cut], 0)
set_value("Edge Round", amount) np.testing.assert_allclose(values[outside], .3)
values = profile_values(obj) assert np.isfinite(values).all()
np.testing.assert_array_equal(values[cut], 0) assert values.max() == pytest.approx(.3)
np.testing.assert_allclose(values[outside], .3)
assert np.isfinite(values).all()
assert values.max() == pytest.approx(.3)
results.append(values)
assert np.all(results[2] <= results[0] + 1e-7)
set_value("Split Threshold", 0) set_value("Split Threshold", 0)
np.testing.assert_allclose(profile_values(obj), .3) np.testing.assert_allclose(profile_values(obj), .3)

View File

@@ -22,7 +22,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert len(set(groups.values())) == 3 assert len(set(groups.values())) == 3
for group in set(groups.values()): for group in set(groups.values()):
validate_node_structure(group) validate_node_structure(group)
revision = 18 if group == groups["DEPTH_SOLID"] else 11 revision = 23 if group == groups["DEPTH_SOLID"] else 12 if group == groups["DEPTH_BALLOON"] else 11
assert group.is_modifier and group.get("anyimage_asset_revision") == revision assert group.is_modifier and group.get("anyimage_asset_revision") == revision
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
@@ -36,21 +36,25 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert inputs["Double Sided"].default_value assert inputs["Double Sided"].default_value
if group == groups["DEPTH_SOLID"]: if group == groups["DEPTH_SOLID"]:
assert np.isclose(inputs["Split Threshold"].default_value, 0.1) assert np.isclose(inputs["Split Threshold"].default_value, 0.1)
assert np.isclose(inputs["Edge Round"].default_value, 0.2) assert not {"Edge Round", "Normal Smooth"} & inputs.keys()
assert inputs["Edge Round"].parent.name == "Options" normal_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" and n.data_type == "FLOAT_VECTOR"
assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round" and not n.inputs["Iterations"].is_linked and n.inputs["Iterations"].default_value == 100]
assert inputs["Normal Smooth"].default_value == 50 assert len(normal_blurs) == 2
assert inputs["Front Inflation"].default_value == 1 assert inputs["Front Inflation"].default_value == 0
assert inputs["Front Inflation"].subtype == "FACTOR"
assert (inputs["Front Inflation"].min_value, inputs["Front Inflation"].max_value) == (0, 1)
assert inputs["Front Inflation"].parent.name == "Options" assert inputs["Front Inflation"].parent.name == "Options"
assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"}
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
expected = ["Cleanup Threshold"] expected = ["Cleanup Threshold"]
if group == groups["DEPTH_SOLID"]: if group == groups["DEPTH_SOLID"]:
expected.insert(0, "Boundary Smooth") expected += ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Outline Depth Fix", "Front Inflation"]
expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"] assert inputs["Outline Depth Fix"].default_value == 16
assert not set(inputs) & {"Ratio Lower", "Ratio Upper", "Weight Blur Iterations", "Weight Update Rounds", "Depth Steps Per Round"}
elif group == groups["DEPTH_BALLOON"]: elif group == groups["DEPTH_BALLOON"]:
expected += ["Reference Depth"] expected += ["Smooth Weight", "Reference Depth"]
expected += ["Smooth Weight"] if group not in (groups["DEPTH_SOLID"], groups["DEPTH_BALLOON"]):
expected += ["Smooth Weight"]
if group == groups["DEPTH_BALLOON"]: if group == groups["DEPTH_BALLOON"]:
expected += ["Depth Direction"] expected += ["Depth Direction"]
assert options == expected, (group.name, options) assert options == expected, (group.name, options)

View File

@@ -4,9 +4,7 @@ import numpy as np
import pytest import pytest
from tools.nodes.groups.image_depth_cutout import build_volume_fields from tools.nodes.groups.image_depth_cutout import build_volume_fields
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
from anyimage.common.object import modifier_input_identifier, set_modifier_input from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tools.nodes.common.nodes import sample_field
def store_test_fields(group, geometry, fields): def store_test_fields(group, geometry, fields):
@@ -37,8 +35,7 @@ def set_test_structure(monkeypatch, value):
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields) monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
@pytest.mark.parametrize("iterations", [0, 50]) def test_uniform_thickness_follows_the_tilted_front_normal():
def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
from scipy.spatial import cKDTree from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=0, resolution=4) obj, set_value = surface(step=0, resolution=4)
@@ -47,7 +44,8 @@ def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
pixels[..., 2] = 1 + 0.4 * pixels[..., 0] pixels[..., 2] = 1 + 0.4 * pixels[..., 0]
image.pixels.foreach_set(pixels.ravel()) image.pixels.foreach_set(pixels.ravel())
image.update() image.update()
set_value("Normal Smooth", iterations) obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
front, _ = evaluated(obj) front, _ = evaluated(obj)
set_value("Thickness", 0.2) set_value("Thickness", 0.2)
points, faces = evaluated(obj) points, faces = evaluated(obj)
@@ -66,8 +64,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
profile[abs(profile) < 1e-7] = 0 profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", 0) set_value("Mode", 0)
set_value("Edge Round", 0.0)
set_value("Normal Smooth", 0)
modifier = obj.modifiers[0] modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE") thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
def amount(value): def amount(value):
@@ -85,57 +81,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02) np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02)
@pytest.mark.parametrize("amount", [0.0, 1.0])
def test_rounding_shares_front_and_back_falloff(amount):
bpy.ops.wm.read_factory_settings(use_empty=True)
n = 15
xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2))
depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
mesh = bpy.data.meshes.new("Round profile")
mesh.from_pydata(np.column_stack((xy, depth)), [], faces)
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon)
obj = bpy.data.objects.new("Round profile", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree")
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Uniform Thickness", "NodeSocketFloat", 0), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
if default is not None:
socket.default_value = default
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
fields = build_volume_fields(group)
geometry = source.outputs["Geometry"]
center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")}
mode = group.nodes.new("FunctionNodeInputBool")
result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0])
rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT")
for name, value in rounded.items()}
result = store_test_fields(group, result, {**center, **rounded})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Round", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
def values(name):
return np.array([v.value for v in result.attributes[name].data])
center = values("center")
u = balloon / balloon.max()
weight = amount * (1 - u**3 * (u * (6*u - 15) + 10))
delta = (center + balloon - depth) * weight
original = np.maximum(depth - center + balloon, 0.05 * balloon)
np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6)
np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6)
np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6)
normals = np.array([v.vector[:] for v in result.attributes["normal"].data])
np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6)
assert np.max(abs(normals[:, :2])) > 0.1
finally:
evaluated.to_mesh_clear()
def test_smooth_back_rejects_front_noise_and_preserves_center_depth(): def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
n = 41 n = 41
@@ -197,7 +142,8 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
@pytest.mark.parametrize("structure", [0, 1, 4]) @pytest.mark.parametrize("structure", [0, 1, 4])
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1]) @pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
def test_uniform_front_inflation_scales_with_distance(thickness, structure, monkeypatch): @pytest.mark.parametrize("inflation", [0.0, 0.0001, 0.5, 1.0])
def test_uniform_front_inflation_scales_with_distance(thickness, structure, inflation, monkeypatch):
from tests.support.depth_surface import surface, evaluated, assert_closed from tests.support.depth_surface import surface, evaluated, assert_closed
set_test_structure(monkeypatch, structure) set_test_structure(monkeypatch, structure)
obj, set_value = surface(step=0, resolution=10) obj, set_value = surface(step=0, resolution=10)
@@ -208,12 +154,12 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
set_value("Thickness", thickness) set_value("Thickness", thickness)
set_value("Front Inflation", 0.0) set_value("Front Inflation", 0.0)
baseline, faces = evaluated(obj) baseline, faces = evaluated(obj)
set_value("Front Inflation", 1.0) set_value("Front Inflation", inflation)
inflated, inflated_faces = evaluated(obj) inflated, inflated_faces = evaluated(obj)
assert faces == inflated_faces assert faces == inflated_faces
delta = np.linalg.norm(inflated - baseline, axis=1) delta = np.linalg.norm(inflated - baseline, axis=1)
front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6) front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6)
np.testing.assert_allclose(delta[front], thickness, atol=2e-6) np.testing.assert_allclose(delta[front], thickness * inflation, atol=2e-6)
np.testing.assert_allclose(delta[~front], 0, atol=2e-6) np.testing.assert_allclose(delta[~front], 0, atol=2e-6)
if thickness: if thickness:
assert_closed(inflated_faces) assert_closed(inflated_faces)
@@ -221,35 +167,6 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
np.testing.assert_array_equal(inflated, baseline) np.testing.assert_array_equal(inflated, baseline)
def test_normal_smooth_controls_front_and_rear_directions():
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10)
set_value("Front Inflation", 1.0)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group
set_value("Mode", 0)
set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0)
masks = []
coordinates = []
for iterations in (0, 50):
set_value("Normal Smooth", iterations)
points, faces = evaluated(obj)
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = ev.to_mesh()
mask = [v.value for v in mesh.attributes["o_normal_scale"].data]
front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face}
rear = set(range(len(points))) - front
ev.to_mesh_clear()
coordinates.append(points)
masks.append((front, rear))
assert masks[0] == masks[1]
for ids in masks[0]:
assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch): def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
from tests.support.depth_surface import surface, evaluated from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1) set_test_structure(monkeypatch, 1)
@@ -260,7 +177,6 @@ def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group group = obj.modifiers[0].node_group
set_value("Mode", 0) set_value("Mode", 0)
set_value("Edge Round", 0)
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE") thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
previous = 0 previous = 0
for thickness in (0.001, 0.01, 0.1, 0.5, 1): for thickness in (0.001, 0.01, 0.1, 0.5, 1):

View File

@@ -160,8 +160,7 @@ def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief, refe
np.testing.assert_array_equal(result[2], base[2]) np.testing.assert_array_equal(result[2], base[2])
@pytest.mark.parametrize("normal_smooth", [0, 2, 50]) def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth):
from scipy.spatial import cKDTree from scipy.spatial import cKDTree
obj, set_value, _, _, image = surface obj, set_value, _, _, image = surface
@@ -181,7 +180,6 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
raw_normals = np.array([v.normal[:] for v in mesh.vertices]) raw_normals = np.array([v.normal[:] for v in mesh.vertices])
finally: finally:
result.to_mesh_clear() result.to_mesh_clear()
set_value("Normal Smooth", normal_smooth)
thickness = 0.001 thickness = 0.001
set_value("Thickness", thickness) set_value("Thickness", thickness)
points = evaluated(obj)[0] points = evaluated(obj)[0]
@@ -196,18 +194,15 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
edges = np.array(list({tuple(sorted((a, b))) for f in faces edges = np.array(list({tuple(sorted((a, b))) for f in faces
for a, b in zip(f, (*f[1:], f[0]))})) for a, b in zip(f, (*f[1:], f[0]))}))
roughness = lambda n: np.mean(np.linalg.norm(n[edges[:, 0]] - n[edges[:, 1]], axis=1)) roughness = lambda n: np.mean(np.linalg.norm(n[edges[:, 0]] - n[edges[:, 1]], axis=1))
if normal_smooth == 0: assert roughness(normals) < roughness(raw_normals)
np.testing.assert_allclose(normals, raw_normals, atol=2e-4)
else:
assert roughness(normals) < roughness(raw_normals)
def test_surface_interface_and_node_structure(surface): def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface obj, _, _, inputs, _ = surface
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only", expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
"Boundary Smooth", "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"] "Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold", "Uniform Scale", "Depth Image"]
assert list(inputs) == expected assert list(inputs) == expected
assert inputs["Split Threshold"].default_value == 0 assert inputs["Split Threshold"].default_value == pytest.approx(0.1)
assert inputs["Split Threshold"].min_value == 0 assert inputs["Split Threshold"].min_value == 0
assert inputs["Split Threshold"].max_value == 1 assert inputs["Split Threshold"].max_value == 1
assert inputs["Split Threshold"].subtype == "FACTOR" assert inputs["Split Threshold"].subtype == "FACTOR"
@@ -222,10 +217,7 @@ def test_surface_interface_and_node_structure(surface):
assert inputs["Smooth Weight"].default_value == 1 assert inputs["Smooth Weight"].default_value == 1
assert inputs["Smooth Weight"].subtype == "FACTOR" assert inputs["Smooth Weight"].subtype == "FACTOR"
assert inputs["Smooth Weight"].parent.name == "Options" assert inputs["Smooth Weight"].parent.name == "Options"
assert inputs["Normal Smooth"].default_value == 50 assert not {"Normal Smooth", "Cleanup Threshold"} & inputs.keys()
assert inputs["Normal Smooth"].min_value == 0
assert inputs["Normal Smooth"].max_value == 50
assert inputs["Normal Smooth"].parent.name == "Options"
assert inputs["Thickness"].default_value == 0 assert inputs["Thickness"].default_value == 0
assert inputs["Subdivide"].default_value == 6 assert inputs["Subdivide"].default_value == 6
assert inputs["Depth Scale"].default_value == 1 assert inputs["Depth Scale"].default_value == 1
@@ -414,8 +406,7 @@ def test_plane_interface_panels(source, kind):
assert [item.name for item in panels] == ["Options", "Data"] assert [item.name for item in panels] == ["Options", "Data"]
controls = ["Reference Depth"] controls = ["Reference Depth"]
if kind == "DEPTH": if kind == "DEPTH":
controls.insert(0, "Boundary Smooth") controls = ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold"]
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
else: else:
controls += ["Smooth Weight", "Depth Direction"] controls += ["Smooth Weight", "Depth Direction"]
assert [item.name for item in panels[0].interface_items] == controls assert [item.name for item in panels[0].interface_items] == controls

View File

@@ -134,7 +134,7 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind):
split = inputs[names.index("Split Threshold")] split = inputs[names.index("Split Threshold")]
assert split.parent == inputs[names.index("Smooth")].parent assert split.parent == inputs[names.index("Smooth")].parent
assert split.parent.name != "Options" assert split.parent.name != "Options"
assert split.default_value == pytest.approx(0.1 if kind == "cutout" else 0) assert split.default_value == pytest.approx(0.1)
assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR") assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR")
assert "Remove Strip Faces" not in names assert "Remove Strip Faces" not in names
@@ -157,6 +157,7 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind):
image.pixels.foreach_set(pixels.ravel()) image.pixels.foreach_set(pixels.ravel())
image.update() image.update()
set_value("Depth Scale", 1.0) set_value("Depth Scale", 1.0)
set_value("Reference Depth", 11.0)
assert len(evaluated(obj)[1]) == 8 assert len(evaluated(obj)[1]) == 8
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.5)
vertices, faces = evaluated(obj) vertices, faces = evaluated(obj)

View File

@@ -66,7 +66,7 @@ def test_surface_caps_and_segmented_walls_are_connected(thickness, split):
@pytest.mark.parametrize("size", [0.01, 1.0, 100.0]) @pytest.mark.parametrize("size", [0.01, 1.0, 100.0])
def test_split_mapping_and_uniform_scale(size): def test_split_mapping_and_uniform_scale(size):
obj, set_value = surface(step=2.0, size=size) obj, set_value = surface(step=2.0, size=size)
# Face centers are size/2 apart: gradient is 4. # The normalized ray jump crosses the threshold only at full strength.
counts = [] counts = []
for split in (0.0, 0.25, 0.5, 1.0): for split in (0.0, 0.25, 0.5, 1.0):
set_value("Split Threshold", split) set_value("Split Threshold", split)
@@ -80,12 +80,12 @@ def test_split_mapping_and_uniform_scale(size):
assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6 assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6
def test_midpoint_selects_gradient_six_and_preserves_faces(): def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
obj, set_value = surface(step=3.0) obj, set_value = surface(step=4.0)
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.5)
coords, faces = evaluated(obj) coords, faces = evaluated(obj)
assert len(coords) == 18 and len(faces) == 8 assert len(coords) == 18 and len(faces) == 8
assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0} assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0}
def test_cleanup_keeps_split_parts_of_a_large_source_island(): def test_cleanup_keeps_split_parts_of_a_large_source_island():
@@ -102,7 +102,7 @@ def test_cleanup_keeps_split_parts_of_a_large_source_island():
@pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0]) @pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0])
def test_split_topology_uses_depth_before_display_scaling(depth_scale): def test_split_topology_uses_depth_before_display_scaling(depth_scale):
obj, set_value = surface(step=3.0) obj, set_value = surface(step=4.0)
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.5)
original, original_faces = evaluated(obj) original, original_faces = evaluated(obj)
set_value("Depth Scale", depth_scale) set_value("Depth Scale", depth_scale)
@@ -203,7 +203,7 @@ def test_small_positive_split_preserves_unsplit_projection():
def test_cut_vertices_preserve_camera_rays_and_other_vertices(): def test_cut_vertices_preserve_camera_rays_and_other_vertices():
obj, set_value = surface(step=3.0) obj, set_value = surface(step=4.0)
original, original_faces = evaluated(obj) original, original_faces = evaluated(obj)
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.5)
coords, faces = evaluated(obj) coords, faces = evaluated(obj)
@@ -212,14 +212,14 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
before, after = original[source_index], coords[result_index] before, after = original[source_index], coords[result_index]
if obj.data.vertices[source_index].co.x != 1.0: if obj.data.vertices[source_index].co.x != 1.0:
np.testing.assert_allclose(after, before, atol=1e-6) np.testing.assert_allclose(after, before, atol=1e-6)
before_depth, after_depth = 1.0 - before[2], 1.0 - after[2] before_depth, after_depth = 3.0 - before[2], 3.0 - after[2]
np.testing.assert_allclose( np.testing.assert_allclose(
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6 after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
) )
def test_source_surface_sampling_preserves_depth_on_side_walls(): def test_source_surface_sampling_preserves_depth_on_side_walls():
obj, set_value = surface(step=3.0) obj, set_value = surface(step=4.0)
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.5)
set_value("Thickness", 0.1) set_value("Thickness", 0.1)
coords, faces = evaluated(obj) coords, faces = evaluated(obj)
@@ -286,7 +286,7 @@ def test_zero_center_distance_does_not_split_or_produce_nan():
def test_triangle_corner_weights_do_not_open_sloped_side_walls(): def test_triangle_corner_weights_do_not_open_sloped_side_walls():
obj, set_value = surface(step=3.0) obj, set_value = surface(step=4.0)
bm = bmesh.new() bm = bmesh.new()
bm.from_mesh(obj.data) bm.from_mesh(obj.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces)) bmesh.ops.triangulate(bm, faces=list(bm.faces))
@@ -305,7 +305,7 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls():
@pytest.mark.parametrize("resolution", [1, 2, 4]) @pytest.mark.parametrize("resolution", [1, 2, 4])
def test_linear_depth_slope_has_density_independent_selection(resolution): def test_relative_distance_slope_has_density_independent_selection(resolution):
obj, set_value = surface(step=0.0, resolution=resolution) obj, set_value = surface(step=0.0, resolution=resolution)
output_split_mesh(obj) output_split_mesh(obj)
bm = bmesh.new() bm = bmesh.new()
@@ -315,10 +315,65 @@ def test_linear_depth_slope_has_density_independent_selection(resolution):
bm.free() bm.free()
image = bpy.data.images["Camera"] image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + 4 * pixels[..., 0] pixels[..., 2] = np.exp(6 * pixels[..., 0])
pixels[..., :2] = 0
image.pixels.foreach_set(pixels.ravel()) image.pixels.foreach_set(pixels.ravel())
image.update() image.update()
set_value("Split Threshold", 0.5) set_value("Split Threshold", 0.25)
assert len(evaluated(obj)[0]) == len(obj.data.vertices) assert len(evaluated(obj)[0]) == len(obj.data.vertices)
set_value("Split Threshold", 1.0) set_value("Split Threshold", 1.0)
assert len(evaluated(obj)[0]) > len(obj.data.vertices) assert len(evaluated(obj)[0]) > len(obj.data.vertices)
@pytest.mark.parametrize("kind", ["plane", "cutout"])
@pytest.mark.parametrize("source", ["definition", "asset"])
def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind, source):
from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, image = create_surface(source, "DEPTH")
set_value("Subdivide", 1)
else:
obj, set_value = surface(step=0)
if source == "asset":
from tests.support.paths import PROJECT_ROOT
with bpy.data.libraries.load(str(PROJECT_ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target):
target.node_groups = ["O Image Depth Cutout"]
obj.modifiers[0].node_group = target.node_groups[0]
image = bpy.data.images["Camera"]
output_split_mesh(obj)
set_value("Reference Depth", 3)
set_value("Split Threshold", 0)
width, height = image.size
pixels = np.ones((height, width, 4), np.float32)
original_count = len(evaluated(obj)[0])
set_value("Split Threshold", 1)
# Equal axial Z, but a tenfold ray-distance jump: Z-only splitting misses it.
pixels[..., :3] = (0, 0, 1)
pixels[:, width // 2:, 0] = np.sqrt(99)
signature = None
for scale in (0.01, 1.0, 100.0):
scaled = pixels.copy()
scaled[..., :3] *= scale
image.pixels.foreach_set(scaled.ravel())
image.update()
set_value("Depth Image", image)
points, faces = evaluated(obj)
assert len(points) > original_count
if signature is None:
signature = points, faces
np.testing.assert_array_equal(points, signature[0])
assert faces == signature[1]
# Equal ray distances with different Z must keep the faces connected.
pixels[..., :3] = (0, 0, 10)
pixels[:, width // 2:, :3] = (8, 0, 6)
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Depth Image", image)
assert len(evaluated(obj)[0]) == original_count
# Degenerate distances and a zero reference remain finite and connected.
pixels[..., :3] = 0
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Reference Depth", 0)
points, _ = evaluated(obj)
assert len(points) == original_count and np.isfinite(points).all()

View File

@@ -11,7 +11,7 @@ from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_g
from tests.support.nodes import validate_node_structure from tests.support.nodes import validate_node_structure
def panorama(depth=3, alpha=1, subdivide=3, reference=3): def panorama(depth=3, alpha=1, subdivide=3):
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_image_depth_panorama_group() group = build_image_depth_panorama_group()
assert group.name == "O Image Depth Panorama" assert group.name == "O Image Depth Panorama"
@@ -34,7 +34,7 @@ def panorama(depth=3, alpha=1, subdivide=3, reference=3):
obj.update_tag(refresh={"DATA"}) obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update() bpy.context.view_layer.update()
for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)): for name, value in (("Depth Image", image), ("Subdivide", subdivide)):
set_value(name, value) set_value(name, value)
set_value("Boundary Smooth", 0) set_value("Boundary Smooth", 0)
return obj, set_value return obj, set_value
@@ -190,7 +190,7 @@ def test_split_strength_and_distance_scale_invariance():
depth[:, 64:] = 7 depth[:, 64:] = 7
signatures = [] signatures = []
for scale in (0.01, 1, 100): for scale in (0.01, 1, 100):
obj, set_value = panorama(depth * scale, reference=3 * scale) obj, set_value = panorama(depth * scale)
counts = [] counts = []
for split in (0, 0.1, 0.5, 1): for split in (0, 0.1, 0.5, 1):
set_value("Split Threshold", split) set_value("Split Threshold", split)

View File

@@ -0,0 +1,104 @@
"""Check adaptive outline batches against a numerical graph reference."""
from collections import Counter
import bpy
import numpy as np
import pytest
from tests.support.depth_surface import evaluated
from tools.nodes.common.outline_smoothing import smooth_outline_camera
from tools.nodes.common.depth_surface import CUT_ATTRIBUTE
@pytest.mark.parametrize("steps", [1, 4])
def test_outline_batches_update_weights_preserve_split_and_camera_rays(steps):
bpy.ops.wm.read_factory_settings(use_empty=True)
points = np.array([(x, y, 0) for y in range(9) for x in range(9)], dtype=float)
faces = [(y*9+x, y*9+x+1, (y+1)*9+x+1, (y+1)*9+x) for y in range(8) for x in range(8)]
deleted = [i % 8 == 4 for i in range(len(faces))]
result_faces = [face for face, remove in zip(faces, deleted) if not remove]
cut = np.isin(points[:, 0], [4, 5])
depth = np.full(81, 2.0)
depth[2] = 20
depth[80] = 0
camera = np.column_stack((points[:, :2]*depth[:, None]/10, depth))
distance = np.linalg.norm(camera, axis=1)
mesh = bpy.data.meshes.new("Split outline")
mesh.from_pydata(points.tolist(), [], faces)
for name, kind, domain, values, prop in (
("test_camera", "FLOAT_VECTOR", "POINT", camera.ravel(), "vector"),
(CUT_ATTRIBUTE, "FLOAT", "POINT", cut, "value"),
("test_deleted", "BOOLEAN", "FACE", deleted, "value"),
):
mesh.attributes.new(name, kind, domain).data.foreach_set(prop, values)
obj = bpy.data.objects.new("Outline", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Adaptive outline test", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
ids = {}
for name in ("Outline Depth Fix", "Boundary Smooth"):
ids[name] = group.interface.new_socket(name=name, in_out="INPUT", socket_type="NodeSocketInt").identifier
source = group.nodes.new("NodeGroupInput")
attributes = {}
for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_deleted", "BOOLEAN")):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = kind
node.inputs["Name"].default_value = name
attributes[name] = node.outputs["Attribute"]
delete = group.nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ONLY_FACE"
group.links.new(source.outputs["Geometry"], delete.inputs["Geometry"])
group.links.new(attributes["test_deleted"], delete.inputs["Selection"])
scale = group.nodes.new("ShaderNodeValue")
scale.outputs[0].default_value = 1
result = smooth_outline_camera(group, source.outputs["Geometry"], delete.outputs[0], attributes["test_camera"], scale.outputs[0])
position = group.nodes.new("GeometryNodeSetPosition")
group.links.new(delete.outputs[0], position.inputs["Geometry"])
group.links.new(result, position.inputs["Position"])
output = group.nodes.new("NodeGroupOutput")
group.links.new(position.outputs[0], output.inputs["Geometry"])
modifier = obj.modifiers.new("Outline", "NODES")
modifier.node_group = group
# ONLY_FACE retains the separating column's edges in the current topology.
edges = np.array([tuple(e.vertices) for e in mesh.edges])
adjacency = np.zeros((81, 81))
adjacency[edges[:, 0], edges[:, 1]] = 1
adjacency += adjacency.T
degree = adjacency.sum(axis=1)
def blur(value, weight):
return (value + weight*(adjacency @ value))/(1 + weight*degree)
counts = Counter(tuple(sorted((a, b))) for f in result_faces for a, b in zip(f, (*f[1:], f[0])))
boundary = np.zeros(81)
for edge, count in counts.items():
if count == 1:
boundary[list(edge)] = 1
protected = boundary.astype(bool) & cut
seed = boundary*(~protected)
influence = np.maximum(seed, blur(blur(seed, 1), 1))*(~protected)
influence *= depth > 1e-8
for rounds, batch in ((0, steps), (16, 0), (16, steps)):
modifier[ids["Outline Depth Fix"]] = rounds
modifier[ids["Boundary Smooth"]] = batch
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
actual, actual_faces = evaluated(obj)
expected = distance.copy()
for _ in range(rounds):
ratio = abs(expected-blur(expected, .5))
t = np.clip((ratio-.25)/.25, 0, 1)
weight = t*t*(3-2*t)*influence
for _ in range(batch):
expected = blur(expected, weight)
np.testing.assert_allclose(np.linalg.norm(actual, axis=1), expected, atol=2e-5)
assert actual_faces == result_faces
np.testing.assert_array_equal(actual[influence == 0], camera[influence == 0].astype(np.float32))
if rounds and batch:
assert 2 < actual[2, 2] < 15
valid = depth > 0
np.testing.assert_allclose(actual[valid, :2]/actual[valid, 2:3], camera[valid, :2]/camera[valid, 2:3], atol=1e-6)
else:
np.testing.assert_array_equal(actual, camera.astype(np.float32))
assert not any(n.bl_idname in {"GeometryNodeCaptureAttribute", "GeometryNodeStoreNamedAttribute"} for n in group.nodes)

View File

@@ -25,11 +25,11 @@ class NodeAssetsTest(unittest.TestCase):
self.assertEqual(len(groups), 7) self.assertEqual(len(groups), 7)
for group in groups: for group in groups:
validate_node_structure(group) validate_node_structure(group)
positions = {node.name: tuple(node.location) for node in group.nodes positions = {node.name: tuple(node.location_absolute) for node in group.nodes
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}} if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}}
arrange_nodes(group) arrange_nodes(group)
for name, position in positions.items(): for name, position in positions.items():
for actual, expected in zip(group.nodes[name].location, position): for actual, expected in zip(group.nodes[name].location_absolute, position):
self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}") self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}")

View File

@@ -8,10 +8,16 @@ import numpy as np
from scipy.optimize import LinearConstraint, minimize from scipy.optimize import LinearConstraint, minimize
from .dimensions import estimate_node_dimensions, visible_socket from .dimensions import estimate_node_dimensions, visible_socket
from .zones import node_zones
from .flow import graph, geometry_paths, origin_socket, peer_links, shared_geometry_outputs, socket_links from .flow import graph, geometry_paths, origin_socket, peer_links, shared_geometry_outputs, socket_links
from .connections import curve_footprint, link_endpoints from .connections import curve_footprint, link_endpoints
from .fields import field_layout 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 GEOMETRY_GAP = 100
@@ -23,15 +29,6 @@ ROUTE_GAP = 40
ROUTE_RUN = 120 ROUTE_RUN = 120
def _replace_link(node_group, link, source):
"""Replace a source without changing a multi-input socket's branch order."""
multi = link.to_socket.is_multi_input
replacement = node_group.links.new(source, link.to_socket)
if multi:
replacement.swap_multi_input_sort_id(link)
node_group.links.remove(link)
def _remove_routes(node_group): def _remove_routes(node_group):
previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"] previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"]
@@ -41,7 +38,7 @@ def _remove_routes(node_group):
if not any(link.from_node in previous for link in links): if not any(link.from_node in previous for link in links):
continue continue
if not socket.is_multi_input: if not socket.is_multi_input:
_replace_link(node_group, links[0], origin_socket(links[0].from_socket)) replace_link_source(node_group, links[0], origin_socket(links[0].from_socket))
continue continue
sources = [origin_socket(link.from_socket) for link in links] sources = [origin_socket(link.from_socket) for link in links]
for link in links: for link in links:
@@ -88,10 +85,10 @@ def _localize_peer_inputs(node_group, ancestors, children):
port = node_group.nodes.new("NodeReroute") port = node_group.nodes.new("NodeReroute")
port["arrangement_input"] = True port["arrangement_input"] = True
node_group.links.new(link.from_socket, port.inputs[0]) node_group.links.new(link.from_socket, port.inputs[0])
_replace_link(node_group, link, port.outputs[0]) replace_link_source(node_group, link, port.outputs[0])
def _columns(nodes, parents, peers, sizes, gap, spacing=None): def _columns(nodes, parents, peers, sizes, gap):
"""Allocate columns with equal right edges for independent peer inputs.""" """Allocate columns with equal right edges for independent peer inputs."""
roots = {node: node for node in nodes} roots = {node: node for node in nodes}
@@ -117,7 +114,6 @@ def _columns(nodes, parents, peers, sizes, gap, spacing=None):
columns[levels[root(node)]].append(node) columns[levels[root(node)]].append(node)
right, positions = 0, {} right, positions = 0, {}
for level in sorted(columns): for level in sorted(columns):
right += max(((spacing or {}).get(node, 0) for node in columns[level]), default=0)
right += max(sizes[node].width for node in columns[level]) right += max(sizes[node].width for node in columns[level])
for node in columns[level]: for node in columns[level]:
positions[node] = right - sizes[node].width positions[node] = right - sizes[node].width
@@ -126,13 +122,13 @@ def _columns(nodes, parents, peers, sizes, gap, spacing=None):
def _field_regions(nodes, core, children, order): def _field_regions(nodes, core, children, order):
"""Group calculations by their first geometry consumers and shared scope.""" """Group connected calculations by the geometry inputs that consume them."""
targets = {} targets = {}
fields = set(nodes) - core fields = set(nodes) - core
for node in reversed(order): for node in reversed(order):
targets[node] = frozenset({node}) if node in core else frozenset().union( targets[node] = frozenset(link.to_socket for socket in node.outputs for link in socket.links
*(targets[child] for child in children[node]) if link.to_node in core) | frozenset().union(
) *(targets[child] for child in children[node] if child not in core))
for node in order: for node in order:
if node.get("arrangement_exit"): if node.get("arrangement_exit"):
targets[node] = targets[origin_socket(node.outputs[0]).node] targets[node] = targets[origin_socket(node.outputs[0]).node]
@@ -140,7 +136,26 @@ def _field_regions(nodes, core, children, order):
for node in order: for node in order:
if node in fields: if node in fields:
regions[targets[node]].append(node) regions[targets[node]].append(node)
return [(region, owners) for owners, region in regions.items()] 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): def _localize_module_outputs(node_group, core, children):
@@ -155,7 +170,7 @@ def _localize_module_outputs(node_group, core, children):
port = node_group.nodes.new("NodeReroute") port = node_group.nodes.new("NodeReroute")
port["arrangement_exit"] = True port["arrangement_exit"] = True
node_group.links.new(link.from_socket, port.inputs[0]) node_group.links.new(link.from_socket, port.inputs[0])
_replace_link(node_group, link, port.outputs[0]) replace_link_source(node_group, link, port.outputs[0])
def _localize_inputs(node_group): def _localize_inputs(node_group):
@@ -169,7 +184,7 @@ def _localize_inputs(node_group):
local = node_group.nodes.new("NodeGroupInput") local = node_group.nodes.new("NodeGroupInput")
for link, identifier in inputs: for link, identifier in inputs:
source = next(socket for socket in local.outputs if socket.identifier == identifier) source = next(socket for socket in local.outputs if socket.identifier == identifier)
_replace_link(node_group, link, source) replace_link_source(node_group, link, source)
for node in controls: for node in controls:
node_group.nodes.remove(node) node_group.nodes.remove(node)
@@ -384,7 +399,7 @@ class BranchSpace:
if link.to_node != other.to_node and link.from_socket != other.from_socket) 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, trunks, space, horizontal=None, *, allow_opposite=False): 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.""" """Place an upstream module together with its forward-facing exit channels."""
right = min(positions[node][0] for node in destinations) - FIELD_GAP right = min(positions[node][0] for node in destinations) - FIELD_GAP
baseline = sum(positions[node][1] for node in owners) / len(owners) baseline = sum(positions[node][1] for node in owners) / len(owners)
@@ -398,23 +413,13 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
if allow_opposite: if allow_opposite:
side = 0 side = 0
desired = right - width desired = right - width
front_allowed = not horizontal and all(link.from_node in local front_allowed = all(link.from_node in local
for node in local for socket in node.inputs for link in socket.links) for node in local for socket in node.inputs for link in socket.links)
shared = [] if horizontal else shared_geometry_outputs(local) outlets, constraints = [], []
references = set(shared_geometry_outputs(local))
outlets, constraints, entries = [], [], []
for node, (x, y) in local.items(): for node, (x, y) in local.items():
if horizontal:
for socket in node.inputs:
for link in socket.links:
if link.from_node in positions:
source_x, source_y = positions[link.from_node]
entries.append((source_x + sizes[link.from_node].width - x,
source_y - sizes[link.from_node].sockets[link.from_socket],
y - sizes[node].sockets[socket]))
for socket in node.outputs: for socket in node.outputs:
links = list(socket.links) links = list(socket.links)
if socket not in references: if socket.type != "GEOMETRY" and not peripheral:
returns = [link for link in links if link.to_node in core] returns = [link for link in links if link.to_node in core]
if returns: if returns:
outlets.append(returns) outlets.append(returns)
@@ -424,12 +429,15 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
constraints.append((target_x - x - sizes[node].width, constraints.append((target_x - x - sizes[node].width,
y - sizes[node].sockets[socket], y - sizes[node].sockets[socket],
target_y - sizes[link.to_node].sockets[link.to_socket])) target_y - sizes[link.to_node].sockets[link.to_socket]))
trunk_end = max(positions[node][0] for node in {*owners, *destinations}) 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), candidates = {desired, *(left - BRANCH_GAP - width for left, _, _, _ in occupied),
*(end + BRANCH_GAP for _, end, _, _ in occupied)} *(end + BRANCH_GAP for _, end, _, _ in occupied)}
if horizontal:
candidates = {left for left in candidates if horizontal[0] <= left and left + width <= horizontal[1]}
candidates.add(horizontal[0])
best, seen = None, set() best, seen = None, set()
for left in sorted(candidates, key=lambda x: (abs(x - desired), x)): for left in sorted(candidates, key=lambda x: (abs(x - desired), x)):
tops = {ceiling + BRANCH_GAP + height, floor - BRANCH_GAP, tops = {ceiling + BRANCH_GAP + height, floor - BRANCH_GAP,
@@ -443,16 +451,11 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)): for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)):
if top > limits[0] or top - height < limits[1]: if top > limits[0] or top - height < limits[1]:
continue continue
minimum = max([horizontal[0], *(start + max(GEOMETRY_GAP, abs(top + target_y - source_y) * 0.8) placed_left = min(left, desired)
for start, source_y, target_y in entries)]) if horizontal else -float("inf") placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(source_y + top - target_y) * exit_slope)
placed_left = max(left, minimum) if horizontal else min(left, desired)
placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(target_y - top - source_y) * 0.8)
for end, source_y, target_y in constraints)]) for end, source_y, target_y in constraints)])
if not horizontal: # Leave clearance at saturated link constraints for solver roundoff.
# Leave clearance at saturated link constraints for solver roundoff. placed_left -= 0.01
placed_left -= 0.01
if horizontal and not minimum <= placed_left <= horizontal[1] - width:
continue
in_front = placed_left + width + BRANCH_GAP <= core_left 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) 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)): or (side == -1 and top > floor - BRANCH_GAP + 1e-5)):
@@ -462,26 +465,23 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
continue continue
seen.add(point) seen.add(point)
distance = abs(placed_left - desired) distance = abs(placed_left - desired)
base_score = distance + abs(top - height / 2 - baseline) * 1.5 base_score = (sum(end - placed_left + abs(source_y + top - target_y) * 1.5
if best is not None and base_score > best[0]: 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 continue
overlapping = [box for box in occupied overlapping = [box for box in occupied
if placed_left < box[1] + BRANCH_GAP and placed_left + width > box[0] - BRANCH_GAP] 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 if any(top > bottom - BRANCH_GAP + 1e-5 and top - height < upper + BRANCH_GAP - 1e-5
for _, _, upper, bottom in overlapping): for _, _, upper, bottom in overlapping):
continue continue
channels = [(placed_left + local[socket.node][0] + sizes[socket.node].width,
top + local[socket.node][1] - sizes[socket.node].sockets[socket]) for socket in shared]
if any(a < trunk_end and b > start and bottom - ROUTE_GAP < level < upper + ROUTE_GAP
for start, level in channels for a, b, upper, bottom in occupied):
continue
placed = positions | {node: (x + placed_left, y + top) for node, (x, y) in local.items()} placed = positions | {node: (x + placed_left, y + top) for node, (x, y) in local.items()}
exits, routes = space.connections(outlets, placed, sizes) exits, routes = space.connections(outlets, placed, sizes)
body = (placed_left, placed_left + width, top, top - height) body = (placed_left, placed_left + width, top, top - height)
if not space.fits(body, exits): if not space.fits(body, exits):
continue continue
score = base_score + space.crossings(exits) * BRANCH_GAP * 4 score = base_score + space.crossings(exits) * BRANCH_GAP * 4
candidate = (score, distance, -top, placed_left, top) candidate = (round(score, 6), round(distance, 6), -top, placed_left, top)
if best is None or candidate < best: if best is None or candidate < best:
best = candidate best = candidate
best_exits, best_routes = exits, routes best_exits, best_routes = exits, routes
@@ -489,7 +489,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
if not allow_opposite: if not allow_opposite:
return _place_field_region( return _place_field_region(
local, width, height, destinations, owners, positions, sizes, local, width, height, destinations, owners, positions, sizes,
occupied, limits, core, trunks, space, horizontal, allow_opposite=True, occupied, limits, core, space, allow_opposite=True, peripheral=peripheral,
repeat_consumer=repeat_consumer,
) )
raise ValueError("The field region does not fit its reserved scope") raise ValueError("The field region does not fit its reserved scope")
_, _, _, left, top = best _, _, _, left, top = best
@@ -500,38 +501,29 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
space.bodies.append((set(local), body)) space.bodies.append((set(local), body))
space.exits.extend(best_exits) space.exits.extend(best_exits)
space.routes.extend(best_routes) space.routes.extend(best_routes)
for socket in shared:
start = positions[socket.node][0] + sizes[socket.node].width
level = positions[socket.node][1] - sizes[socket.node].sockets[socket]
trunks[socket] = level
occupied.append((start, trunk_end, level + ROUTE_GAP, level - ROUTE_GAP))
def _route_field_links(node_group, layouts, positions): def _route_field_links(node_group, layouts, positions):
"""Materialize the passages reserved between expression columns.""" """Materialize the passages reserved between expression columns."""
protected = set()
for layout in layouts: for layout in layouts:
anchor = next(iter(layout.positions)) anchor = next(iter(layout.positions))
dx = positions[anchor][0] - layout.positions[anchor][0] dx = positions[anchor][0] - layout.positions[anchor][0]
dy = positions[anchor][1] - layout.positions[anchor][1] dy = positions[anchor][1] - layout.positions[anchor][1]
for link, points in layout.routes: for link, points in layout.routes:
source = link.from_socket source = link.from_socket
protected.add((source, link.to_socket))
for x, y in points: for x, y in points:
node = node_group.nodes.new("NodeReroute") node = node_group.nodes.new("NodeReroute")
node["arrangement_branch"] = True node["arrangement_branch"] = True
node.location = (x + dx - 8, y + dy + 8) node.location = (x + dx - 8, y + dy + 8)
node_group.links.new(source, node.inputs[0]) node_group.links.new(source, node.inputs[0])
source = node.outputs[0] source = node.outputs[0]
_replace_link(node_group, link, source) replace_link_source(node_group, link, source)
return protected
def _route_branch_exits(node_group, routes): def _route_branch_exits(node_group, routes):
"""Materialize the channels reserved during placement, sharing trunk corners.""" """Materialize the channels reserved during placement, sharing trunk corners."""
deliveries, protected = defaultdict(list), set() deliveries = defaultdict(list)
for link, points in routes: for link, points in routes:
protected.add((link.from_socket, link.to_socket))
deliveries[link.from_socket].append((link, points)) deliveries[link.from_socket].append((link, points))
for source, branches in deliveries.items(): for source, branches in deliveries.items():
corners = {} corners = {}
@@ -543,8 +535,7 @@ def _route_branch_exits(node_group, routes):
source = corners[point] = node.outputs[0] source = corners[point] = node.outputs[0]
for link, points in branches: for link, points in branches:
if points: if points:
_replace_link(node_group, link, corners[points[-1]]) replace_link_source(node_group, link, corners[points[-1]])
return protected
def _route_peer_bypasses(node_group, sizes, positions, ancestors): def _route_peer_bypasses(node_group, sizes, positions, ancestors):
@@ -571,116 +562,14 @@ def _route_peer_bypasses(node_group, sizes, positions, ancestors):
entry = node_group.nodes.new("NodeReroute") entry = node_group.nodes.new("NodeReroute")
entry["arrangement_bypass"] = True entry["arrangement_bypass"] = True
node_group.links.new(source, entry.inputs[0]) node_group.links.new(source, entry.inputs[0])
_replace_link(node_group, port.inputs[0].links[0], entry.outputs[0]) replace_link_source(node_group, port.inputs[0].links[0], entry.outputs[0])
positions[entry] = (x - 8, level + 8) positions[entry] = (x - 8, level + 8)
sizes[entry] = estimate_node_dimensions(entry) sizes[entry] = estimate_node_dimensions(entry)
def _route_references(node_group, sizes, positions, regions, paths, trunks, protected):
"""Share forward trunks and branch next to each consumer for every socket type."""
references = defaultdict(list)
path_of = {node: index for index, path in enumerate(paths) for node in path}
peer_edges = {link for node in node_group.nodes if len(links := peer_links(node)) > 1 for link in links}
for link in list(node_group.links):
if (link.from_socket, link.to_socket) in protected or link.from_node.get("arrangement_branch") or link.to_node.get("arrangement_branch"):
continue
if link.from_node.get("arrangement_bypass") or link.to_node.get("arrangement_bypass"):
continue
if (link.from_node in path_of and link.to_node in path_of
and path_of[link.from_node] == path_of[link.to_node] and len(link.from_socket.links) == 1):
continue
if link in peer_edges:
continue
if positions[link.to_node][0] - positions[link.from_node][0] - sizes[link.from_node].width > ROUTE_RUN * 4:
references[link.from_socket].append(link)
boxes = {node: _node_box(node, positions, sizes) for node in positions}
tracks = []
def route(source, x, y):
node = node_group.nodes.new("NodeReroute")
node.location = (x - 8, y + 8)
node_group.links.new(source, node.inputs[0])
return node.outputs[0]
for source, links in references.items():
if len(links) < 2 and source.type != "GEOMETRY":
continue
left = positions[source.node][0] + sizes[source.node].width
right = max(positions[link.to_node][0] for link in links)
start_y = positions[source.node][1] - sizes[source.node].sockets[source]
destinations = {link.to_node for link in links}
obstacles = [(bottom - ROUTE_GAP, top + ROUTE_GAP) for node, (a, b, top, bottom) in boxes.items()
if node not in destinations and a < right and b > left]
for a, b, top, bottom in regions:
if a >= right or b <= left:
continue
endpoints = destinations | {source.node}
if any(a <= positions[node][0] <= b and bottom <= positions[node][1] <= top for node in endpoints):
continue
obstacles.append((bottom - ROUTE_GAP, top + ROUTE_GAP))
obstacles += [(y - ROUTE_GAP, y + ROUTE_GAP) for a, b, y in tracks if a < right and b > left]
if len(links) == 1:
target = links[0]
target_y = positions[target.to_node][1] - sizes[target.to_node].sockets[target.to_socket]
if abs(target_y - start_y) < ROUTE_GAP and all(not lower < start_y < upper for lower, upper in obstacles):
continue
first_target = min(positions[link.to_node][0] for link in links)
reach = (first_target - left) / 2.4
lower, upper = start_y - reach, start_y + reach
for link in links:
y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket]
reach = (positions[link.to_node][0] - left) / 2.4
lower, upper = max(lower, y - reach), min(upper, y + reach)
candidates = [start_y, lower, upper, *(edge for interval in obstacles for edge in interval)]
available = [y for y in candidates if lower <= y <= upper and all(not a < y < b for a, b in obstacles)]
if source in trunks:
level = trunks[source]
elif not available:
continue
else:
level = min(available, key=lambda y: (abs(y - start_y) + sum(
abs(y - positions[link.to_node][1] + sizes[link.to_node].sockets[link.to_socket])
for link in links) / len(links), -y))
entry_run = min(max(ROUTE_RUN, abs(level - start_y) * 0.8), (first_target - left) / 3)
trunk = route(source, left + entry_run, level)
tracks.append((left + entry_run, right, level))
previous_x = left + entry_run
branches = []
for link in links:
target_x = positions[link.to_node][0]
target_y = positions[link.to_node][1] - sizes[link.to_node].sockets[link.to_socket]
exit_run = min(max(ROUTE_RUN, abs(target_y - level) * 0.8), (target_x - left) / 3)
branches.append((target_x - exit_run, link))
for x, link in sorted(branches, key=lambda item: item[0]):
if x != previous_x:
trunk = route(trunk, x, level)
previous_x = x
_replace_link(node_group, link, trunk)
def _zone_width(interior, layout, sizes):
"""Reserve the body and the actual socket runs at each end of a paired scope."""
body_height = max(sizes[node].height for node in interior)
spans = []
for top in (layout.height + BRANCH_GAP, -body_height - BRANCH_GAP):
left, right = FIELD_GAP, layout.width + FIELD_GAP
for node, (x, y) in layout.positions.items():
for socket in node.inputs:
for link in socket.links:
if link.from_node not in layout.positions and link.from_node not in interior:
delta = top + y - sizes[node].sockets[socket] + sizes[link.from_node].sockets[link.from_socket]
left = max(left, max(GEOMETRY_GAP, abs(delta) * 0.8) - x)
for socket in node.outputs:
for link in socket.links:
if link.to_node in interior:
delta = top + y - sizes[node].sockets[socket] + sizes[link.to_node].sockets[link.to_socket]
right = max(right, x + sizes[node].width + max(GEOMETRY_GAP, abs(delta) * 0.8))
spans.append(left + right)
return max(spans) + BRANCH_GAP * 2
def arrange_nodes(node_group): def arrange_nodes(node_group):
"""Fix geometry lanes, pack shallow field regions, and route shared references.""" """Fix geometry lanes, pack shallow field regions, and route shared references."""
remove_field_frames(node_group)
_remove_routes(node_group) _remove_routes(node_group)
parents, children, order, ancestors = graph(node_group) parents, children, order, ancestors = graph(node_group)
_localize_peer_inputs(node_group, ancestors, children) _localize_peer_inputs(node_group, ancestors, children)
@@ -700,68 +589,64 @@ def arrange_nodes(node_group):
outlets = [link.from_node for node in order if node in core for socket in node.inputs 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] for link in socket_links(socket) if link.from_node in region]
peers.append(list(dict.fromkeys(outlets))) peers.append(list(dict.fromkeys(outlets)))
spacing, zones, field_layouts = {}, [], [] # Preserve execution order through shared geometry and field calculations.
scoped = set() preceding = {}
for entry, end, members in reversed(node_zones(node_group)): column_parents = {}
if entry not in core or end not in core: for node in order:
continue sources = set().union(*(preceding[parent] for parent in parents[node]))
interior = (members & core) - {entry} if node in core:
fields = [node for node in order if node in members - core - scoped] column_parents[node] = sources
if fields: preceding[node] = {node} if node in core else sources
scoped.update(fields) geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP)
layout = field_layout(fields, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
field_layouts.append(layout)
local, width, height = layout.positions, layout.width, layout.height
span = _zone_width(interior, layout, sizes)
for node in core:
if entry in geometry_parents[node]:
spacing[node] = max(spacing.get(node, 0), span)
zones.append((entry, end, interior, fields, local, width, height))
column_parents = {node: list(sources) for node, sources in geometry_parents.items()}
for node in core:
if node.get("arrangement_input"):
source = origin_socket(node.outputs[0]).node
if source in core:
column_parents[node] = [source]
geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP, spacing)
positions = _place_geometry_paths(paths, geometry_x, sizes, order) positions = _place_geometry_paths(paths, geometry_x, sizes, order)
occupied = _geometry_corridors(paths, positions, sizes)
trunks = {}
space = BranchSpace() space = BranchSpace()
layouts = [] layouts, field_layouts = [], []
for region, targets in regions: for region, targets in regions:
remaining = [node for node in region if node not in scoped] layout = field_layout(region, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
if remaining: field_layouts.append(layout)
layout = field_layout(remaining, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT) layouts.append((region, targets, layout.positions, layout.width, layout.height))
field_layouts.append(layout) reserve_field_sections(node_group, layouts, positions, sizes, gap=BRANCH_GAP)
layouts.append((remaining, targets, layout.positions, layout.width, layout.height)) occupied = _geometry_corridors(paths, positions, sizes)
reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts), reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts)]) * 2 + BRANCH_GAP * 2
*(height for _, _, _, _, _, _, height in zones)]) * 2 + BRANCH_GAP * 2
reserve += sum(height + BRANCH_GAP * 2 for _, _, _, _, _, _, height in zones)
limits = (max(y for _, y in positions.values()) + reserve, limits = (max(y for _, y in positions.values()) + reserve,
min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve) min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve)
for entry, end, interior, fields, local, width, height in zones:
destinations = [min((node for node in interior if node.bl_idname != "NodeReroute"),
key=lambda node: (positions[node][0], node.name))]
horizontal = (positions[entry][0] + sizes[entry].width + FIELD_GAP, positions[end][0] - FIELD_GAP)
_place_field_region(local, width, height, destinations, destinations, positions, sizes, occupied, limits, core, trunks, space, horizontal)
members = interior | {entry} | set(fields)
boxes = [_node_box(node, positions, sizes) for node in members]
occupied.append((min(box[0] for box in boxes), max(box[1] for box in boxes),
max(box[2] for box in boxes), min(box[3] for box in boxes)))
pending = list(layouts) pending = list(layouts)
shared_nodes = {socket.node for socket in shared_geometry_outputs(node_group.nodes)} 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): def delivery_order(layout):
ports = [link.to_socket for node in layout[0] for socket in node.outputs 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] for link in socket.links if link.to_node in core]
return min(((positions[socket.node][0], list(socket.node.inputs).index(socket)) return min(((positions[socket.node][0], list(socket.node.inputs).index(socket))
for socket in ports), default=(float("inf"), 0)) 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: while pending:
ready = [layout for layout in pending if all( ready = [layout for layout in pending if all(
link.to_node in positions or link.to_node in layout[0] 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)] for node in layout[0] for socket in node.outputs for link in socket.links)]
layout = min(ready, key=lambda item: (any(node in shared_nodes for node in item[0]), 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]))) delivery_order(item), order.index(item[0][0])))
pending.remove(layout) pending.remove(layout)
region, targets, local, width, height = layout region, targets, local, width, height = layout
@@ -769,15 +654,28 @@ def arrange_nodes(node_group):
for link in socket.links if link.to_node not in region and link.to_node in positions)) 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]] 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]] 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, trunks, space) _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space,
protected = _route_field_links(node_group, field_layouts, positions) peripheral=region[0] in peripheral_regions,
protected.update(_route_branch_exits(node_group, space.routes)) 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) _route_peer_bypasses(node_group, sizes, positions, ancestors)
for node, position in positions.items(): for node, position in positions.items():
node.location = position node.location = position
_route_references(node_group, sizes, positions, occupied, paths, trunks, protected) route_references(node_group, space.bodies, peripheral_nodes=peripheral_nodes)
while unused := [node for node in node_group.nodes while unused := [node for node in node_group.nodes
if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]: 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: for node in unused:
node_group.nodes.remove(node) node_group.nodes.remove(node)
route_field_passages(node_group, space.bodies)
create_field_frames(node_group, space.bodies, core)
_presentation(node_group) _presentation(node_group)

View File

@@ -22,6 +22,8 @@ def visible_socket(socket):
def estimate_node_dimensions(node): def estimate_node_dimensions(node):
"""Reserve socket rows, expanded vector values, and node-specific controls.""" """Reserve socket rows, expanded vector values, and node-specific controls."""
if node.bl_idname == "NodeFrame":
return NodeDimensions(node.width, node.height, {})
if node.bl_idname == "NodeReroute": if node.bl_idname == "NodeReroute":
return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)}) return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)})
inputs = [s for s in node.inputs if visible_socket(s)] inputs = [s for s in node.inputs if visible_socket(s)]

View File

@@ -33,6 +33,22 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
continue continue
for node in members[1:]: for node in members[1:]:
roots[root(node)] = root(members[0]) 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) groups = defaultdict(list)
for node in nodes: for node in nodes:
groups[root(node)].append(node) groups[root(node)].append(node)
@@ -80,11 +96,12 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
layers = columns layers = columns
depth = column - 1 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] 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 = [], [] edges, passages, vertical = [], [], []
for link in links: for link in links:
first, last = block_of[link.from_node], block_of[link.to_node] first, last = block_of[link.from_node], block_of[link.to_node]
if first == last: if first == last:
raise ValueError("Equal operands must not contain an internal dependency") vertical.append(link)
continue
start_offset = offsets[link.from_node] + sizes[link.from_node].sockets[link.from_socket] 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] end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket]
previous, previous_offset, path = first, start_offset, [] previous, previous_offset, path = first, start_offset, []
@@ -173,18 +190,18 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
raise ValueError(f"Expression layers could not be placed: {result.message}") raise ValueError(f"Expression layers could not be placed: {result.message}")
centers = result.x[:count] centers = result.x[:count]
positions, point_positions, right = {}, {}, 0 positions, point_positions, right = {}, {}, 0
vertical_blocks = {block_of[link.from_node] for link in vertical}
for rank in range(depth + 1): for rank in range(depth + 1):
layer = layers[rank] layer = layers[rank]
width = max(max(sizes[node].width for node in blocks[item]) if item < len(blocks) else 16 for item in layer) 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: if rank:
run = max([gap, *(abs(centers[first] + a - centers[last] - b) * 0.8 right += gap
for first, last, a, b in edges if ranks[last] == rank)]) right += width + padding * 2
right += run
right += width
for item in layer: for item in layer:
if item < len(blocks): if item < len(blocks):
for node in blocks[item]: for node in blocks[item]:
positions[node] = (right - sizes[node].width, -centers[item] - offsets[node]) positions[node] = (right - padding - sizes[node].width, -centers[item] - offsets[node])
else: else:
point_positions[item] = (right, -centers[item]) point_positions[item] = (right, -centers[item])
routes = [] routes = []
@@ -193,4 +210,12 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
points = [point for index, point in enumerate(points) if index == 0 or index == len(points)-1 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] 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)) 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) return FieldLayout(positions, right, result.x[count], routes)

View File

@@ -0,0 +1,58 @@
"""Frame complex field calculations by their consuming inputs."""
from .flow import origin_socket
FRAME_PADDING = 32
FRAME_HEADING = 48
FRAME_MIN_NODES = 12
def remove_field_frames(node_group):
"""Detach generated containers before calculating absolute positions again."""
frames = {node for node in node_group.nodes if node.get("arrangement_frame")}
for node in node_group.nodes:
if node.parent in frames:
position = tuple(node.location_absolute)
node.parent = None
node.location = position
for frame in frames:
node_group.nodes.remove(frame)
def create_field_frames(node_group, bodies, core):
"""Keep a complex calculation and its internal wire passages in one frame."""
for members, (left, right, top, bottom) in bodies:
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) < FRAME_MIN_NODES:
continue
members = set(members)
outlets = [link for node in members for socket in node.outputs for link in socket.links
if link.to_node not in members]
targets = []
pending = list(outlets)
visited = set()
while pending:
link = pending.pop()
if link.to_node in core:
targets.append(link)
elif link.to_node not in visited:
visited.add(link.to_node)
pending.extend(edge for socket in link.to_node.outputs for edge in socket.links)
target = min(targets, key=lambda link: (link.to_node.location_absolute.x,
link.to_node.name, link.to_socket.name), default=None)
pending = {node for node in node_group.nodes if node.bl_idname == "NodeReroute"
and node.get("arrangement_branch") and origin_socket(node.outputs[0]).node in members}
while ready := {node for node in pending if all(link.to_node in members for link in node.outputs[0].links)}:
members.update(ready)
pending.difference_update(ready)
frame = node_group.nodes.new("NodeFrame")
frame["arrangement_frame"] = True
frame.name = f"{target.to_node.name} · {target.to_socket.name}" if target else "Field Calculation"
frame.label = frame.name
frame.shrink = False
frame.location = (left - FRAME_PADDING, top + FRAME_HEADING)
frame.width = right - left + FRAME_PADDING * 2
frame.height = top - bottom + FRAME_HEADING + FRAME_PADDING
frame.use_custom_color = True
frame.color = (0.12, 0.16, 0.18)
for node in members:
node.parent = frame

View File

@@ -0,0 +1,275 @@
"""Find curved passages around unrelated calculation frames."""
from heapq import heappop, heappush
from math import hypot
from ..common.nodes import replace_link_source
from .connections import curve_footprint, link_endpoints
from .dimensions import estimate_node_dimensions
from .flow import origin_socket, peer_links
from .frames import FRAME_HEADING, FRAME_MIN_NODES, FRAME_PADDING
from .zones import node_zones, repeat_regions
PASSAGE_GAP = 96
PASSAGE_SLOPE = 0.2
def _intersects(start, end, boxes):
bend = max(40, abs(end[0] - start[0]) * 0.42)
left, right = min(start[0], end[0] - bend), max(start[0] + bend, end[0])
bottom, top = sorted((start[1], end[1]))
boxes = [box for box in boxes if box[0] < right and box[1] > left and box[3] < top and box[2] > bottom]
if not boxes:
return False
return any(a < right and b > left and bottom < top and upper > lower
for left, right, top, lower in curve_footprint(start, end)
for a, b, upper, bottom in boxes)
def _passage(start, end, boxes, tracks, *, monotonic=False, forward_start=False, bounds=None):
"""Find a short sequence of unobstructed, slanted Bezier segments."""
points = [start, end]
for gap in (PASSAGE_GAP, PASSAGE_GAP * 2, PASSAGE_GAP * 4):
points.extend((x, y) for left, right, top, bottom in boxes
for x in (left - gap, right + gap) for y in (top + gap, bottom - gap))
points.extend((x, y) for left, right, top, bottom in boxes
for x in (left - (top - bottom) - PASSAGE_GAP, right + (top - bottom) + PASSAGE_GAP)
for y in (top + PASSAGE_GAP, bottom - PASSAGE_GAP))
points = list(dict.fromkeys(points))
if bounds is not None:
points = points[:2] + [point for point in points[2:] if bounds[0] <= point[0] <= bounds[1]]
direction = 1 if end[0] >= start[0] else -1
result = _search_passage(points, boxes, tracks, direction, forward_start)
if result is not None or monotonic:
return result
result = _search_passage(points, boxes, tracks, 0, forward_start)
if result is not None:
return result
raise ValueError("No unobstructed passage between calculation regions")
def _search_passage(points, boxes, tracks, direction, forward_start):
"""Prefer a continuous horizontal direction whenever obstacles permit it."""
costs, previous, pending = {0: 0}, {}, [(0, 0)]
while pending:
cost, index = heappop(pending)
if cost != costs[index]:
continue
if index == 1:
path = []
while index:
path.append(points[index])
index = previous[index]
return list(reversed(path))
x, y = points[index]
for target, (u, v) in enumerate(points):
if target == index or abs(u - x) < max(40, abs(v - y) * PASSAGE_SLOPE):
continue
if direction and (u - x) * direction <= 0:
continue
if forward_start and index == 0 and u <= x:
continue
if abs(v - y) < 0.01:
if u - x < PASSAGE_GAP:
continue
if any(a < u and b > x and abs(y - level) < PASSAGE_GAP
for a, b, level in tracks):
continue
distance = cost + hypot(u - x, v - y) + max(0, x - u) + PASSAGE_GAP
if distance >= costs.get(target, float("inf")) or _intersects((x, y), (u, v), boxes):
continue
costs[target], previous[target] = distance, index
heappush(pending, (distance, target))
return None
def route_field_passages(node_group, bodies):
"""Allow wires into their own calculations and route transit wires around frames."""
frames = [(members, (left - FRAME_PADDING, right + FRAME_PADDING,
top + FRAME_HEADING, bottom - FRAME_PADDING))
for members, (left, right, top, bottom) in bodies
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES]
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
for entry, end, members in node_zones(node_group):
if entry.bl_idname != "GeometryNodeRepeatInput":
continue
for node in members:
if node.bl_idname == "NodeReroute":
x, y = positions[node]
x = max(positions[entry][0] + sizes[entry].width + 40,
min(x, positions[end][0] - 40 - sizes[node].width))
node.location = positions[node] = (x, y)
repeats = repeat_regions(node_group, positions, sizes)
frames.extend(repeats)
for node in node_group.nodes:
if node.bl_idname != "NodeReroute":
continue
x, y = positions[node]
if not any(node not in members and left < x + 8 < right and bottom < y - 8 < top
for members, (left, right, top, bottom) in repeats):
continue
blocked = [box for members, box in frames if node not in members and box[0] < x + 8 < box[1]]
levels = {edge for _, _, top, bottom in blocked
for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP)}
level = min((level for level in levels if all(not bottom < level < top
for _, _, top, bottom in blocked)), key=lambda level: abs(level - y))
node.location = positions[node] = (x, level + 8)
for link in list(node_group.links):
if link.from_socket.type != "GEOMETRY" or link.from_node.bl_idname == "NodeReroute":
continue
start, end = link_endpoints(link, positions, sizes)
distance = start[0] - end[0]
if any(link.from_node in members and link.to_node in members for members, _ in repeats):
continue
if distance <= 2000:
continue
# Bound the right-facing handle before a long return towards the left.
run = distance * 0.08 + PASSAGE_GAP
offset = max(PASSAGE_GAP * 2, run * PASSAGE_SLOPE)
point = (start[0] - run, start[1] + (offset if end[1] > start[1] else -offset))
levels = {start[1] - offset, start[1] + offset,
*(edge for _, (_, _, top, bottom) in frames
for edge in (top + PASSAGE_GAP, bottom - PASSAGE_GAP))}
clear_levels = [level for level in levels
if abs(level - start[1]) >= PASSAGE_GAP and abs(level - end[1]) >= PASSAGE_GAP
and all(not (left < point[0] < right and bottom < level < top)
for _, (left, right, top, bottom) in frames)]
point = (point[0], min(clear_levels, key=lambda level: (abs(level - point[1]), -level)))
corner = node_group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner["arrangement_return"] = True
corner.location = (point[0] - 8, point[1] + 8)
positions[corner] = tuple(corner.location_absolute)
sizes[corner] = estimate_node_dimensions(corner)
node_group.links.new(link.from_socket, corner.inputs[0])
replace_link_source(node_group, link, corner.outputs[0])
tracks = []
for link in node_group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01:
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1]))
for link in list(node_group.links):
consumers, pending = set(), [link.to_node]
while pending:
node = pending.pop()
if node in consumers:
continue
consumers.add(node)
if node.bl_idname == "NodeReroute":
pending.extend(edge.to_node for edge in node.outputs[0].links)
source = origin_socket(link.from_socket).node
bounds = next(((box[0] + 60, box[1] - 60) for members, box in repeats
if source in members and consumers & members), None)
obstacles = [box for members, box in frames if source not in members and not consumers & members]
if (link.from_socket.type != "GEOMETRY" and not link.from_node.get("arrangement_reference")
and not link.to_node.get("arrangement_reference")):
obstacles = [box for members, box in repeats if source not in members and not consumers & members]
start, end = link_endpoints(link, positions, sizes)
if not _intersects(start, end, obstacles):
continue
try:
path = _passage(start, end, obstacles, tracks,
bounds=bounds,
forward_start=link.from_socket.type != "GEOMETRY"
and link.from_node.bl_idname != "NodeReroute")
except ValueError as error:
raise ValueError(f"Cannot route {source.name} to {link.to_node.name}: {start} -> {end}") from error
output, previous = link.from_socket, start
for point in path[:-1]:
corner = node_group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner.location = (point[0] - 8, point[1] + 8)
node_group.links.new(output, corner.inputs[0])
output = corner.outputs[0]
if abs(previous[1] - point[1]) < 0.01:
tracks.append((previous[0], point[0], point[1]))
previous = point
replace_link_source(node_group, link, output)
_simplify_passages(node_group, frames, bodies)
def _simplify_passages(node_group, frames, bodies):
"""Shortcut redundant turns without removing shared junctions or crossing frames."""
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
internal = set().union(*(members for _, _, members in node_zones(node_group)))
while True:
removed = False
for corner in list(node_group.nodes):
if corner.get("arrangement_return") or corner in internal:
continue
if not corner.get("arrangement_reference") or len(corner.inputs[0].links) != 1:
continue
if len(corner.outputs[0].links) != 1:
continue
incoming = corner.inputs[0].links[0]
start, _ = link_endpoints(incoming, positions, sizes)
points = [start]
chain, current = [], corner
while current.get("arrangement_reference") and len(current.outputs[0].links) == 1:
chain.append(current)
outgoing = current.outputs[0].links[0]
if outgoing in peer_links(outgoing.to_node):
break
middle, end = link_endpoints(outgoing, positions, sizes)
points.append(middle)
# A shortcut must remove an actual reversal, not a useful pipe approach.
forward_entry = (incoming.from_socket.type != "GEOMETRY"
and incoming.from_node.bl_idname != "NodeReroute")
if (not forward_entry or end[0] > start[0]) and any(
(b[0] - a[0]) * (end[0] - start[0]) < 0
for a, b in zip(points, points[1:] + [end])):
dx, dy = end[0] - start[0], end[1] - start[1]
consumers, pending = set(), [outgoing.to_node]
while pending:
node = pending.pop()
if node in consumers:
continue
consumers.add(node)
if node.bl_idname == "NodeReroute":
pending.extend(edge.to_node for edge in node.outputs[0].links)
source = origin_socket(incoming.from_socket).node
obstacles = [box for members, box in frames
if source not in members and not consumers & members]
horizontal = abs(dy) < 0.01
blocked = abs(dx) < max(40, abs(dy) * PASSAGE_SLOPE) or (horizontal and dx < PASSAGE_GAP)
if horizontal and not blocked:
for edge in node_group.links:
if edge.from_node in chain or edge.to_node in chain:
continue
a, b = link_endpoints(edge, positions, sizes)
if abs(a[1] - b[1]) < 0.01 and abs(a[1] - start[1]) < PASSAGE_GAP:
blocked |= min(a[0], b[0]) < end[0] and max(a[0], b[0]) > start[0]
path = [end] if not blocked and not _intersects(start, end, obstacles) else None
if path is None and abs(dx) >= max(40, abs(dy) * PASSAGE_SLOPE):
tracks = []
for edge in node_group.links:
if edge.from_node in chain or edge.to_node in chain:
continue
a, b = link_endpoints(edge, positions, sizes)
if abs(a[1] - b[1]) < 0.01:
tracks.append((min(a[0], b[0]), max(a[0], b[0]), a[1]))
path = _passage(start, end, obstacles, tracks, monotonic=True)
if path is not None:
output = incoming.from_socket
for point in path[:-1]:
node = node_group.nodes.new("NodeReroute")
node["arrangement_reference"] = True
node.location = (point[0] - 8, point[1] + 8)
positions[node] = tuple(node.location_absolute)
sizes[node] = estimate_node_dimensions(node)
node_group.links.new(output, node.inputs[0])
output = node.outputs[0]
replace_link_source(node_group, outgoing, output)
for members, _ in bodies:
members.difference_update(chain)
for node in reversed(chain):
node_group.nodes.remove(node)
removed = True
break
current = outgoing.to_node
if removed:
break
if not removed:
return

View File

@@ -0,0 +1,185 @@
"""Route shared results on peripheral tracks with local consumer branches."""
from collections import defaultdict
from ..common.nodes import replace_link_source
from .connections import curve_footprint, socket_position, link_endpoints
from .dimensions import estimate_node_dimensions
from .flow import geometry_paths, origin_socket, peer_links
from .frames import FRAME_PADDING, FRAME_HEADING, FRAME_MIN_NODES
from .zones import node_zones
TRACK_GAP = 96
PORT_RUN = 40
FRAME_CLEARANCE = 160
def route_references(node_group, bodies, *, peripheral_nodes=frozenset()):
"""Keep execution links direct and distribute shared results outside field regions."""
nodes = list(node_group.nodes)
repeats = [members for entry, _, members in node_zones(node_group)
if entry.bl_idname == "GeometryNodeRepeatInput"]
sizes = {node: estimate_node_dimensions(node) for node in nodes}
positions = {node: tuple(node.location_absolute) for node in nodes}
region_of = {node: index for index, (members, _) in enumerate(bodies) for node in members}
producer_regions = {index for index, (members, _) in enumerate(bodies) if members & peripheral_nodes}
boxes = [(node, (positions[node][0], positions[node][0] + sizes[node].width,
positions[node][1], positions[node][1] - sizes[node].height))
for node in nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}]
padding = FRAME_CLEARANCE - TRACK_GAP
regions = [(left - FRAME_PADDING - padding, right + FRAME_PADDING + padding,
top + FRAME_HEADING + padding, bottom - FRAME_PADDING - padding)
for _, (left, right, top, bottom) in bodies]
frames = {index for index, (members, _) in enumerate(bodies)
if sum(node.bl_idname not in {"NodeGroupInput", "NodeReroute"} for node in members) >= FRAME_MIN_NODES}
_, paths = geometry_paths(node_group)
execution = {(first, last) for path in paths for first, last in zip(path, path[1:])}
references = defaultdict(list)
for node in nodes:
if node.bl_idname == "NodeReroute" and not node.get("arrangement_input"):
continue
for socket in node.inputs:
for link in list(socket.links):
if link.from_node.get("arrangement_input"):
continue
source = origin_socket(link.from_socket)
if any(source.node in members and node in members for members in repeats):
continue
if source.node.bl_idname == "NodeGroupInput":
continue
if (source.node, node) in execution:
continue
if source.node in region_of and region_of[source.node] == region_of.get(node):
continue
start = socket_position(source, positions, sizes)
end = link_endpoints(link, positions, sizes)[1]
if link in peer_links(node):
end = (end[0], start[1])
distance = end[0] - start[0]
if socket.type != "GEOMETRY":
references[source, False].append((link, end))
continue
if 0 <= distance <= 480:
continue
if distance > 0 and (len(source.links) == 1 or distance <= 800):
footprint = curve_footprint(start, end)
endpoint_regions = {region_of[member] for member in (source.node, node) if member in region_of}
obstacles = [box for member, box in boxes if member not in {source.node, node}]
obstacles += [(left + padding, right - padding, top - padding, bottom + padding)
for index, (left, right, top, bottom) in enumerate(regions)
if index in frames and index not in endpoint_regions]
if all(not any(a < right and b > left and bottom < top and upper > lower
for left, right, top, lower in footprint)
for a, b, upper, bottom in obstacles):
if link.from_socket != source:
replace_link_source(node_group, link, source)
continue
references[source, distance < 0].append((link, end))
references = {key: deliveries for key, deliveries in references.items()
if key[0].type == "GEOMETRY" or len({link.to_node for link, _ in deliveries}) > 1}
tracks = []
replaced = {link for deliveries in references.values() for link, _ in deliveries}
wire_corridors = []
for link in node_group.links:
start, end = link_endpoints(link, positions, sizes)
if link.from_socket.type == "GEOMETRY" and link not in replaced:
wire_corridors.extend(curve_footprint(start, end))
if link.from_socket.type == "GEOMETRY" and abs(start[1] - end[1]) < 1 and abs(start[0] - end[0]) > PORT_RUN:
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1]))
def route(source, point):
node = node_group.nodes.new("NodeReroute")
node["arrangement_reference"] = True
node.location = (point[0] - 8, point[1] + 8)
node_group.links.new(source, node.inputs[0])
return node.outputs[0]
ordered = sorted(references, key=lambda item: (item[0].type != "GEOMETRY", item[0].node.name, item[0].identifier, item[1]))
for source, backward in ordered:
deliveries = references[source, backward]
start = socket_position(source, positions, sizes)
peripheral = len(deliveries) > 1
forward_pipe = peripheral and (source.type != "GEOMETRY" or source.node in peripheral_nodes)
level_entry = forward_pipe and source.type != "GEOMETRY"
source_region = region_of.get(source.node)
source_members = bodies[source_region][0] if source_region is not None else {source.node}
def path(level):
arrivals = [(end[0] - max(PORT_RUN, abs(level - end[1]) * 0.8), link, end)
for link, end in deliveries]
launch = min(x for x, _, _ in arrivals)
run = max(PORT_RUN, abs(level - start[1]) * 0.8)
launch = start[0] + run if forward_pipe or launch >= start[0] + run else min(launch, start[0] - run)
return launch, arrivals
endpoints = {source.node, *(link.to_node for link, _ in deliveries)}
obstacles = [box for node, box in boxes if node not in endpoints]
obstacles += [box for index, box in enumerate(regions) if not forward_pipe or index != source_region]
boundaries = [box for _, box in boxes] + regions if peripheral else obstacles
candidates = {edge for y in [start[1], *(end[1] for _, end in deliveries)]
for edge in (y - TRACK_GAP, y + TRACK_GAP)}
if level_entry:
candidates.add(start[1])
candidates.update({
*(edge for _, _, top, bottom in boundaries for edge in (top + TRACK_GAP, bottom - TRACK_GAP)),
*(edge for _, _, y in tracks for edge in (y + TRACK_GAP, y - TRACK_GAP))})
def clear(level):
launch, arrivals = path(level)
if forward_pipe and launch > min(x for x, _, _ in arrivals):
return False
if any(abs(level - y) < TRACK_GAP for y in [*([start[1]] if not level_entry else []), *(end[1] for _, end in deliveries)]):
return False
left = min(launch, *(x for x, _, _ in arrivals))
right = max(launch, *(x for x, _, _ in arrivals))
if peripheral:
scope_left = min(left, start[0], *(end[0] for _, end in deliveries))
scope_right = max(right, start[0], *(end[0] for _, end in deliveries))
scope = [box for box in [*(box for node, box in boxes if node not in source_members and node not in peripheral_nodes),
*(box for index, box in enumerate(regions) if index != source_region and index not in producer_regions)]
if box[0] < scope_right and box[1] > scope_left]
if scope and min(box[3] for box in scope) - TRACK_GAP < level < max(box[2] for box in scope) + TRACK_GAP:
return False
return (all(not bottom - TRACK_GAP < level < top + TRACK_GAP
for a, b, top, bottom in obstacles if a < right and b > left)
and all(abs(level - previous) >= TRACK_GAP - 0.01
for a, b, previous in tracks if a < right and b > left))
available = [y for y in candidates if clear(y)]
if not available:
raise ValueError(f"No forward reference channel for {source.node.name}")
def cost(level):
length = abs(level - start[1]) + sum(abs(level - end[1]) for _, end in deliveries)
launch, arrivals = path(level)
length += max(0, start[0] - launch) * 2
ramps = [(start, (launch, level))]
ramps += [((x, level), end) for x, _, end in arrivals]
footprints = [curve_footprint(a, b) for a, b in ramps]
crossings = sum(any(a < right and b > left and bottom < upper and top > lower
for left, right, upper, lower in footprint)
for node, (a, b, top, bottom) in boxes if node not in endpoints
for footprint in footprints)
overlaps = sum(min(right, b) - max(left, a) > TRACK_GAP
and max(lower, bottom) - min(upper, top) < TRACK_GAP
for footprint in footprints for left, right, upper, lower in footprint
if upper - lower < (right - left) * 0.25
for a, b, top, bottom in wire_corridors
if top - bottom < (b - a) * 0.25)
return overlaps * 4000 + crossings * 400 + length, abs(level - start[1]), -level
level = min(available, key=cost)
launch, arrivals = path(level)
root = route(source, (launch, level))
trunk, previous_x = root, launch
for x, link, end in sorted(arrivals, key=lambda item: item[0]):
if x - previous_x >= PORT_RUN * 2:
trunk = route(trunk, (x, level))
previous_x = x
actual_end = link_endpoints(link, positions, sizes)[1]
branch = route(trunk, (end[0] - PORT_RUN, end[1])) if end != actual_end else trunk
replace_link_source(node_group, link, branch)
tracks.append((min(launch, *(x for x, _, _ in arrivals)),
max(launch, *(x for x, _, _ in arrivals)), level))
wire_corridors.extend(curve_footprint(start, (launch, level)))
for x, _, end in arrivals:
wire_corridors.extend(curve_footprint((x, level), end))

View File

@@ -0,0 +1,116 @@
"""Reserve horizontal space for field calculations beside their geometry consumers."""
from collections import defaultdict
from .zones import node_zones, repeat_regions
def clear_repeat_regions(node_group, positions, sizes, bodies, *, gap):
"""Move unrelated calculation bodies and nodes outside reserved repeat areas."""
regions = repeat_regions(node_group, positions, sizes)
grouped = set().union(*(members for members, _ in bodies)) if bodies else set()
groups = [members for members, _ in bodies] + [{node} for node in positions if node not in grouped]
for members in groups:
obstacles = [box for owned, box in regions if not members & owned]
left = min(positions[node][0] for node in members)
right = max(positions[node][0] + sizes[node].width for node in members)
top = max(positions[node][1] for node in members)
bottom = min(positions[node][1] - sizes[node].height for node in members)
if not any(left < b and right > a and top > d and bottom < c for a, b, c, d in obstacles):
continue
occupied = obstacles + [(x, x + sizes[node].width, y, y - sizes[node].height)
for node, (x, y) in positions.items() if node not in members]
occupied = [box for box in occupied if left < box[1] + gap and right > box[0] - gap]
offsets = {edge for _, _, upper, lower in occupied
for edge in (upper + gap - bottom, lower - gap - top)}
targets = {link.to_node for node in members for socket in node.outputs for link in socket.links
if link.to_node not in members and link.to_node in positions}
above = (right - left < 1000 and len(targets) == 1
and bottom > positions[next(iter(targets))][1])
offset = min((offset for offset in offsets
if all(top + offset <= lower - gap + 1e-5 or bottom + offset >= upper + gap - 1e-5
for _, _, upper, lower in occupied)),
key=lambda offset: (above and bottom + offset <= positions[next(iter(targets))][1], abs(offset)))
for node in members:
x, y = positions[node]
positions[node] = (x, y + offset)
for index, (members, _) in enumerate(bodies):
bodies[index] = (members, (min(positions[node][0] for node in members),
max(positions[node][0] + sizes[node].width for node in members),
max(positions[node][1] for node in members),
min(positions[node][1] - sizes[node].height for node in members)))
def enclose_repeat_branches(node_group, positions, sizes, bodies, *, gap):
"""Leave real horizontal space between repeat boundaries and their calculations."""
for entry, end, members in node_zones(node_group):
if entry.bl_idname != "GeometryNodeRepeatInput":
continue
interior = (members - {entry, end}) & positions.keys()
if not interior:
continue
for boundary, direction in ((entry, -1), (end, 1)):
start = positions[boundary][0]
target = (min(positions[node][0] for node in interior) - gap - sizes[entry].width
if direction < 0 else
max(positions[node][0] + sizes[node].width for node in interior) + gap)
distance = direction * (target - start)
if distance <= 0:
continue
moving = {node for node, (x, _) in positions.items()
if node not in members and direction * (x - start) >= 0} | {boundary}
for body, _ in bodies:
if body & moving and not body & members:
moving.update(body)
for node in moving:
x, y = positions[node]
positions[node] = (x + direction * distance, y)
for index, (members, _) in enumerate(bodies):
bodies[index] = (members, (min(positions[node][0] for node in members),
max(positions[node][0] + sizes[node].width for node in members),
max(positions[node][1] for node in members),
min(positions[node][1] - sizes[node].height for node in members)))
def reserve_field_sections(node_group, layouts, positions, sizes, *, gap):
"""Extend execution stages to make room for substantial incoming calculations."""
stages = {node: node for node in positions}
for entry, _, members in node_zones(node_group):
for node in members & positions.keys():
stages[node] = entry
sections = defaultdict(list)
for region, owners, local, width, height in layouts:
if width < 1000 or not owners:
continue
owner = min(owners, key=lambda node: (positions[node][0], node.name))
sections[stages[owner]].append((region, owner, local, width, height))
front = max((width for _, _, _, width, _ in layouts), default=0)
bands = [min(x for x, _ in positions.values()) - front - gap] * 2
for stage in sorted(sections, key=lambda node: (positions[node][0], node.name)):
start = positions[stage][0]
pending = []
for region, owner, local, width, height in sorted(sections[stage], key=lambda item: -item[3]):
band = min(range(2), key=lambda index: bands[index])
left = bands[band] + gap
for node in region:
for socket in node.inputs:
for link in socket.links:
source = link.from_node
if source in positions and stages[source] != stage:
left = max(left, positions[source][0] + sizes[source].width + gap - local[node][0])
offsets = [local[node][1] - sizes[node].sockets[socket]
+ sizes[link.to_node].sockets[link.to_socket]
+ positions[owner][1] - positions[link.to_node][1]
for node in region for socket in node.outputs for link in socket.links
if link.to_node in positions]
above = max((abs(height + gap + offset) for offset in offsets), default=gap)
below = max((abs(-sizes[owner].height - gap + offset) for offset in offsets), default=gap)
exit_run = max(gap, min(above, below) * 0.8)
finish = max(positions[owner][0] - exit_run, left + width)
pending.append((owner, finish + exit_run))
bands[band] = finish
extra = max(finish - positions[owner][0] for owner, finish in pending)
if extra > 0:
for node, (x, y) in positions.items():
if x >= start:
positions[node] = (x + extra, y)

View File

@@ -0,0 +1,38 @@
"""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)

View File

@@ -3,6 +3,20 @@
from graphlib import TopologicalSorter from graphlib import TopologicalSorter
def repeat_regions(node_group, positions, sizes):
"""Bound repeat execution and calculations without peripheral routing corners."""
regions = []
for entry, end, members in node_zones(node_group):
if entry.bl_idname != "GeometryNodeRepeatInput":
continue
nodes = {node for node in members if node.bl_idname not in {"NodeReroute", "NodeFrame"}}
regions.append((members, (positions[entry][0] - 60,
positions[end][0] + sizes[end].width + 60,
max(positions[node][1] for node in nodes) + 60,
min(positions[node][1] - sizes[node].height for node in nodes) - 40)))
return regions
def node_zones(node_group): def node_zones(node_group):
"""Return each paired entry, exit, and its data-dependent node membership.""" """Return each paired entry, exit, and its data-dependent node membership."""
parents = {node: set() for node in node_group.nodes} parents = {node: set() for node in node_group.nodes}

View File

@@ -58,7 +58,8 @@ def check_asset_library(asset_path):
modifier = obj.modifiers.new("Asset Check", "NODES") modifier = obj.modifiers.new("Asset Check", "NODES")
modifier.node_group = node_group modifier.node_group = node_group
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0, values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0} "Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
"Split Threshold": 0.0}
for item in node_group.interface.items_tree: for item in node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values: if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
modifier[item.identifier] = values[item.name] modifier[item.identifier] = values[item.name]

View File

@@ -55,6 +55,12 @@ def smooth_boundary_camera(group, camera, protected=False):
links.new(camera, axes.inputs[0]) links.new(camera, axes.inputs[0])
depth = axes.outputs["Z"] depth = axes.outputs["Z"]
smoothed = smooth_boundary_depth(group, depth, protected) smoothed = smooth_boundary_depth(group, depth, protected)
return reconstruct_camera_depth(group, camera, depth, smoothed)
def reconstruct_camera_depth(group, camera, depth, smoothed):
"""Move each valid camera point along its original projection ray."""
nodes, links = group.nodes, group.links
changed = _math(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0) changed = _math(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0)
enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8)) enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8))
projected = nodes.new("ShaderNodeVectorMath") projected = nodes.new("ShaderNodeVectorMath")

View File

@@ -36,10 +36,8 @@ def taper_split_profile(group, original, geometry):
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
influence = boundary_influence(group, protected) influence = boundary_influence(group, protected)
controls = group_input(nodes, {"Edge Round", "Split Threshold"}) controls = group_input(nodes, {"Split Threshold"})
strength = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Edge Round"], 4)) band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0))
band = _math(group, "MINIMUM", _math(group, "MULTIPLY", influence, strength), 1)
band = _math(group, "MULTIPLY", band, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0))
retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0)) retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0))
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained) profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
return store_float_attribute(group, geometry, "o_balloon", profile) return store_float_attribute(group, geometry, "o_balloon", profile)

View File

@@ -155,15 +155,23 @@ def sample_face_camera(group, image):
return texture.outputs["Color"] return texture.outputs["Color"]
def split_depth_surface(group, geometry, face_camera, strength, uniform, *, def ray_distance(group, camera):
face_center=None, triangles=False): """Measure the distance from the camera origin to a camera-space point."""
"""Split adjacent face samples using their Z depth and base center spacing.""" length = group.nodes.new("ShaderNodeVectorMath")
length.operation = "LENGTH"
group.links.new(camera, length.inputs[0])
return length.outputs["Value"]
def split_depth_surface(group, geometry, face_camera, strength, reference_depth, *,
face_distance=None, face_center=None, triangles=False):
"""Split relative ray-distance jumps over the base face-center spacing."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
if face_center is None: if face_center is None:
face_center = nodes.new("GeometryNodeInputPosition").outputs[0] face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE") face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
axes = nodes.new("ShaderNodeSeparateXYZ") if face_distance is None:
links.new(face_camera, axes.inputs[0]) face_distance = ray_distance(group, face_camera)
centers, depths = [], [] centers, depths = [], []
for side in (0, 1): for side in (0, 1):
corner = nodes.new("GeometryNodeCornersOfEdge") corner = nodes.new("GeometryNodeCornersOfEdge")
@@ -172,7 +180,7 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"]) links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
for data_type, field, results in ( for data_type, field, results in (
("FLOAT_VECTOR", face_center, centers), ("FLOAT_VECTOR", face_center, centers),
("FLOAT", axes.outputs["Z"], depths), ("FLOAT", face_distance, depths),
): ):
sample = nodes.new("GeometryNodeFieldAtIndex") sample = nodes.new("GeometryNodeFieldAtIndex")
sample.data_type = data_type sample.data_type = data_type
@@ -185,13 +193,15 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
for index, center in enumerate(centers): for index, center in enumerate(centers):
links.new(center, distance.inputs[index]) links.new(center, distance.inputs[index])
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths)) delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
scaled = _math(group, "MULTIPLY", delta, uniform) local_distance = _math(group, "MAXIMUM", _math(group, "MULTIPLY", _math(group, "ADD", *depths), 0.5), 1e-8)
# Cross multiplication avoids division and stays invariant under uniform scaling. reference = _math(group, "MAXIMUM", reference_depth, 1e-8)
# Uniform Scale cancels between the distance jump and local spacing correction.
# Cross multiplication also avoids dividing by the local distance.
selected = _math( selected = _math(
group, group,
"GREATER_THAN", "GREATER_THAN",
_math(group, "MULTIPLY", scaled, strength), _math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength),
_math(group, "MULTIPLY", distance.outputs["Value"], 2.5), _math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5),
) )
selected = _math( selected = _math(
group, group,
@@ -352,7 +362,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
normal = nodes.new("GeometryNodeInputNormal") normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute") blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR" blur.data_type = "FLOAT_VECTOR"
links.new(normal_smooth, blur.inputs["Iterations"]) blur.inputs["Iterations"].default_value = normal_smooth
links.new(normal.outputs[0], blur.inputs["Value"]) links.new(normal.outputs[0], blur.inputs["Value"])
normalize = nodes.new("ShaderNodeVectorMath") normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE" normalize.operation = "NORMALIZE"

View File

@@ -11,6 +11,15 @@ def prepare_node_group(node_group):
node_group.nodes.remove(node) node_group.nodes.remove(node)
def replace_link_source(node_group, link, source):
"""Replace a source while preserving multi-input socket order."""
multi = link.to_socket.is_multi_input
replacement = node_group.links.new(source, link.to_socket)
if multi:
replacement.swap_multi_input_sort_id(link)
node_group.links.remove(link)
def interface_socket(group, name, in_out, socket_type, parent=None): def interface_socket(group, name, in_out, socket_type, parent=None):
socket = group.interface.new_socket( socket = group.interface.new_socket(
name=name, name=name,

View File

@@ -0,0 +1,98 @@
"""Adapt camera depth along the original outline with batched weight updates."""
from .boundary_smoothing import boundary_influence, reconstruct_camera_depth
from .depth_surface import CUT_ATTRIBUTE, _math, ray_distance
from .nodes import evaluate_field, group_input, read_float_attribute, sample_field
from .smoothing import edge_boundary_field
def _outline_influence(group, original, geometry):
"""Identify original outline points and protect split boundary intersections."""
nodes, links = group.nodes, group.links
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
nearest = nodes.new("GeometryNodeSampleNearest")
nearest.domain = "POINT"
links.new(original, nearest.inputs["Geometry"])
position = nodes.new("GeometryNodeInputPosition").outputs["Position"]
links.new(position, nearest.inputs["Sample Position"])
original_rim = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
original_rim = _math(group, "GREATER_THAN", original_rim, 0)
cut = _math(group, "GREATER_THAN", read_float_attribute(group, CUT_ATTRIBUTE), 0)
protected = _math(group, "MULTIPLY", _math(group, "GREATER_THAN", rim, 0),
_math(group, "MAXIMUM", cut, _math(group, "SUBTRACT", 1, original_rim)))
# Resolve planar correspondence before replacing positions with camera depth.
return sample_field(group, geometry, boundary_influence(group, protected), "FLOAT")
def smooth_outline_camera(group, original, geometry, camera, uniform_scale):
"""Update outline weights and blur ray distance in fixed-weight batches."""
nodes, links = group.nodes, group.links
controls = group_input(nodes, {"Outline Depth Fix", "Boundary Smooth"})
influence = _outline_influence(group, original, geometry)
depth = ray_distance(group, camera)
endpoints = nodes.new("GeometryNodeInputMeshEdgeVertices")
length = nodes.new("ShaderNodeVectorMath")
length.operation = "DISTANCE"
links.new(endpoints.outputs["Position 1"], length.inputs[0])
links.new(endpoints.outputs["Position 2"], length.inputs[1])
statistic = nodes.new("GeometryNodeAttributeStatistic")
statistic.data_type, statistic.domain = "FLOAT", "EDGE"
links.new(geometry, statistic.inputs["Geometry"])
links.new(length.outputs["Value"], statistic.inputs["Attribute"])
scale = _math(group, "DIVIDE", uniform_scale,
_math(group, "MAXIMUM", statistic.outputs["Median"], 1e-8))
seed = nodes.new("ShaderNodeCombineXYZ")
links.new(depth, seed.inputs["Z"])
carrier = nodes.new("GeometryNodeSetPosition")
links.new(geometry, carrier.inputs["Geometry"])
links.new(seed.outputs[0], carrier.inputs["Position"])
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
links.new(carrier.outputs[0], repeat_input.inputs["Geometry"])
links.new(controls.outputs["Outline Depth Fix"], repeat_input.inputs["Iterations"])
position = nodes.new("GeometryNodeInputPosition")
current = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], current.inputs[0])
reference = nodes.new("GeometryNodeBlurAttribute")
reference.data_type = "FLOAT"
reference.inputs["Iterations"].default_value = 1
reference.inputs["Weight"].default_value = 0.5
links.new(current.outputs["Z"], reference.inputs["Value"])
residual = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", current.outputs["Z"], reference.outputs[0]))
ratio = _math(group, "MULTIPLY", residual, scale)
weight = nodes.new("ShaderNodeMapRange")
weight.interpolation_type, weight.clamp = "SMOOTHSTEP", True
weight.inputs["From Min"].default_value = 0.25
weight.inputs["From Max"].default_value = 0.5
links.new(ratio, weight.inputs["Value"])
active = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", depth, 1e-8))
active = sample_field(group, geometry, active, "FLOAT")
weighted = _math(group, "MULTIPLY", weight.outputs[0], active)
# Sampling the round's input prevents the inner Blur from updating its weight.
frozen = sample_field(group, repeat_input.outputs["Geometry"], weighted, "FLOAT")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT"
links.new(current.outputs["Z"], blur.inputs["Value"])
links.new(frozen, blur.inputs["Weight"])
links.new(controls.outputs["Boundary Smooth"], blur.inputs["Iterations"])
updated = nodes.new("ShaderNodeCombineXYZ")
links.new(blur.outputs[0], updated.inputs["Z"])
move = nodes.new("GeometryNodeSetPosition")
links.new(repeat_input.outputs["Geometry"], move.inputs["Geometry"])
links.new(updated.outputs[0], move.inputs["Position"])
links.new(move.outputs[0], repeat_output.inputs["Geometry"])
result = sample_field(group, repeat_output.outputs["Geometry"], current.outputs["Z"], "FLOAT")
enabled = _math(group, "MULTIPLY", active,
_math(group, "MULTIPLY", controls.outputs["Outline Depth Fix"],
controls.outputs["Boundary Smooth"]))
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "FLOAT"
links.new(enabled, choose.inputs["Switch"])
links.new(depth, choose.inputs["False"])
links.new(result, choose.inputs["True"])
return reconstruct_camera_depth(group, camera, depth, choose.outputs[0])

View File

@@ -51,7 +51,6 @@ def expand_smooth(
repeat_output = nodes.new("GeometryNodeRepeatOutput") repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput") repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output) repeat_input.pair_with_output(repeat_output)
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
links.new( links.new(
iteration_controls.outputs[iterations_name], iteration_controls.outputs[iterations_name],
repeat_input.inputs["Iterations"], repeat_input.inputs["Iterations"],
@@ -109,11 +108,6 @@ def expand_smooth(
links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"]) links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"])
links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"]) links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"])
links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"]) links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"])
boundary_mix = nodes.new("ShaderNodeMix")
boundary_mix.data_type = "VECTOR"
boundary_mix.inputs[0].default_value = 1.0
links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4])
links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5])
weight = weight_controls.outputs[weight_name] weight = weight_controls.outputs[weight_name]
if pin_sharp: if pin_sharp:
@@ -126,15 +120,10 @@ def expand_smooth(
normal_coherence = nodes.new("ShaderNodeVectorMath") normal_coherence = nodes.new("ShaderNodeVectorMath")
normal_coherence.operation = "LENGTH" normal_coherence.operation = "LENGTH"
links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0]) links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0])
sharp_switch = nodes.new("GeometryNodeSwitch")
sharp_switch.input_type = "FLOAT"
sharp_switch.inputs["Switch"].default_value = pin_sharp
sharp_switch.inputs["False"].default_value = 1.0
links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"])
smooth_weight = nodes.new("ShaderNodeMath") smooth_weight = nodes.new("ShaderNodeMath")
smooth_weight.operation = "MULTIPLY" smooth_weight.operation = "MULTIPLY"
links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0]) links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0])
links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) links.new(normal_coherence.outputs["Value"], smooth_weight.inputs[1])
weight = smooth_weight.outputs[0] weight = smooth_weight.outputs[0]
if influence is not None: if influence is not None:
masked_weight = nodes.new("ShaderNodeMath") masked_weight = nodes.new("ShaderNodeMath")
@@ -143,17 +132,12 @@ def expand_smooth(
links.new(influence, masked_weight.inputs[1]) links.new(influence, masked_weight.inputs[1])
weight = masked_weight.outputs[0] weight = masked_weight.outputs[0]
target_position = nodes.new("GeometryNodeSwitch")
target_position.input_type = "VECTOR"
target_position.inputs["Switch"].default_value = True
links.new(smooth_all.outputs["Value"], target_position.inputs["False"])
links.new(boundary_mix.outputs["Result"], target_position.inputs["True"])
current_position = nodes.new("GeometryNodeInputPosition") current_position = nodes.new("GeometryNodeInputPosition")
mix_position = nodes.new("ShaderNodeMix") mix_position = nodes.new("ShaderNodeMix")
mix_position.data_type = "VECTOR" mix_position.data_type = "VECTOR"
links.new(weight, mix_position.inputs[0]) links.new(weight, mix_position.inputs[0])
links.new(current_position.outputs["Position"], mix_position.inputs[4]) links.new(current_position.outputs["Position"], mix_position.inputs[4])
links.new(target_position.outputs["Output"], mix_position.inputs[5]) links.new(choose_boundary.outputs["Output"], mix_position.inputs[5])
set_position = nodes.new("GeometryNodeSetPosition") set_position = nodes.new("GeometryNodeSetPosition")
links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"]) links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"])
links.new(mix_position.outputs["Result"], set_position.inputs["Position"]) links.new(mix_position.outputs["Result"], set_position.inputs["Position"])

View File

@@ -110,7 +110,7 @@ def build_image_depth_balloon_group():
group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True) group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY' group.color_tag = 'GEOMETRY'
group['anyimage_asset_revision'] = 11 group['anyimage_asset_revision'] = 12
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
float_input(group, 'Depth Scale', 1.0) float_input(group, 'Depth Scale', 1.0)
@@ -120,8 +120,8 @@ def build_image_depth_balloon_group():
iterations.min_value, iterations.max_value = (0, 20) iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True) options = group.interface.new_panel(name='Options', default_closed=True)
float_input(group, 'Cleanup Threshold', 0.0, parent=options) float_input(group, 'Cleanup Threshold', 0.0, parent=options)
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options)
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options) vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options)
data = group.interface.new_panel(name='Data', default_closed=True) data = group.interface.new_panel(name='Data', default_closed=True)
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True

View File

@@ -4,7 +4,8 @@ from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
from ..common.smoothing import expand_smooth from ..common.smoothing import expand_smooth
from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.outline_smoothing import smooth_outline_camera
from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface
from ..common.smoothing import edge_boundary_field from ..common.smoothing import edge_boundary_field
@@ -72,83 +73,33 @@ def build_volume_fields(group):
return {"rim": rim, "center": center, "thickness": thickness} return {"rim": rim, "center": center, "thickness": thickness}
def build_volume_rounding(group, geometry, fields, uniform, normal_weight=1): def build_shell_fields(group, geometry, fields, uniform, normal_weight=1):
links = group.links """Evaluate rear directions on the center surface and select shell thickness."""
nodes, links = group.nodes, group.links
control = group.nodes.new("NodeGroupInput") position = nodes.new("GeometryNodeInputPosition")
profile = group.nodes.new("GeometryNodeInputNamedAttribute") axes = nodes.new("ShaderNodeSeparateXYZ")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
balloon = profile.outputs["Attribute"]
maximum = group.nodes.new("GeometryNodeAttributeStatistic")
maximum.data_type, maximum.domain = "FLOAT", "POINT"
links.new(geometry, maximum.inputs["Geometry"])
links.new(balloon, maximum.inputs["Attribute"])
limit = _math(group, "MAXIMUM", maximum.outputs["Max"], 1e-8)
falloff = group.nodes.new("ShaderNodeMapRange")
falloff.interpolation_type = "SMOOTHERSTEP"
falloff.inputs["To Min"].default_value = 1
falloff.inputs["To Max"].default_value = 0
links.new(balloon, falloff.inputs["Value"])
links.new(limit, falloff.inputs["From Max"])
weight = _math(group, "MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"])
center = fields["center"]
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0]) links.new(position.outputs[0], axes.inputs[0])
target = _math(group, "ADD", center, balloon) center_position = nodes.new("ShaderNodeCombineXYZ")
delta = _math(group, "SUBTRACT", target, axes.outputs["Z"])
delta = _math(group, "MULTIPLY", delta, weight)
enabled = _math(group, "MINIMUM", control.outputs["Thickness"], 1)
enabled = _math(group, "MAXIMUM", enabled, 0)
delta = _math(group, "MULTIPLY", delta, enabled)
rear = _math(group, "MULTIPLY", balloon, weight)
rear = _math(group, "MULTIPLY", rear, control.outputs["Thickness"])
thickness = fields["thickness"]
total = _math(group, "ADD", thickness, delta)
total = _math(group, "ADD", total, rear)
# Evaluate normals on a separate center surface with identical point indices.
center_position = group.nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Y"): for axis in ("X", "Y"):
links.new(axes.outputs[axis], center_position.inputs[axis]) links.new(axes.outputs[axis], center_position.inputs[axis])
links.new(center, center_position.inputs["Z"]) links.new(fields["center"], center_position.inputs["Z"])
surface = group.nodes.new("GeometryNodeSetPosition") surface = nodes.new("GeometryNodeSetPosition")
links.new(geometry, surface.inputs["Geometry"]) links.new(geometry, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"]) links.new(center_position.outputs[0], surface.inputs["Position"])
normal = group.nodes.new("GeometryNodeInputNormal") normal = nodes.new("GeometryNodeInputNormal")
blur = group.nodes.new("GeometryNodeBlurAttribute") blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR" blur.data_type = "FLOAT_VECTOR"
blur.inputs["Iterations"].default_value = 100
links.new(normal.outputs[0], blur.inputs["Value"]) links.new(normal.outputs[0], blur.inputs["Value"])
local = group.nodes.new("NodeGroupInput")
links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"])
normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
index = group.nodes.new("GeometryNodeInputIndex") rear_normal = sample_field(group, surface.outputs[0], normalized, "FLOAT_VECTOR")
sample = group.nodes.new("GeometryNodeSampleIndex") control = group_input(nodes, {"Uniform Thickness"})
sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT" thickness = nodes.new("GeometryNodeSwitch")
links.new(surface.outputs[0], sample.inputs["Geometry"])
links.new(normalized, sample.inputs["Value"])
links.new(index.outputs[0], sample.inputs["Index"])
thickness = group.nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT" thickness.input_type = "FLOAT"
links.new(uniform, thickness.inputs["Switch"]) links.new(uniform, thickness.inputs["Switch"])
links.new(total, thickness.inputs["False"]) links.new(fields["thickness"], thickness.inputs["False"])
links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"]) links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"])
rounding = { return {"thickness": thickness.outputs[0], "normal": rear_normal}
"edge_weight": weight, "front_delta": delta,
"thickness": thickness.outputs[0], "normal": sample.outputs[0],
}
displacement = group.nodes.new("ShaderNodeCombineXYZ")
links.new(delta, displacement.inputs["Z"])
front = group.nodes.new("GeometryNodeSetPosition")
links.new(geometry, front.inputs["Geometry"])
links.new(displacement.outputs[0], front.inputs["Offset"])
choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(uniform, choose.inputs["Switch"])
links.new(front.outputs[0], choose.inputs["False"])
links.new(geometry, choose.inputs["True"])
return choose.outputs[0], rounding
def build_front_fields(group, rim, front_normal, normal_weight=1): def build_front_fields(group, rim, front_normal, normal_weight=1):
@@ -186,14 +137,10 @@ def build_front_fields(group, rim, front_normal, normal_weight=1):
structure = _math(group, "MAXIMUM", structure, 0) structure = _math(group, "MAXIMUM", structure, 0)
normal = front_normal normal = front_normal
controls = group.nodes.new("NodeGroupInput")
# Keep the accepted broad front-direction filter at the default of 50;
# zero disables both this filter and the initial front/back normal filters.
spread = _math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50)
smooth = group.nodes.new("GeometryNodeBlurAttribute") smooth = group.nodes.new("GeometryNodeBlurAttribute")
smooth.data_type = "FLOAT_VECTOR" smooth.data_type = "FLOAT_VECTOR"
links.new(normal, smooth.inputs["Value"]) links.new(normal, smooth.inputs["Value"])
links.new(spread, smooth.inputs["Iterations"]) smooth.inputs["Iterations"].default_value = 512
normalized = blend_normal(group, normal, smooth.outputs[0], normal_weight) normalized = blend_normal(group, normal, smooth.outputs[0], normal_weight)
return {"structure": structure, "inflation_normal": normalized} return {"structure": structure, "inflation_normal": normalized}
@@ -247,17 +194,21 @@ def build_inflation_position(group, position, distance, uniform, fields):
result.operation = "ADD" result.operation = "ADD"
links.new(position, result.inputs[0]) links.new(position, result.inputs[0])
links.new(offset.outputs[0], result.inputs[1]) links.new(offset.outputs[0], result.inputs[1])
return result.outputs[0] enabled = group.nodes.new("GeometryNodeSwitch")
enabled.input_type = "VECTOR"
links.new(control.outputs["Front Inflation"], enabled.inputs["Switch"])
links.new(position, enabled.inputs["False"])
links.new(result.outputs[0], enabled.inputs["True"])
return enabled.outputs[0]
def build_front_normal(group, normal_weight): def build_front_normal(group, normal_weight):
links = group.links links = group.links
normal = group.nodes.new("GeometryNodeInputNormal") normal = group.nodes.new("GeometryNodeInputNormal")
control = group.nodes.new("NodeGroupInput")
blur = group.nodes.new("GeometryNodeBlurAttribute") blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR" blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"]) links.new(normal.outputs[0], blur.inputs["Value"])
links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"]) blur.inputs["Iterations"].default_value = 100
normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight) normalized = blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT") return evaluate_field(group, normalized, "FLOAT_VECTOR", "POINT")
@@ -291,35 +242,14 @@ def build_shell_position(group, position, front, uniform, fields):
thin_offset = vector("SCALE", normal, distance) thin_offset = vector("SCALE", normal, distance)
uniform_position = vector("ADD", front, thin_offset) uniform_position = vector("ADD", front, thin_offset)
# R = t * (front_z - center) + (1 - t) * front_delta, with t clamped. control = group_input(group.nodes, {"Thickness"})
# This avoids division by Thickness and removes the old zero-thickness drift.
control = group.nodes.new("NodeGroupInput")
clamp = group.nodes.new("ShaderNodeClamp") clamp = group.nodes.new("ShaderNodeClamp")
links.new(control.outputs["Thickness"], clamp.inputs["Value"]) links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0] t = clamp.outputs[0]
center = fields["center"] radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
delta = fields["front_delta"] rear = _math(group, "SUBTRACT", 1, read_float_attribute(group, "o_normal_scale"))
radius = _math(group, "SUBTRACT", front_z, center) rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
radius = _math(group, "MULTIPLY", radius, t) direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
remaining = _math(group, "SUBTRACT", 1, t)
remainder = _math(group, "MULTIPLY", remaining, delta)
radius = _math(group, "ADD", radius, remainder)
# Front and rear use the same edge field, evaluated on their own caps.
edge = fields["edge_weight"]
mask = read_float_attribute(group, "o_normal_scale")
remaining = _math(group, "SUBTRACT", 1, edge)
remaining = _math(group, "MULTIPLY", remaining, mask)
weight = _math(group, "SUBTRACT", 1, remaining)
difference = _math(group, "SUBTRACT", weight, edge)
base = _math(group, "MULTIPLY", difference, radius)
layer = _math(group, "MULTIPLY", weight, distance)
rear_radius = _math(group, "ADD", base, layer)
front_radius = _math(group, "MULTIPLY", edge, radius)
normal = fields["normal"]
direction = vector("SUBTRACT", normal, (0, 0, 1))
front_shift = vector("SCALE", direction, front_radius)
front_position = vector("ADD", front, front_shift)
rear_shift = vector("SCALE", direction, rear_radius) rear_shift = vector("SCALE", direction, rear_radius)
axis_offset = group.nodes.new("ShaderNodeCombineXYZ") axis_offset = group.nodes.new("ShaderNodeCombineXYZ")
links.new(distance, axis_offset.inputs["Z"]) links.new(distance, axis_offset.inputs["Z"])
@@ -334,7 +264,7 @@ def build_shell_position(group, position, front, uniform, fields):
links.new(transition.outputs[0], mix.inputs[0]) links.new(transition.outputs[0], mix.inputs[0])
links.new(thin_offset, mix.inputs[4]) links.new(thin_offset, mix.inputs[4])
links.new(full_offset, mix.inputs[5]) links.new(full_offset, mix.inputs[5])
balloon_position = vector("ADD", front_position, mix.outputs["Result"]) balloon_position = vector("ADD", front, mix.outputs["Result"])
choose = group.nodes.new("GeometryNodeSwitch") choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "VECTOR" choose.input_type = "VECTOR"
links.new(uniform, choose.inputs["Switch"]) links.new(uniform, choose.inputs["Switch"])
@@ -351,11 +281,11 @@ def _build_depth_surface(group, geometry, controls):
scale = controls.outputs["Uniform Scale"] scale = controls.outputs["Uniform Scale"]
original = geometry original = geometry
geometry, corner_camera, cut_vertex = split_depth_surface( geometry, corner_camera, cut_vertex = split_depth_surface(
group, geometry, sample_face_camera(group, image), split, scale, triangles=True, group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"], triangles=True,
) )
geometry = remove_boundary_triangles(group, geometry) geometry = remove_boundary_triangles(group, geometry)
camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split) camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split)
camera = smooth_boundary_camera(group, camera) camera = smooth_outline_camera(group, original, geometry, camera, scale)
projection = project_depth_surface( projection = project_depth_surface(
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split, group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split,
) )
@@ -370,10 +300,10 @@ def _build_depth_surface(group, geometry, controls):
normal_weight = build_normal_weight(group, uniform) normal_weight = build_normal_weight(group, uniform)
normal = build_front_normal(group, normal_weight) normal = build_front_normal(group, normal_weight)
front = build_front_fields(group, volume["rim"], normal, normal_weight) front = build_front_fields(group, volume["rim"], normal, normal_weight)
rounded, rounding = build_volume_rounding(group, projection, volume, uniform, normal_weight) shell_fields = build_shell_fields(group, projection, volume, uniform, normal_weight)
thickness = sample_field(group, projection, rounding["thickness"], "FLOAT") thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT")
shell = thicken_depth_surface( shell = thicken_depth_surface(
group, rounded, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True, group, projection, thickness, back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
) )
fields = { fields = {
name: sample_field(group, projection, value, data_type, index=shell["source_index"]) name: sample_field(group, projection, value, data_type, index=shell["source_index"])
@@ -385,7 +315,7 @@ def _build_depth_surface(group, geometry, controls):
fields.update({ fields.update({
name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT", name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT",
index=shell["source_index"]) index=shell["source_index"])
for name, value in rounding.items() for name, value in shell_fields.items()
}) })
oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields) oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields)
links.new(oriented, shell["position_input"]) links.new(oriented, shell["position_input"])
@@ -405,7 +335,7 @@ def build_image_depth_cutout_group():
group = bpy.data.node_groups.new(name, 'GeometryNodeTree') group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True) group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY' group.color_tag = 'GEOMETRY'
group['anyimage_asset_revision'] = 18 group['anyimage_asset_revision'] = 23
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
@@ -416,17 +346,16 @@ def build_image_depth_cutout_group():
iterations.default_value = 0 iterations.default_value = 0
iterations.min_value, iterations.max_value = (0, 20) iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True) options = group.interface.new_panel(name='Options', default_closed=True)
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20
boundary_smooth.description = 'Smooth boundary depth and geometry along the original outline and split edges'
float_input(group, 'Cleanup Threshold', 0.0, parent=options) float_input(group, 'Cleanup Threshold', 0.0, parent=options)
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options)
float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options)
normal_iterations = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
normal_iterations.default_value = 50
normal_iterations.min_value, normal_iterations.max_value = (0, 50)
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options) float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options)
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 1, 20
boundary_smooth.description = 'Boundary geometry iterations and depth blur steps per outline smoothing round'
outline_smooth = interface_socket(group, 'Outline Depth Fix', 'INPUT', 'NodeSocketInt', options)
outline_smooth.default_value, outline_smooth.min_value, outline_smooth.max_value = 16, 0, 200
outline_smooth.description = 'Adaptive ray-distance smoothing rounds around the original outline; preserves split boundary distance'
float_input(group, 'Front Inflation', 0.0, maximum=1, subtype='FACTOR', parent=options)
data = group.interface.new_panel(name='Data', default_closed=True) data = group.interface.new_panel(name='Data', default_closed=True)
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True

View File

@@ -39,7 +39,7 @@ def _quad_sphere(group, segments):
def build_image_depth_panorama_group(): def build_image_depth_panorama_group():
group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree") group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
group.is_modifier = group.use_fake_user = True group.is_modifier = group.use_fake_user = True
group["anyimage_asset_revision"] = 14 group["anyimage_asset_revision"] = 15
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8 subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8
@@ -57,7 +57,6 @@ def build_image_depth_panorama_group():
float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options) float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options)
data = group.interface.new_panel("Data", default_closed=True) data = group.interface.new_panel("Data", default_closed=True)
interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
float_input(group, "Reference Distance", 1, minimum=1e-6, subtype="DISTANCE", parent=data).hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
@@ -151,11 +150,15 @@ def build_image_depth_panorama_group():
links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"]) links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"])
links.new(delete.outputs[0], selection.inputs["False"]) links.new(delete.outputs[0], selection.inputs["False"])
links.new(culled, selection.inputs["True"]) links.new(culled, selection.inputs["True"])
controls = group_input(nodes, {"Split Threshold", "Reference Distance"}) controls = group_input(nodes, {"Split Threshold"})
strength = controls.outputs["Split Threshold"] strength = controls.outputs["Split Threshold"]
geometry, corner_camera, cut_vertex = split_depth_surface(group, selection.outputs[0], depth(combine(face_u, face_v))[0], strength, face_camera = depth(combine(face_u, face_v))[0]
_math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]), radial = nodes.new("ShaderNodeSeparateXYZ")
face_center=face.outputs["Value"]) links.new(face_camera, radial.inputs[0])
geometry, corner_camera, cut_vertex = split_depth_surface(
group, selection.outputs[0], face_camera, strength, 1.0,
face_distance=radial.outputs["X"], face_center=face.outputs["Value"],
)
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength) distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
axes = nodes.new("ShaderNodeSeparateXYZ") axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(distance, axes.inputs[0]) links.new(distance, axes.inputs[0])

View File

@@ -25,7 +25,7 @@ def build_image_depth_plane_group(image_plane_group):
"GeometryNodeTree", "GeometryNodeTree",
) )
group.is_modifier = True group.is_modifier = True
group["anyimage_asset_revision"] = 14 group["anyimage_asset_revision"] = 16
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry") geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE" geometry.structure_type = "SINGLE"
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt") subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
@@ -40,35 +40,18 @@ def build_image_depth_plane_group(image_plane_group):
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
depth_scale.default_value = 1.0 depth_scale.default_value = 1.0
depth_scale.min_value = 0.0 depth_scale.min_value = 0.0
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR") float_input(group, "Split Threshold", 0.1, maximum=1, subtype="FACTOR")
iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
iterations.default_value = 0 iterations.default_value = 0
iterations.min_value, iterations.max_value = 0, 20 iterations.min_value, iterations.max_value = 0, 20
valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool") valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool")
valid_only.default_value = True valid_only.default_value = True
options = group.interface.new_panel(name="Options", default_closed=True) options = group.interface.new_panel(name="Options", default_closed=True)
float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options)
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options) boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 5, 0, 20 boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 20
boundary_smooth.description = "Smooth boundary depth and cut-edge geometry while preserving the original image border" boundary_smooth.description = "Smooth boundary depth and cut-edge geometry while preserving the original image border"
base_plane_depth = _interface_socket(
group,
"Reference Depth",
"INPUT",
"NodeSocketFloat",
parent=options,
)
base_plane_depth.default_value = 1.0
base_plane_depth.min_value = 0.0
base_plane_depth.subtype = "DISTANCE"
normal_smooth = _interface_socket(
group, "Normal Smooth", "INPUT", "NodeSocketInt", parent=options,
)
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 50, 0, 50
normal_smooth.description = "Smooth the shell thickness direction"
smooth_weight = _interface_socket(group, "Smooth Weight", "INPUT", "NodeSocketFloat", parent=options)
smooth_weight.default_value = 1.0
smooth_weight.min_value, smooth_weight.max_value = 0.0, 1.0
smooth_weight.subtype = "FACTOR"
validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options) validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options)
validity_threshold.default_value = 0.9 validity_threshold.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
@@ -134,9 +117,10 @@ def build_image_depth_plane_group(image_plane_group):
links.new(culled, cull.inputs["True"]) links.new(culled, cull.inputs["True"])
links.new(protected, cull.inputs["False"]) links.new(protected, cull.inputs["False"])
split_input = _group_input(nodes, {"Split Threshold"}) split_input = _group_input(nodes, {"Split Threshold"})
reference_input = _group_input(nodes, {"Reference Depth"})
geometry, corner_camera, cut_vertex = split_depth_surface( geometry, corner_camera, cut_vertex = split_depth_surface(
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]), group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
split_input.outputs["Split Threshold"], scale_input.outputs["Uniform Scale"], split_input.outputs["Split Threshold"], reference_input.outputs["Reference Depth"],
) )
camera = build_surface_camera( camera = build_surface_camera(
group, texture.outputs["Color"], corner_camera, cut_vertex, group, texture.outputs["Color"], corner_camera, cut_vertex,
@@ -150,10 +134,10 @@ def build_image_depth_plane_group(image_plane_group):
depth_input.outputs["Depth Scale"], split_input.outputs["Split Threshold"], depth_input.outputs["Depth Scale"], split_input.outputs["Split Threshold"],
) )
surface = smooth_cut_boundary(group, surface, original_boundary) surface = smooth_cut_boundary(group, surface, original_boundary)
shell_input = _group_input(nodes, {"Thickness", "Normal Smooth"}) shell_input = _group_input(nodes, {"Thickness"})
shell = thicken_depth_surface( shell = thicken_depth_surface(
group, surface, shell_input.outputs["Thickness"], group, surface, shell_input.outputs["Thickness"],
normal_smooth=shell_input.outputs["Normal Smooth"], normal_smooth=100,
) )
positive = nodes.new("ShaderNodeMath") positive = nodes.new("ShaderNodeMath")
positive.operation = "GREATER_THAN" positive.operation = "GREATER_THAN"

View File

@@ -9,7 +9,7 @@ import bpy
from ..arrangement.dimensions import estimate_node_dimensions from ..arrangement.dimensions import estimate_node_dimensions
from ..arrangement.flow import node_roles from ..arrangement.flow import node_roles
from ..arrangement.connections import link_endpoints from ..arrangement.connections import link_endpoints
from ..arrangement.zones import node_zones from ..arrangement.zones import node_zones, repeat_regions
def _display_value(value): def _display_value(value):
@@ -30,7 +30,7 @@ def _display_value(value):
def _node_group_data(node_group): def _node_group_data(node_group):
nodes = list(node_group.nodes) nodes = [node for node in node_group.nodes if node.bl_idname != "NodeFrame"]
ids = {node: index for index, node in enumerate(nodes)} ids = {node: index for index, node in enumerate(nodes)}
dimensions = {node: estimate_node_dimensions(node) for node in nodes} dimensions = {node: estimate_node_dimensions(node) for node in nodes}
roles = node_roles(node_group) roles = node_roles(node_group)
@@ -56,28 +56,39 @@ def _node_group_data(node_group):
sockets.append([socket.name, round(offset, 2), int(socket.is_output), socket.type, default]) sockets.append([socket.name, round(offset, 2), int(socket.is_output), socket.type, default])
records.append({"id": ids[node], "name": node.name, "title": title, records.append({"id": ids[node], "name": node.name, "title": title,
"type": node.bl_idname, "role": roles[node], "type": node.bl_idname, "role": roles[node],
"x": round(node.location.x, 2), "y": round(-node.location.y, 2), "x": round(node.location_absolute.x, 2), "y": round(-node.location_absolute.y, 2),
"w": size.width, "h": size.height, "folded": bool(node.hide), "w": size.width, "h": size.height, "folded": bool(node.hide),
"settings": settings, "sockets": sockets}) "settings": settings, "sockets": sockets})
links = [] links = []
positions = {node: tuple(node.location) for node in nodes} positions = {node: tuple(node.location_absolute) for node in nodes}
for link in node_group.links: for link in node_group.links:
source, target = link.from_node, link.to_node source, target = link.from_node, link.to_node
(x1, y1), (x2, y2) = link_endpoints(link, positions, dimensions) (x1, y1), (x2, y2) = link_endpoints(link, positions, dimensions)
links.append([ids[source], ids[target], *[round(v, 2) for v in (x1, -y1, x2, -y2)], link.from_socket.type]) links.append([ids[source], ids[target], *[round(v, 2) for v in (x1, -y1, x2, -y2)], link.from_socket.type])
zones = [] zones = []
repeats = {frozenset(members): box for members, box in repeat_regions(node_group, positions, dimensions)}
for entry, end, members in node_zones(node_group): for entry, end, members in node_zones(node_group):
if entry.bl_idname != "GeometryNodeRepeatInput":
members = {node for node in members if roles[node] == "geometry"} | {entry, end}
boxes = [records[ids[node]] for node in members] boxes = [records[ids[node]] for node in members]
left = min(node["x"] for node in boxes) - 40 padding = 60 if entry.bl_idname == "GeometryNodeRepeatInput" else 40
left = min(node["x"] for node in boxes) - padding
top = min(node["y"] for node in boxes) - 60 top = min(node["y"] for node in boxes) - 60
right = max(node["x"] + node["w"] for node in boxes) + 40 right = max(node["x"] + node["w"] for node in boxes) + padding
bottom = max(node["y"] + node["h"] for node in boxes) + 40 bottom = max(node["y"] + node["h"] for node in boxes) + 40
if entry.bl_idname == "GeometryNodeRepeatInput":
left, right, upper, lower = repeats[frozenset(members)]
top, bottom = -upper, -lower
zones.append({"title": entry.bl_label.removesuffix(" Input"), zones.append({"title": entry.bl_label.removesuffix(" Input"),
"type": entry.bl_idname, "entry": ids[entry], "exit": ids[end], "type": entry.bl_idname, "entry": ids[entry], "exit": ids[end],
"nodes": sorted(ids[node] for node in members), "nodes": sorted(ids[node] for node in members),
"x": left, "y": top, "w": right - left, "h": bottom - top}) "x": left, "y": top, "w": right - left, "h": bottom - top})
zones.sort(key=lambda zone: zone["w"] * zone["h"], reverse=True) zones.sort(key=lambda zone: zone["w"] * zone["h"], reverse=True)
return {"name": node_group.name, "nodes": records, "links": links, "zones": zones} frames = [{"title": node.label or node.name, "x": node.location_absolute.x,
"y": -node.location_absolute.y, "w": node.width, "h": node.height,
"nodes": [ids[child] for child in nodes if child.parent == node]}
for node in node_group.nodes if node.bl_idname == "NodeFrame"]
return {"name": node_group.name, "nodes": records, "links": links, "zones": zones, "frames": frames}
def write_node_preview(node_groups, output_path, *, fragment=False): def write_node_preview(node_groups, output_path, *, fragment=False):

View File

@@ -76,22 +76,33 @@
dy = (height - (bottom + top) * scale) / 2; dy = (height - (bottom + top) * scale) / 2;
draw(); draw();
} }
function updateZoneViews() { function updateRegionViews() {
view.querySelectorAll('[data-zone]').forEach(option => option.remove()); view.querySelectorAll('[data-zone], [data-frame]').forEach(option => option.remove());
current.zones.forEach(zone => { current.zones.forEach(zone => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = `zone:${zone.entry}`; option.dataset.zone = 'true'; option.value = `zone:${zone.entry}`; option.dataset.zone = 'true';
option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`; option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`;
view.appendChild(option); view.appendChild(option);
}); });
current.frames.forEach((frame, index) => {
const option = document.createElement('option');
option.value = `frame:${index}`; option.dataset.frame = 'true';
option.textContent = `Frame · ${frame.title}`;
view.appendChild(option);
});
} }
function changeView() { function changeView() {
selected = null; selected = null;
if (view.value === 'all') { if (view.value === 'all') {
fit(bounds([...current.nodes, ...current.zones])); fit(bounds([...current.nodes, ...current.zones, ...current.frames]));
detail.textContent = '完整排列'; detail.textContent = '完整排列';
return; return;
} }
if (view.value.startsWith('frame:')) {
const frame = current.frames[Number(view.value.split(':')[1])];
fit(bounds([frame])); detail.textContent = frame.title;
return;
}
if (view.value === 'zone' || view.value.startsWith('zone:')) { if (view.value === 'zone' || view.value.startsWith('zone:')) {
if (current.zones.length) { if (current.zones.length) {
const zones = view.value === 'zone' ? current.zones : current.zones.filter(zone => `zone:${zone.entry}` === view.value); const zones = view.value === 'zone' ? current.zones : current.zones.filter(zone => `zone:${zone.entry}` === view.value);
@@ -152,6 +163,17 @@
context.strokeStyle = border; context.lineWidth = 1; context.strokeStyle = border; context.lineWidth = 1;
context.strokeRect(0.5, 0.5, width - 1, height - 1); context.strokeRect(0.5, 0.5, width - 1, height - 1);
context.save(); context.beginPath(); context.rect(1, 1, width - 2, height - 2); context.clip(); context.save(); context.beginPath(); context.rect(1, 1, width - 2, height - 2); context.clip();
for (const frame of current.frames) {
const x = frame.x * scale + dx, y = frame.y * scale + dy;
const w = frame.w * scale, h = frame.h * scale;
context.fillStyle = '#78909c'; context.globalAlpha = 0.06;
context.fillRect(x, y, w, h);
context.globalAlpha = 0.5; context.strokeStyle = '#78909c'; context.lineWidth = 1;
context.strokeRect(x, y, w, h); context.globalAlpha = 1;
if (w > 90 && scale >= 0.15) {
context.fillStyle = foreground; label(frame.title, x + 8, y + Math.max(size, 24 * scale), w - 16);
}
}
for (const zone of current.zones) { for (const zone of current.zones) {
const x = zone.x * scale + dx, y = zone.y * scale + dy; const x = zone.x * scale + dx, y = zone.y * scale + dy;
const w = zone.w * scale, h = zone.h * scale; const w = zone.w * scale, h = zone.h * scale;
@@ -236,7 +258,9 @@
} }
picker.addEventListener('change', () => { picker.addEventListener('change', () => {
if (view.value.startsWith('zone:')) view.value = 'zone'; if (view.value.startsWith('zone:')) view.value = 'zone';
current = data[Number(picker.value)]; updateZoneViews(); changeView(); current = data[Number(picker.value)]; updateRegionViews();
if (!view.value) view.value = 'all';
changeView();
}); });
view.addEventListener('change', changeView); view.addEventListener('change', changeView);
root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5)); root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5));
@@ -274,6 +298,6 @@
}).observe(canvas); }).observe(canvas);
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }); new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
updateZoneViews(); updateRegionViews();
})(); })();
</script> </script>