diff --git a/openspec/changes/compact-repeat-node-arrangement/.openspec.yaml b/openspec/changes/compact-repeat-node-arrangement/.openspec.yaml
new file mode 100644
index 0000000..515eaae
--- /dev/null
+++ b/openspec/changes/compact-repeat-node-arrangement/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-11
diff --git a/openspec/changes/compact-repeat-node-arrangement/design.md b/openspec/changes/compact-repeat-node-arrangement/design.md
new file mode 100644
index 0000000..c16ecf1
--- /dev/null
+++ b/openspec/changes/compact-repeat-node-arrangement/design.md
@@ -0,0 +1,21 @@
+## Context
+
+排列器以几何主链、取值支线及共享引用组织节点。循环目前额外计算支线宽度并预留两端间距。
+
+## Goals / Non-Goals
+
+目标是让循环执行步骤紧凑可读,并保持前置几何处理的横向先后关系。
+
+## Decisions
+
+- 预览层从完整区域成员中筛选几何执行主链,取值计算复用支线排列流程。
+- 横向列依赖保留实际几何来源,局部引用端口保持连接顺序。
+- 使用真实 Depth Cutout 和小型循环验证布局、连接及几何求值。
+- 简单计算链在有限高度内纵向分段,复杂支线按消费者形成独立 Frame。
+- 支线按实际消费输入与连通依赖分组,计算流程保持连续展开。
+- 几何引用线同时处理向前与回向连接,水平通道统一避让节点、支线及已有通道。
+- 引用线的高差通过有横向展开的斜向曲线过渡。
+
+## Risks / Trade-offs
+
+支线引用循环数据时可能需要回向连线;通过分离主链通道和支线区域保证可读性,并验证几何连接语义保持一致。
diff --git a/openspec/changes/compact-repeat-node-arrangement/proposal.md b/openspec/changes/compact-repeat-node-arrangement/proposal.md
new file mode 100644
index 0000000..6822c71
--- /dev/null
+++ b/openspec/changes/compact-repeat-node-arrangement/proposal.md
@@ -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`。
diff --git a/openspec/changes/compact-repeat-node-arrangement/specs/compact-repeat-arrangement/spec.md b/openspec/changes/compact-repeat-node-arrangement/specs/compact-repeat-arrangement/spec.md
new file mode 100644
index 0000000..e4befac
--- /dev/null
+++ b/openspec/changes/compact-repeat-node-arrangement/specs/compact-repeat-arrangement/spec.md
@@ -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** 共享计算支线与管道一同位于消费者外围,管道入口在结果输出节点右侧
diff --git a/openspec/changes/compact-repeat-node-arrangement/tasks.md b/openspec/changes/compact-repeat-node-arrangement/tasks.md
new file mode 100644
index 0000000..a8f654b
--- /dev/null
+++ b/openspec/changes/compact-repeat-node-arrangement/tasks.md
@@ -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 验证入口方向、支线位置及求值,重建资产并更新预览
diff --git a/openspec/changes/unify-boundary-depth-smoothing/design.md b/openspec/changes/unify-boundary-depth-smoothing/design.md
index 2a2d6c9..d3d49b5 100644
--- a/openspec/changes/unify-boundary-depth-smoothing/design.md
+++ b/openspec/changes/unify-boundary-depth-smoothing/design.md
@@ -46,7 +46,7 @@ flowchart TD
| 节点组 | 深度 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 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 为零。直接连接可复用字段;必要的现有临时命名协议在最后消费者后清理,保留用户属性。
实施时读取当前工作区基线,保留其他修改。完成源码与测试后运行 `uv run node-group build`,更新 `O_AnyImage.blend` 并独立加载验证接口、求值和节点布局。
diff --git a/openspec/changes/unify-boundary-depth-smoothing/proposal.md b/openspec/changes/unify-boundary-depth-smoothing/proposal.md
index 079bc9b..0a171ec 100644
--- a/openspec/changes/unify-boundary-depth-smoothing/proposal.md
+++ b/openspec/changes/unify-boundary-depth-smoothing/proposal.md
@@ -7,7 +7,7 @@ Alpha 轮廓和有效性剔除留下的网格边界仍可能采到突变深度
- 为 `O Image Depth Cutout`、`O Image Depth Plane`、`O Image Depth Panorama` 统一加入置换前的边界深度平滑。
- 复用 `Boundary Smooth` 控制深度 Blur 与现有边界网格平滑的迭代数,保留默认值 `5`、范围 `0–20`;`0` 完全关闭两者。
- 在有效性剔除及 Split 后的连通网格上平滑深度标量,仅应用于可处理边界及邻近两圈,并保持各顶点原投影方向。
-- Cutout 的原 Alpha 轮廓参与深度平滑;Depth Plane 保留原矩形外框保护;Panorama 按球面径向距离处理实际网格边界。
+- Cutout 的原 Alpha 轮廓参与分批自适应深度平滑,Outline Depth Fix 控制批次数、Boundary Smooth 控制每批步数,Ratio 固定 0.25–0.5,保护 Split 切边;Depth Plane 保留原矩形外框保护;Panorama 按球面径向距离处理实际网格边界。
- 复用公共构建函数,补齐行为测试、全景专项验证与节点资产重建。
## Capabilities
diff --git a/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md b/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md
index 3e7ee58..712284f 100644
--- a/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md
+++ b/openspec/changes/unify-boundary-depth-smoothing/specs/boundary-depth-smoothing/spec.md
@@ -2,12 +2,12 @@
### 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
- **WHEN** 用户将任一组的 Boundary Smooth 设为 12
-- **THEN** 深度 Blur 和现有边界网格平滑均使用 12 次迭代
-- **AND** 修改器只提供现有 Boundary Smooth 控制项
+- **THEN** 每批深度 Blur 和现有边界网格平滑均使用 12 次迭代
+- **AND** Depth Cutout 的批次数由 Outline Depth Fix 控制
#### Scenario: Disabled smoothing
- **WHEN** Boundary Smooth 为 0
@@ -16,7 +16,7 @@
### 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
- **WHEN** 原本平缓的表面在一个可处理边界点具有深度尖刺并启用 Boundary Smooth
@@ -33,7 +33,18 @@
### 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
- **WHEN** Fine Outline 的原轮廓存在异常深度,Thickness 为 0 且 Boundary Smooth 大于 0
@@ -88,3 +99,21 @@ Cutout 和 Depth Plane SHALL 在深度平滑阶段保持原相机射线,通过
- **WHEN** 执行节点资产构建并在独立 Blender 进程中重新加载保存资产
- **THEN** 资产与源码构建结果通过相同的行为验证
- **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
diff --git a/openspec/changes/unify-boundary-depth-smoothing/tasks.md b/openspec/changes/unify-boundary-depth-smoothing/tasks.md
index c9ae834..7946c05 100644
--- a/openspec/changes/unify-boundary-depth-smoothing/tasks.md
+++ b/openspec/changes/unify-boundary-depth-smoothing/tasks.md
@@ -26,3 +26,29 @@
- [x] 5.1 运行 uv run node-group build,更新 O_AnyImage.blend 和必要资产版本标识,在独立 Blender 进程中加载并验证。
- [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 验证零值及连续膨胀行为、共用平滑结果与性能,重建验证资产。
diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend
index e180f7c..f3bdabf 100644
--- a/src/anyimage/assets/O_AnyImage.blend
+++ b/src/anyimage/assets/O_AnyImage.blend
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2eb80fcf06db490cea283351adcda71680f8978412113047ea36ab554ca3a303
-size 8601131
+oid sha256:dcd246d17d02dac65bb3d72c5c0c40f48fce9f259f0ff737ca81050343990d04
+size 9813608
diff --git a/src/anyimage/operators/convert_to_panorama/object.py b/src/anyimage/operators/convert_to_panorama/object.py
index 1be60c2..ee1ff01 100644
--- a/src/anyimage/operators/convert_to_panorama/object.py
+++ b/src/anyimage/operators/convert_to_panorama/object.py
@@ -1,7 +1,6 @@
"""Apply packed panorama artifacts around the source Empty's camera origin."""
import json
-import math
from pathlib import Path
import bpy
@@ -18,15 +17,13 @@ def load_panorama_metadata(path):
metadata = json.loads(Path(path).read_text(encoding="utf-8"))
try:
size = tuple(int(value) for value in metadata["image_size"])
- distance = float(metadata["reference_distance"])
valid = (metadata["projection"] == "equirectangular" and len(size) == 2
- and size[1] > 0 and size[0] == 2 * size[1]
- and math.isfinite(distance) and distance > 0)
+ and size[1] > 0 and size[0] == 2 * size[1])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Panorama metadata is invalid") from error
if not valid:
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):
@@ -35,7 +32,7 @@ def create_panorama_from_result(context, result, operator):
try:
with material_color_image(input_path=operator.color_path) as color:
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)
material = create_image_material(source.data, color, shadeless=True, scene=context.scene,
texture_extension="REPEAT")
@@ -52,7 +49,6 @@ def create_panorama_from_result(context, result, operator):
modifier.show_group_selector = False
for name, value in (
("Subdivide", operator.mesh_detail), ("Depth Image", depth),
- ("Reference Distance", metadata["reference_distance"]),
):
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
finalize_object_result(context, source, obj)
diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py
index 0b7a2cc..0a69116 100644
--- a/src/anyimage/operators/convert_to_plane/object.py
+++ b/src/anyimage/operators/convert_to_plane/object.py
@@ -62,7 +62,7 @@ def create_depth_plane_object(
node_group = load_node_group({
"DEPTH": "O Image Depth 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
bounds = image_empty_bounds(source_object)
mesh = create_image_plane_mesh(source_name, bounds)
diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py
index e49c891..20fb54b 100644
--- a/src/anyimage/operators/cutout_tool/geometry.py
+++ b/src/anyimage/operators/cutout_tool/geometry.py
@@ -21,11 +21,11 @@ class SelectionContours:
)
image_size = tuple(int(value) for value in self.image_size)
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):
- 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:
- 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, "image_size", image_size)
diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py
index c93cf2b..e42e48b 100644
--- a/src/anyimage/operators/cutout_tool/object.py
+++ b/src/anyimage/operators/cutout_tool/object.py
@@ -99,7 +99,7 @@ def _assign_float_point_attribute(mesh, name, values):
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):
diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py
index 266fbf4..492be42 100644
--- a/src/anyimage/properties.py
+++ b/src/anyimage/properties.py
@@ -209,7 +209,7 @@ def cutout_generate_normal_property():
def cutout_alpha_threshold_property():
return bpy.props.FloatProperty(
name="Alpha Threshold",
- description="Minimum Alpha kept in the Cutout contour",
+ description="Minimum Alpha kept in the Cutout outline",
default=0.9,
min=0.0,
max=1.0,
diff --git a/src/anyimage/server/jobs/panorama.py b/src/anyimage/server/jobs/panorama.py
index e3b3ae4..7b9cf59 100644
--- a/src/anyimage/server/jobs/panorama.py
+++ b/src/anyimage/server/jobs/panorama.py
@@ -61,8 +61,8 @@ def run(context, parameters):
alpha = np.asarray(source.getchannel("A"),
dtype=np.float32) / 255.0
alpha *= valid
- reference = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0)
- if not reference.any():
+ valid_depth = (alpha >= 0.9) & np.isfinite(distance) & (distance > 0)
+ if not valid_depth.any():
raise ValueError("The panorama contains no valid depth pixels")
if not np.isfinite(distance).all() or (distance <= 0).any():
raise ValueError("Panorama fusion produced invalid distances")
@@ -71,7 +71,6 @@ def run(context, parameters):
write_float_exr(rgba, outputs[0])
outputs[1].write_text(json.dumps({
"projection": "equirectangular", "image_size": [width, height],
- "reference_distance": float(np.median(distance[reference])),
}, separators=(",", ":")), encoding="utf-8")
context.check_cancelled()
context.progress(1.0, "Panorama conversion data is ready")
diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py
index f87f11f..fb9a08d 100644
--- a/tests/anyimage/operators/test_cutout_depth.py
+++ b/tests/anyimage/operators/test_cutout_depth.py
@@ -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["anyimage_asset_revision"] = 11
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["anyimage_asset_revision"] == 11
assert load_cutout_node_group("DEPTH_SOLID") == current
diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py
index dcbf269..9d268bd 100644
--- a/tests/anyimage/operators/test_cutout_geometry.py
+++ b/tests/anyimage/operators/test_cutout_geometry.py
@@ -47,7 +47,7 @@ def test_cutout_node_groups_are_loaded_from_the_asset():
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES:
assert cutout_object.load_cutout_node_group(shape) is loaded
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
diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py
index 10381a4..f3c17e0 100644
--- a/tests/anyimage/operators/test_cutout_objects.py
+++ b/tests/anyimage/operators/test_cutout_objects.py
@@ -188,7 +188,7 @@ def test_cutout_presets_load_three_direct_modifier_assets():
assert inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1
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"
else:
assert "Boundary Smooth" not in inputs
diff --git a/tests/anyimage/operators/test_panorama_conversion.py b/tests/anyimage/operators/test_panorama_conversion.py
index 9cdd741..5aa7f97 100644
--- a/tests/anyimage/operators/test_panorama_conversion.py
+++ b/tests/anyimage/operators/test_panorama_conversion.py
@@ -70,7 +70,7 @@ def artifacts(tmp_path, source):
rgba = np.ones((16, 32, 4), np.float32)
rgba[..., :3] = 3
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])
operator = SimpleNamespace(source_object_name=source.name, source_identity=str(source.as_pointer()),
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))))
obj = conversion.create_panorama_from_result(bpy.context, result, operator)
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 obj.name == operator.source_object_name and obj.type == "MESH"
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":
operator.image_identity = "replaced"
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)):
conversion.create_panorama_from_result(bpy.context, result, operator)
for name, values in before.items():
diff --git a/tests/anyimage/operators/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py
index 15bdc5c..6fd43f3 100644
--- a/tests/anyimage/operators/test_plane_objects.py
+++ b/tests/anyimage/operators/test_plane_objects.py
@@ -151,7 +151,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0]
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
uniform_scale = depth_modifier[
object_data.modifier_input_identifier(
diff --git a/tests/anyimage/server/test_panorama.py b/tests/anyimage/server/test_panorama.py
index 8fba1d5..cc2f38f 100644
--- a/tests/anyimage/server/test_panorama.py
+++ b/tests/anyimage/server/test_panorama.py
@@ -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())
assert metadata["projection"] == "equirectangular"
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():
diff --git a/tests/support/depth_surface.py b/tests/support/depth_surface.py
index c226809..faad5c4 100644
--- a/tests/support/depth_surface.py
+++ b/tests/support/depth_surface.py
@@ -61,7 +61,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
for name, value in (
("Depth Image", image),
("Uniform Scale", size),
- ("Reference Depth", size),
+ ("Reference Depth", size * (1 + step / 2)),
("Depth Scale", 1.0),
("Thickness", 0.0),
("Front Inflation", 0.0),
diff --git a/tests/support/planes.py b/tests/support/planes.py
index 7d7d52e..957b9fe 100644
--- a/tests/support/planes.py
+++ b/tests/support/planes.py
@@ -65,6 +65,8 @@ def create_surface(source, plane_type):
set_value(name, value)
if "Boundary Smooth" in inputs:
set_value("Boundary Smooth", 0)
+ if "Split Threshold" in inputs:
+ set_value("Split Threshold", 0.0)
return obj, set_value, plane, inputs, image
diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py
index 53e7f80..904a188 100644
--- a/tests/tools/nodes/test_arrangement.py
+++ b/tests/tools/nodes/test_arrangement.py
@@ -44,7 +44,7 @@ def _value(value):
def _program(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
settings = {
name: _value(getattr(node, name))
@@ -129,6 +129,8 @@ def _surface(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)
evaluate = _surface(node_group)
geometry = evaluate()
@@ -138,27 +140,67 @@ def test_arrangement_preserves_each_asset_program(node_group):
for node in node_group.nodes:
if node.bl_idname == "NodeGroupInput":
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)
for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()):
np.testing.assert_allclose(after_points, before_points, atol=1e-6)
assert after_faces == before_faces
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"}
}
arrange_nodes(node_group)
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:
sources = [link.from_socket for link in peer_links(node)]
if len(sources) < 2:
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)
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
@@ -227,12 +269,12 @@ def test_geometry_paths_order_join_branches_and_separate_their_shared_input(bran
program = _program(node_group)
arrange_nodes(node_group)
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)]
assert levels == sorted(levels, reverse=True)
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"]
calculations = [field.node]
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)
arrange_nodes(node_group)
assert _program(node_group) == program
- assert {node: node.location.y for node in geometry_parents} == heights
- assert max(node.location.y for node in calculations) - min(
- node.location.y - estimate_node_dimensions(node).height for node in calculations
- ) <= FIELD_HEIGHT
+ assert {node: node.location_absolute.y for node in geometry_parents} == heights
+ assert max(node.location_absolute.y for node in calculations) - min(
+ node.location_absolute.y - estimate_node_dimensions(node).height for node in calculations
+ ) <= 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():
@@ -304,10 +360,10 @@ def test_shared_field_references_branch_near_their_consumers():
for node in consumers[1:]:
branch = node.inputs["Offset"].links[0].from_node
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
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
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(join.outputs["Geometry"], output.inputs["Geometry"])
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]
- 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 = []
for target in (lane[1], lane[3]):
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)
arrange_nodes(node_group)
assert _program(node_group) == program
- assert [node.location.y for branch in branches for node in branch] == heights
- boxes = [(min(node.location.x for node in branch),
- max(node.location.x + estimate_node_dimensions(node).width for node in branch),
- max(node.location.y for node in branch),
- min(node.location.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
+ assert [node.location_absolute.y for branch in branches for node in branch] == heights
+ boxes = [(min(node.location_absolute.x for node in branch),
+ max(node.location_absolute.x + estimate_node_dimensions(node).width for node in branch),
+ max(node.location_absolute.y for node in branch),
+ min(node.location_absolute.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
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:
if right + BRANCH_GAP <= core_left + 0.001:
continue
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:
- 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
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)
@@ -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)
for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)):
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"]
- x1 = terminal.location.x + terminal.width
- y1 = terminal.location.y - estimate_node_dimensions(terminal).sockets[socket]
- x2 = target.location.x
- y2 = target.location.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
+ x1 = terminal.location_absolute.x + terminal.width
+ y1 = terminal.location_absolute.y - estimate_node_dimensions(terminal).sockets[socket]
+ x2 = target.location_absolute.x
+ y2 = target.location_absolute.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
bend = max(40, (x2 - x1) * 0.42)
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
@@ -401,10 +457,10 @@ def test_boolean_operands_follow_socket_order_and_center_the_junction():
program = _program(node_group)
arrange_nodes(node_group)
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 comparisons[0].location.x + comparisons[0].width == comparisons[1].location.x + comparisons[1].width
- assert operation.location.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2)
+ assert comparisons[0].location_absolute.x + comparisons[0].width == comparisons[1].location_absolute.x + comparisons[1].width
+ 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):
@@ -415,7 +471,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
arrange_nodes(node_group)
node_group.nodes[0].name = ''
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")
markup = destination.read_text(encoding="utf-8")
assert markup.startswith("")
@@ -423,7 +479,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
assert '' not in markup
assert '\\u003c/script>' in markup
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():
@@ -444,14 +500,14 @@ def test_geometry_bypass_has_two_level_corners_next_to_processing():
exit_port = switch.inputs["False"].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.location.y == exit_port.location.y
- assert source.location.x + source.width < entry_port.location.x < exit_port.location.x < switch.location.x
- assert exit_port.location.x + 16 == pytest.approx(transform.location.x + transform.width)
+ assert entry_port.location_absolute.y == exit_port.location_absolute.y
+ 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_absolute.x + 16 == pytest.approx(transform.location_absolute.x + transform.width)
assert _program(node_group) == program
@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
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"])
geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY")
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)
zone = data["zones"][0]
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"]:
node = data["nodes"][index]
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
collector = node_group.nodes["Join Geometry.001"]
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 abs(collector.location.y - entry.location.y) <= FIELD_HEIGHT + BRANCH_GAP
- assert entry.location.y == end.location.y
+ assert entry.location_absolute.x < collector.location_absolute.x < end.location_absolute.x
+ assert abs(collector.location_absolute.y - entry.location_absolute.y) <= FIELD_HEIGHT + BRANCH_GAP
+ 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():
@@ -516,7 +685,7 @@ def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
for sample in samplers[1:]:
branch = sample.inputs["Geometry"].links[0].from_node
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
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)
@@ -545,7 +714,7 @@ def test_reused_geometry_processing_keeps_its_upstream_result_path():
arrange_nodes(node_group)
roles = node_roles(node_group)
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
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)
assert roles[grid] == roles[prepare] == "field"
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.zones import node_zones
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = CUTOUT_BUILDERS["SOLID"]()
arrange_nodes(node_group)
core, _ = geometry_paths(node_group)
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
- positions = {node: tuple(node.location) for node in node_group.nodes}
- checked = 0
- for _, _, members in node_zones(node_group):
- for link in node_group.links:
- if link.from_node not in members or link.to_node not in members:
- continue
- if (link.from_node in core) == (link.to_node in core):
- continue
- 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)
+ positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
+ frames = [node for node in node_group.nodes if node.bl_idname == "NodeFrame"]
+ for index, frame in enumerate(frames):
+ left, top = positions[frame]
+ for other in frames[index + 1:]:
+ x, y = positions[other]
+ assert (left + frame.width <= x or x + other.width <= left
+ or top - frame.height >= y or y - other.height >= top)
for port in core:
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
continue
source = origin_socket(port.outputs[0]).node
if source not in core:
continue
- left = source.location.x + sizes[source].width
- right, level = port.location.x + 8, port.location.y - 8
+ left = source.location_absolute.x + sizes[source].width
+ right, level = port.location_absolute.x + 8, port.location_absolute.y - 8
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
- if left < node.location.x + sizes[node].width and node.location.x < right:
- assert not node.location.y - sizes[node].height < level < node.location.y, node.name
+ if left < node.location_absolute.x + sizes[node].width and node.location_absolute.x < right:
+ assert not node.location_absolute.y - sizes[node].height < level < node.location_absolute.y, node.name
for link in node_group.links:
if link.from_socket.type != "GEOMETRY":
continue
@@ -634,10 +798,10 @@ def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
if abs(start[1] - target[1]) > 0.01:
continue
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
- if start[0] < node.location.x + sizes[node].width and node.location.x < target[0]:
- assert not node.location.y - sizes[node].height < start[1] < node.location.y, node.name
+ if start[0] < node.location_absolute.x + sizes[node].width and node.location_absolute.x < target[0]:
+ 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():
@@ -652,7 +816,7 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
seed = node_group.nodes.new("GeometryNodeInputIndex")
previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0]
fields = [seed, previous.node]
- for _ in range(7):
+ for _ in range(24):
operation = node_group.nodes.new("ShaderNodeMath")
operation.operation = "ADD"
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)
arrange_nodes(node_group)
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}
- 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)
for link in node_group.links:
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
left, top = positions[node]
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
diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py
index d35b556..8459a35 100644
--- a/tests/tools/nodes/test_boundary_smoothing.py
+++ b/tests/tools/nodes/test_boundary_smoothing.py
@@ -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
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"
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
yy, xx = np.mgrid[:8, :16]
@@ -216,15 +216,18 @@ def test_every_split_node_enables_boundary_smoothing_by_default(source):
continue
split_groups.append(group.name)
smooth = inputs["Boundary Smooth"]
- assert smooth.default_value == 5
- assert (smooth.min_value, smooth.max_value) == (0, 20)
+ assert smooth.default_value == (5 if group.name == "O Image Depth Panorama" else 2)
+ assert (smooth.min_value, smooth.max_value) == (1 if "Outline Depth Fix" in inputs else 0, 20)
assert smooth.parent.name == "Options"
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"
- and n.data_type == "FLOAT" and n.inputs[3].is_linked
- and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute")
- depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node
- assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
+ depth_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute"
+ and n.data_type == "FLOAT" and n.inputs["Iterations"].is_linked
+ and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"]
+ assert len(depth_blurs) == 1
+ 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"
and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked
and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"]
diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py
index e457aa7..3c3c0f8 100644
--- a/tests/tools/nodes/test_cutout_boundary.py
+++ b/tests/tools/nodes/test_cutout_boundary.py
@@ -52,7 +52,7 @@ def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
@pytest.mark.parametrize("resolution", [12, 24])
@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)
for vertex in obj.data.vertices:
vertex.co.x *= aspect
@@ -73,16 +73,11 @@ def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]
assert outside
- results = []
- for amount in (0, .2, .6):
- set_value("Edge Round", amount)
- values = profile_values(obj)
- np.testing.assert_array_equal(values[cut], 0)
- 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)
+ values = profile_values(obj)
+ np.testing.assert_array_equal(values[cut], 0)
+ np.testing.assert_allclose(values[outside], .3)
+ assert np.isfinite(values).all()
+ assert values.max() == pytest.approx(.3)
set_value("Split Threshold", 0)
np.testing.assert_allclose(profile_values(obj), .3)
diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py
index 592ecf2..c1b72bd 100644
--- a/tests/tools/nodes/test_cutouts.py
+++ b/tests/tools/nodes/test_cutouts.py
@@ -22,7 +22,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert len(set(groups.values())) == 3
for group in set(groups.values()):
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 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"}
@@ -36,21 +36,25 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert inputs["Double Sided"].default_value
if group == groups["DEPTH_SOLID"]:
assert np.isclose(inputs["Split Threshold"].default_value, 0.1)
- assert np.isclose(inputs["Edge Round"].default_value, 0.2)
- assert inputs["Edge Round"].parent.name == "Options"
- assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round"
- assert inputs["Normal Smooth"].default_value == 50
- assert inputs["Front Inflation"].default_value == 1
+ assert not {"Edge Round", "Normal Smooth"} & inputs.keys()
+ normal_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" and n.data_type == "FLOAT_VECTOR"
+ and not n.inputs["Iterations"].is_linked and n.inputs["Iterations"].default_value == 100]
+ assert len(normal_blurs) == 2
+ 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 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"]
expected = ["Cleanup Threshold"]
if group == groups["DEPTH_SOLID"]:
- expected.insert(0, "Boundary Smooth")
- expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
+ expected += ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Outline Depth Fix", "Front Inflation"]
+ 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"]:
- expected += ["Reference Depth"]
- expected += ["Smooth Weight"]
+ expected += ["Smooth Weight", "Reference Depth"]
+ if group not in (groups["DEPTH_SOLID"], groups["DEPTH_BALLOON"]):
+ expected += ["Smooth Weight"]
if group == groups["DEPTH_BALLOON"]:
expected += ["Depth Direction"]
assert options == expected, (group.name, options)
diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py
index 39dc95f..d9ec097 100644
--- a/tests/tools/nodes/test_depth_cutout.py
+++ b/tests/tools/nodes/test_depth_cutout.py
@@ -4,9 +4,7 @@ import numpy as np
import pytest
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 tools.nodes.common.nodes import sample_field
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)
-@pytest.mark.parametrize("iterations", [0, 50])
-def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
+def test_uniform_thickness_follows_the_tilted_front_normal():
from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed
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]
image.pixels.foreach_set(pixels.ravel())
image.update()
- set_value("Normal Smooth", iterations)
+ obj.update_tag(refresh={"DATA"})
+ bpy.context.view_layer.update()
front, _ = evaluated(obj)
set_value("Thickness", 0.2)
points, faces = evaluated(obj)
@@ -66,8 +64,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", 0)
- set_value("Edge Round", 0.0)
- set_value("Normal Smooth", 0)
modifier = obj.modifiers[0]
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
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)
-@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():
bpy.ops.wm.read_factory_settings(use_empty=True)
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("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
set_test_structure(monkeypatch, structure)
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("Front Inflation", 0.0)
baseline, faces = evaluated(obj)
- set_value("Front Inflation", 1.0)
+ set_value("Front Inflation", inflation)
inflated, inflated_faces = evaluated(obj)
assert faces == inflated_faces
delta = np.linalg.norm(inflated - baseline, axis=1)
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)
if thickness:
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)
-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):
from tests.support.depth_surface import surface, evaluated
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)
group = obj.modifiers[0].node_group
set_value("Mode", 0)
- set_value("Edge Round", 0)
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
previous = 0
for thickness in (0.001, 0.01, 0.1, 0.5, 1):
diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py
index 442cda3..91fae6c 100644
--- a/tests/tools/nodes/test_depth_relief_planes.py
+++ b/tests/tools/nodes/test_depth_relief_planes.py
@@ -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])
-@pytest.mark.parametrize("normal_smooth", [0, 2, 50])
-def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth):
+def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
from scipy.spatial import cKDTree
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])
finally:
result.to_mesh_clear()
- set_value("Normal Smooth", normal_smooth)
thickness = 0.001
set_value("Thickness", thickness)
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
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))
- if normal_smooth == 0:
- np.testing.assert_allclose(normals, raw_normals, atol=2e-4)
- else:
- assert roughness(normals) < roughness(raw_normals)
+ assert roughness(normals) < roughness(raw_normals)
def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface
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 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"].max_value == 1
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"].subtype == "FACTOR"
assert inputs["Smooth Weight"].parent.name == "Options"
- assert inputs["Normal Smooth"].default_value == 50
- assert inputs["Normal Smooth"].min_value == 0
- assert inputs["Normal Smooth"].max_value == 50
- assert inputs["Normal Smooth"].parent.name == "Options"
+ assert not {"Normal Smooth", "Cleanup Threshold"} & inputs.keys()
assert inputs["Thickness"].default_value == 0
assert inputs["Subdivide"].default_value == 6
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"]
controls = ["Reference Depth"]
if kind == "DEPTH":
- controls.insert(0, "Boundary Smooth")
- controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
+ controls = ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold"]
else:
controls += ["Smooth Weight", "Depth Direction"]
assert [item.name for item in panels[0].interface_items] == controls
diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/tools/nodes/test_depth_strip_cleanup.py
index bf89def..2ceea68 100644
--- a/tests/tools/nodes/test_depth_strip_cleanup.py
+++ b/tests/tools/nodes/test_depth_strip_cleanup.py
@@ -134,7 +134,7 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind):
split = inputs[names.index("Split Threshold")]
assert split.parent == inputs[names.index("Smooth")].parent
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 "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.update()
set_value("Depth Scale", 1.0)
+ set_value("Reference Depth", 11.0)
assert len(evaluated(obj)[1]) == 8
set_value("Split Threshold", 0.5)
vertices, faces = evaluated(obj)
diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py
index 07cad0e..780477a 100644
--- a/tests/tools/nodes/test_depth_surface_split.py
+++ b/tests/tools/nodes/test_depth_surface_split.py
@@ -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])
def test_split_mapping_and_uniform_scale(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 = []
for split in (0.0, 0.25, 0.5, 1.0):
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
-def test_midpoint_selects_gradient_six_and_preserves_faces():
- obj, set_value = surface(step=3.0)
+def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
+ obj, set_value = surface(step=4.0)
set_value("Split Threshold", 0.5)
coords, faces = evaluated(obj)
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():
@@ -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])
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)
original, original_faces = evaluated(obj)
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():
- obj, set_value = surface(step=3.0)
+ obj, set_value = surface(step=4.0)
original, original_faces = evaluated(obj)
set_value("Split Threshold", 0.5)
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]
if obj.data.vertices[source_index].co.x != 1.0:
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(
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
)
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("Thickness", 0.1)
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():
- obj, set_value = surface(step=3.0)
+ obj, set_value = surface(step=4.0)
bm = bmesh.new()
bm.from_mesh(obj.data)
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])
-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)
output_split_mesh(obj)
bm = bmesh.new()
@@ -315,10 +315,65 @@ def test_linear_depth_slope_has_density_independent_selection(resolution):
bm.free()
image = bpy.data.images["Camera"]
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.update()
- set_value("Split Threshold", 0.5)
+ set_value("Split Threshold", 0.25)
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
set_value("Split Threshold", 1.0)
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()
diff --git a/tests/tools/nodes/test_image_depth_panorama.py b/tests/tools/nodes/test_image_depth_panorama.py
index bd28f14..4542b9a 100644
--- a/tests/tools/nodes/test_image_depth_panorama.py
+++ b/tests/tools/nodes/test_image_depth_panorama.py
@@ -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
-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)
group = build_image_depth_panorama_group()
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"})
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("Boundary Smooth", 0)
return obj, set_value
@@ -190,7 +190,7 @@ def test_split_strength_and_distance_scale_invariance():
depth[:, 64:] = 7
signatures = []
for scale in (0.01, 1, 100):
- obj, set_value = panorama(depth * scale, reference=3 * scale)
+ obj, set_value = panorama(depth * scale)
counts = []
for split in (0, 0.1, 0.5, 1):
set_value("Split Threshold", split)
diff --git a/tests/tools/nodes/test_outline_smoothing.py b/tests/tools/nodes/test_outline_smoothing.py
new file mode 100644
index 0000000..682bafa
--- /dev/null
+++ b/tests/tools/nodes/test_outline_smoothing.py
@@ -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)
diff --git a/tests/tools/nodes/test_saved_layout.py b/tests/tools/nodes/test_saved_layout.py
index ab1bcc2..95e110c 100644
--- a/tests/tools/nodes/test_saved_layout.py
+++ b/tests/tools/nodes/test_saved_layout.py
@@ -25,11 +25,11 @@ class NodeAssetsTest(unittest.TestCase):
self.assertEqual(len(groups), 7)
for group in groups:
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"}}
arrange_nodes(group)
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}")
diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py
index b8ce672..b2a796b 100644
--- a/tools/nodes/arrangement/arrange.py
+++ b/tools/nodes/arrangement/arrange.py
@@ -8,10 +8,16 @@ import numpy as np
from scipy.optimize import LinearConstraint, minimize
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 .connections import curve_footprint, link_endpoints
from .fields import field_layout
+from .frames import create_field_frames, remove_field_frames
+from .references import route_references
+from .passages import route_field_passages
+from .sections import clear_repeat_regions, enclose_repeat_branches, reserve_field_sections
+from .sources import place_shared_sources
+from .zones import node_zones
+from ..common.nodes import replace_link_source
GEOMETRY_GAP = 100
@@ -23,15 +29,6 @@ ROUTE_GAP = 40
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):
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):
continue
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
sources = [origin_socket(link.from_socket) 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["arrangement_input"] = True
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."""
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)
right, positions = 0, {}
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])
for node in columns[level]:
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):
- """Group calculations by their first geometry consumers and shared scope."""
+ """Group connected calculations by the geometry inputs that consume them."""
targets = {}
fields = set(nodes) - core
for node in reversed(order):
- targets[node] = frozenset({node}) if node in core else frozenset().union(
- *(targets[child] for child in children[node])
- )
+ targets[node] = frozenset(link.to_socket for socket in node.outputs for link in socket.links
+ if link.to_node in core) | frozenset().union(
+ *(targets[child] for child in children[node] if child not in core))
for node in order:
if node.get("arrangement_exit"):
targets[node] = targets[origin_socket(node.outputs[0]).node]
@@ -140,7 +136,26 @@ def _field_regions(nodes, core, children, order):
for node in order:
if node in fields:
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):
@@ -155,7 +170,7 @@ def _localize_module_outputs(node_group, core, children):
port = node_group.nodes.new("NodeReroute")
port["arrangement_exit"] = True
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):
@@ -169,7 +184,7 @@ def _localize_inputs(node_group):
local = node_group.nodes.new("NodeGroupInput")
for link, identifier in inputs:
source = next(socket for socket in local.outputs if socket.identifier == identifier)
- _replace_link(node_group, link, source)
+ replace_link_source(node_group, link, source)
for node in controls:
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)
-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."""
right = min(positions[node][0] for node in destinations) - FIELD_GAP
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:
side = 0
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)
- shared = [] if horizontal else shared_geometry_outputs(local)
- references = set(shared_geometry_outputs(local))
- outlets, constraints, entries = [], [], []
+ outlets, constraints = [], []
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:
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]
if 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,
y - sizes[node].sockets[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),
*(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()
for left in sorted(candidates, key=lambda x: (abs(x - desired), x)):
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)):
if top > limits[0] or top - height < limits[1]:
continue
- minimum = max([horizontal[0], *(start + max(GEOMETRY_GAP, abs(top + target_y - source_y) * 0.8)
- for start, source_y, target_y in entries)]) if horizontal else -float("inf")
- 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)
+ placed_left = min(left, desired)
+ placed_left = min([placed_left, *(end - max(GEOMETRY_GAP, abs(source_y + top - target_y) * exit_slope)
for end, source_y, target_y in constraints)])
- if not horizontal:
- # Leave clearance at saturated link constraints for solver roundoff.
- placed_left -= 0.01
- if horizontal and not minimum <= placed_left <= horizontal[1] - width:
- continue
+ # Leave clearance at saturated link constraints for solver roundoff.
+ placed_left -= 0.01
in_front = placed_left + width + BRANCH_GAP <= core_left
if not (in_front and front_allowed) and ((side == 1 and top - height < ceiling + BRANCH_GAP - 1e-5)
or (side == -1 and top > floor - BRANCH_GAP + 1e-5)):
@@ -462,26 +465,23 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
continue
seen.add(point)
distance = abs(placed_left - desired)
- base_score = distance + abs(top - height / 2 - baseline) * 1.5
- if best is not None and base_score > best[0]:
+ base_score = (sum(end - placed_left + abs(source_y + top - target_y) * 1.5
+ for end, source_y, target_y in constraints) / len(constraints)
+ if constraints else distance + abs(top - height / 2 - baseline) * 1.5)
+ if best is not None and round(base_score, 6) > best[0]:
continue
overlapping = [box for box in occupied
if placed_left < box[1] + BRANCH_GAP and placed_left + width > box[0] - BRANCH_GAP]
if any(top > bottom - BRANCH_GAP + 1e-5 and top - height < upper + BRANCH_GAP - 1e-5
for _, _, upper, bottom in overlapping):
continue
- 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()}
exits, routes = space.connections(outlets, placed, sizes)
body = (placed_left, placed_left + width, top, top - height)
if not space.fits(body, exits):
continue
score = base_score + space.crossings(exits) * BRANCH_GAP * 4
- candidate = (score, distance, -top, placed_left, top)
+ candidate = (round(score, 6), round(distance, 6), -top, placed_left, top)
if best is None or candidate < best:
best = candidate
best_exits, best_routes = exits, routes
@@ -489,7 +489,8 @@ def _place_field_region(local, width, height, destinations, owners, positions, s
if not allow_opposite:
return _place_field_region(
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")
_, _, _, 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.exits.extend(best_exits)
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):
"""Materialize the passages reserved between expression columns."""
- protected = set()
for layout in layouts:
anchor = next(iter(layout.positions))
dx = positions[anchor][0] - layout.positions[anchor][0]
dy = positions[anchor][1] - layout.positions[anchor][1]
for link, points in layout.routes:
source = link.from_socket
- protected.add((source, link.to_socket))
for x, y in points:
node = node_group.nodes.new("NodeReroute")
node["arrangement_branch"] = True
node.location = (x + dx - 8, y + dy + 8)
node_group.links.new(source, node.inputs[0])
source = node.outputs[0]
- _replace_link(node_group, link, source)
- return protected
+ replace_link_source(node_group, link, source)
def _route_branch_exits(node_group, routes):
"""Materialize the channels reserved during placement, sharing trunk corners."""
- deliveries, protected = defaultdict(list), set()
+ deliveries = defaultdict(list)
for link, points in routes:
- protected.add((link.from_socket, link.to_socket))
deliveries[link.from_socket].append((link, points))
for source, branches in deliveries.items():
corners = {}
@@ -543,8 +535,7 @@ def _route_branch_exits(node_group, routes):
source = corners[point] = node.outputs[0]
for link, points in branches:
if points:
- _replace_link(node_group, link, corners[points[-1]])
- return protected
+ replace_link_source(node_group, link, corners[points[-1]])
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["arrangement_bypass"] = True
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)
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):
"""Fix geometry lanes, pack shallow field regions, and route shared references."""
+ remove_field_frames(node_group)
_remove_routes(node_group)
parents, children, order, ancestors = graph(node_group)
_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
for link in socket_links(socket) if link.from_node in region]
peers.append(list(dict.fromkeys(outlets)))
- spacing, zones, field_layouts = {}, [], []
- scoped = set()
- for entry, end, members in reversed(node_zones(node_group)):
- if entry not in core or end not in core:
- continue
- interior = (members & core) - {entry}
- fields = [node for node in order if node in members - core - scoped]
- if fields:
- scoped.update(fields)
- 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)
+ # Preserve execution order through shared geometry and field calculations.
+ preceding = {}
+ column_parents = {}
+ for node in order:
+ sources = set().union(*(preceding[parent] for parent in parents[node]))
+ if node in core:
+ column_parents[node] = sources
+ preceding[node] = {node} if node in core else sources
+ geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP)
positions = _place_geometry_paths(paths, geometry_x, sizes, order)
- occupied = _geometry_corridors(paths, positions, sizes)
- trunks = {}
space = BranchSpace()
- layouts = []
+ layouts, field_layouts = [], []
for region, targets in regions:
- remaining = [node for node in region if node not in scoped]
- if remaining:
- layout = field_layout(remaining, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
- field_layouts.append(layout)
- layouts.append((remaining, targets, layout.positions, layout.width, layout.height))
- reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts),
- *(height for _, _, _, _, _, _, height in zones)]) * 2 + BRANCH_GAP * 2
- reserve += sum(height + BRANCH_GAP * 2 for _, _, _, _, _, _, height in zones)
+ layout = field_layout(region, parents, peers, sizes, gap=FIELD_GAP, target_height=FIELD_HEIGHT)
+ field_layouts.append(layout)
+ layouts.append((region, targets, layout.positions, layout.width, layout.height))
+ reserve_field_sections(node_group, layouts, positions, sizes, gap=BRANCH_GAP)
+ occupied = _geometry_corridors(paths, positions, sizes)
+ reserve = max([FIELD_HEIGHT, *(height for _, _, _, _, height in layouts)]) * 2 + BRANCH_GAP * 2
limits = (max(y for _, y in positions.values()) + reserve,
min(y - sizes[node].height for node, (_, y) in positions.items()) - reserve)
- 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)
shared_nodes = {socket.node for socket in shared_geometry_outputs(node_group.nodes)}
+ peripheral_regions = set()
+ for region, _, _, _, _ in layouts:
+ uses = defaultdict(set)
+ for node in region:
+ for socket in node.outputs:
+ for link in socket.links:
+ if link.to_node not in region:
+ uses[origin_socket(socket)].add(link.to_node)
+ if any(len(consumers) > 1 and (socket.type != "GEOMETRY" or not consumers & core)
+ for socket, consumers in uses.items()):
+ peripheral_regions.add(region[0])
+ while True:
+ peripheral_nodes = {node for region, _, _, _, _ in layouts if region[0] in peripheral_regions for node in region}
+ upstream = {region[0] for region, _, _, _, _ in layouts
+ if region[0] not in peripheral_regions
+ and (consumers := {link.to_node for node in region for socket in node.outputs
+ for link in socket.links if link.to_node not in region})
+ and consumers <= peripheral_nodes}
+ if not upstream:
+ break
+ peripheral_regions.update(upstream)
def delivery_order(layout):
ports = [link.to_socket for node in layout[0] for socket in node.outputs
for link in socket.links if link.to_node in core]
return min(((positions[socket.node][0], list(socket.node.inputs).index(socket))
for socket in ports), default=(float("inf"), 0))
+ repeat_consumers = {node for entry, end, members in node_zones(node_group)
+ if entry.bl_idname == "GeometryNodeRepeatInput"
+ for node in members & core if node not in {entry, end}}
while pending:
ready = [layout for layout in pending if all(
link.to_node in positions or link.to_node in layout[0]
for node in layout[0] for socket in node.outputs for link in socket.links)]
- layout = min(ready, key=lambda item: (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])))
pending.remove(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))
destinations = consumers or [node for node in order if node in targets] or [paths[0][-1]]
owners = [node for node in order if node in targets] or [paths[0][-1]]
- _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space)
- protected = _route_field_links(node_group, field_layouts, positions)
- protected.update(_route_branch_exits(node_group, space.routes))
+ _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, space,
+ peripheral=region[0] in peripheral_regions,
+ repeat_consumer=any(node in repeat_consumers for node in destinations))
+ enclose_repeat_branches(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
+ clear_repeat_regions(node_group, positions, sizes, space.bodies, gap=BRANCH_GAP)
+ repeat_members = set().union(*(members for entry, _, members in node_zones(node_group)
+ if entry.bl_idname == "GeometryNodeRepeatInput"))
+ peripheral_nodes.difference_update(repeat_members)
+ place_shared_sources(space.bodies, peripheral_nodes, positions, sizes, gap=BRANCH_GAP)
+ _, space.routes = space.connections([[link] for link, _ in space.routes], positions, sizes)
+ _route_field_links(node_group, field_layouts, positions)
+ _route_branch_exits(node_group, space.routes)
_route_peer_bypasses(node_group, sizes, positions, ancestors)
for node, position in positions.items():
node.location = position
- _route_references(node_group, 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
if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]:
+ for members, _ in space.bodies:
+ members.difference_update(unused)
for node in unused:
node_group.nodes.remove(node)
+ route_field_passages(node_group, space.bodies)
+ create_field_frames(node_group, space.bodies, core)
_presentation(node_group)
diff --git a/tools/nodes/arrangement/dimensions.py b/tools/nodes/arrangement/dimensions.py
index 1f7c41b..2e14d07 100644
--- a/tools/nodes/arrangement/dimensions.py
+++ b/tools/nodes/arrangement/dimensions.py
@@ -22,6 +22,8 @@ def visible_socket(socket):
def estimate_node_dimensions(node):
"""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":
return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)})
inputs = [s for s in node.inputs if visible_socket(s)]
diff --git a/tools/nodes/arrangement/fields.py b/tools/nodes/arrangement/fields.py
index c39333b..5cf7c72 100644
--- a/tools/nodes/arrangement/fields.py
+++ b/tools/nodes/arrangement/fields.py
@@ -33,6 +33,22 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
continue
for node in members[1:]:
roots[root(node)] = root(members[0])
+ # Stack exclusive calculation chains without disturbing ordered operands.
+ operands = {node for sources in peers if len(sources) > 1 for node in sources}
+ chain_heights = {node: sizes[node].height for node in nodes}
+ simple = {"ShaderNodeMath", "ShaderNodeVectorMath", "ShaderNodeClamp"}
+ for node in nodes:
+ sources = [parent for parent in parents[node] if parent in roots]
+ if node.bl_idname not in simple or node in operands or len(sources) != 1:
+ continue
+ source = sources[0]
+ if source in operands or {link.to_node for socket in source.outputs for link in socket.links} != {node}:
+ continue
+ first, last = root(source), root(node)
+ height = chain_heights[first] + gap + chain_heights[last]
+ if first != last and height <= target_height:
+ roots[last] = first
+ chain_heights[first] = height
groups = defaultdict(list)
for node in nodes:
groups[root(node)].append(node)
@@ -80,11 +96,12 @@ def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
layers = columns
depth = column - 1
links = [link for node in nodes for socket in node.inputs for link in socket.links if link.from_node in block_of]
- edges, passages = [], []
+ edges, passages, vertical = [], [], []
for link in links:
first, last = block_of[link.from_node], block_of[link.to_node]
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]
end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket]
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}")
centers = result.x[:count]
positions, point_positions, right = {}, {}, 0
+ vertical_blocks = {block_of[link.from_node] for link in vertical}
for rank in range(depth + 1):
layer = layers[rank]
width = max(max(sizes[node].width for node in blocks[item]) if item < len(blocks) else 16 for item in layer)
+ padding = gap / 2 if any(item in vertical_blocks for item in layer) else 0
if rank:
- run = max([gap, *(abs(centers[first] + a - centers[last] - b) * 0.8
- for first, last, a, b in edges if ranks[last] == rank)])
- right += run
- right += width
+ right += gap
+ right += width + padding * 2
for item in layer:
if item < len(blocks):
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:
point_positions[item] = (right, -centers[item])
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
or not abs(points[index-1][1] - point[1]) < 1e-6 or not abs(points[index+1][1] - point[1]) < 1e-6]
routes.append((link, points))
+ for link in vertical:
+ source, target = link.from_node, link.to_node
+ x1, y1 = positions[source]
+ x2, y2 = positions[target]
+ middle = (y1 - sizes[source].height + y2) / 2
+ routes.append((link, [(x1 + sizes[source].width + gap / 2, y1 - sizes[source].sockets[link.from_socket]),
+ (x1 + sizes[source].width + gap / 2, middle),
+ (x2 - gap / 2, middle), (x2 - gap / 2, y2 - sizes[target].sockets[link.to_socket])]))
return FieldLayout(positions, right, result.x[count], routes)
diff --git a/tools/nodes/arrangement/frames.py b/tools/nodes/arrangement/frames.py
new file mode 100644
index 0000000..cdf0162
--- /dev/null
+++ b/tools/nodes/arrangement/frames.py
@@ -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
diff --git a/tools/nodes/arrangement/passages.py b/tools/nodes/arrangement/passages.py
new file mode 100644
index 0000000..e962fa2
--- /dev/null
+++ b/tools/nodes/arrangement/passages.py
@@ -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
diff --git a/tools/nodes/arrangement/references.py b/tools/nodes/arrangement/references.py
new file mode 100644
index 0000000..d945668
--- /dev/null
+++ b/tools/nodes/arrangement/references.py
@@ -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))
diff --git a/tools/nodes/arrangement/sections.py b/tools/nodes/arrangement/sections.py
new file mode 100644
index 0000000..b445bbf
--- /dev/null
+++ b/tools/nodes/arrangement/sections.py
@@ -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)
diff --git a/tools/nodes/arrangement/sources.py b/tools/nodes/arrangement/sources.py
new file mode 100644
index 0000000..a10f691
--- /dev/null
+++ b/tools/nodes/arrangement/sources.py
@@ -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)
diff --git a/tools/nodes/arrangement/zones.py b/tools/nodes/arrangement/zones.py
index 9eed810..ddbd72c 100644
--- a/tools/nodes/arrangement/zones.py
+++ b/tools/nodes/arrangement/zones.py
@@ -3,6 +3,20 @@
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):
"""Return each paired entry, exit, and its data-dependent node membership."""
parents = {node: set() for node in node_group.nodes}
diff --git a/tools/nodes/check.py b/tools/nodes/check.py
index 97db36e..98d0753 100644
--- a/tools/nodes/check.py
+++ b/tools/nodes/check.py
@@ -58,7 +58,8 @@ def check_asset_library(asset_path):
modifier = obj.modifiers.new("Asset Check", "NODES")
modifier.node_group = node_group
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:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
modifier[item.identifier] = values[item.name]
diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py
index 7ed05a3..536e2a5 100644
--- a/tools/nodes/common/boundary_smoothing.py
+++ b/tools/nodes/common/boundary_smoothing.py
@@ -55,6 +55,12 @@ def smooth_boundary_camera(group, camera, protected=False):
links.new(camera, axes.inputs[0])
depth = axes.outputs["Z"]
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)
enabled = _math(group, "MULTIPLY", changed, _math(group, "GREATER_THAN", depth, 1e-8))
projected = nodes.new("ShaderNodeVectorMath")
diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py
index b66047b..a52a91c 100644
--- a/tools/nodes/common/cutout_boundary.py
+++ b/tools/nodes/common/cutout_boundary.py
@@ -36,10 +36,8 @@ def taper_split_profile(group, original, geometry):
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
protected = sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
influence = boundary_influence(group, protected)
- controls = group_input(nodes, {"Edge Round", "Split Threshold"})
- strength = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Edge Round"], 4))
- band = _math(group, "MINIMUM", _math(group, "MULTIPLY", influence, strength), 1)
- band = _math(group, "MULTIPLY", band, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 0))
+ controls = group_input(nodes, {"Split Threshold"})
+ band = _math(group, "MULTIPLY", influence, _math(group, "GREATER_THAN", controls.outputs["Split Threshold"], 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)
return store_float_attribute(group, geometry, "o_balloon", profile)
diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py
index ff48481..05791f2 100644
--- a/tools/nodes/common/depth_surface.py
+++ b/tools/nodes/common/depth_surface.py
@@ -155,15 +155,23 @@ def sample_face_camera(group, image):
return texture.outputs["Color"]
-def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
- face_center=None, triangles=False):
- """Split adjacent face samples using their Z depth and base center spacing."""
+def ray_distance(group, camera):
+ """Measure the distance from the camera origin to a camera-space point."""
+ 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
if face_center is None:
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
- axes = nodes.new("ShaderNodeSeparateXYZ")
- links.new(face_camera, axes.inputs[0])
+ if face_distance is None:
+ face_distance = ray_distance(group, face_camera)
centers, depths = [], []
for side in (0, 1):
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"])
for data_type, field, results in (
("FLOAT_VECTOR", face_center, centers),
- ("FLOAT", axes.outputs["Z"], depths),
+ ("FLOAT", face_distance, depths),
):
sample = nodes.new("GeometryNodeFieldAtIndex")
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):
links.new(center, distance.inputs[index])
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
- scaled = _math(group, "MULTIPLY", delta, uniform)
- # Cross multiplication avoids division and stays invariant under uniform scaling.
+ local_distance = _math(group, "MAXIMUM", _math(group, "MULTIPLY", _math(group, "ADD", *depths), 0.5), 1e-8)
+ 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(
group,
"GREATER_THAN",
- _math(group, "MULTIPLY", scaled, strength),
- _math(group, "MULTIPLY", distance.outputs["Value"], 2.5),
+ _math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength),
+ _math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5),
)
selected = _math(
group,
@@ -352,7 +362,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute")
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"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
diff --git a/tools/nodes/common/nodes.py b/tools/nodes/common/nodes.py
index c873074..bb2a957 100644
--- a/tools/nodes/common/nodes.py
+++ b/tools/nodes/common/nodes.py
@@ -11,6 +11,15 @@ def prepare_node_group(node_group):
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):
socket = group.interface.new_socket(
name=name,
diff --git a/tools/nodes/common/outline_smoothing.py b/tools/nodes/common/outline_smoothing.py
new file mode 100644
index 0000000..3b66818
--- /dev/null
+++ b/tools/nodes/common/outline_smoothing.py
@@ -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])
diff --git a/tools/nodes/common/smoothing.py b/tools/nodes/common/smoothing.py
index 6bcbfa0..683a056 100644
--- a/tools/nodes/common/smoothing.py
+++ b/tools/nodes/common/smoothing.py
@@ -51,7 +51,6 @@ def expand_smooth(
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
- repeat_output.repeat_items.new("GEOMETRY", "Geometry")
links.new(
iteration_controls.outputs[iterations_name],
repeat_input.inputs["Iterations"],
@@ -109,11 +108,6 @@ def expand_smooth(
links.new(boundary_points.outputs["Value"], choose_boundary.inputs["Switch"])
links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"])
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]
if pin_sharp:
@@ -126,15 +120,10 @@ def expand_smooth(
normal_coherence = nodes.new("ShaderNodeVectorMath")
normal_coherence.operation = "LENGTH"
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.operation = "MULTIPLY"
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]
if influence is not None:
masked_weight = nodes.new("ShaderNodeMath")
@@ -143,17 +132,12 @@ def expand_smooth(
links.new(influence, masked_weight.inputs[1])
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")
mix_position = nodes.new("ShaderNodeMix")
mix_position.data_type = "VECTOR"
links.new(weight, mix_position.inputs[0])
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")
links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"])
links.new(mix_position.outputs["Result"], set_position.inputs["Position"])
diff --git a/tools/nodes/groups/image_depth_balloon.py b/tools/nodes/groups/image_depth_balloon.py
index 3868b59..333441d 100644
--- a/tools/nodes/groups/image_depth_balloon.py
+++ b/tools/nodes/groups/image_depth_balloon.py
@@ -110,7 +110,7 @@ def build_image_depth_balloon_group():
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY'
- group['anyimage_asset_revision'] = 11
+ group['anyimage_asset_revision'] = 12
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
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)
options = group.interface.new_panel(name='Options', default_closed=True)
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, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options)
data = group.interface.new_panel(name='Data', default_closed=True)
interface_socket(group, 'Inward Axis', 'INPUT', 'NodeSocketMenu', data).hide_in_modifier = True
diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py
index a88aed9..1245f4c 100644
--- a/tools/nodes/groups/image_depth_cutout.py
+++ b/tools/nodes/groups/image_depth_cutout.py
@@ -4,7 +4,8 @@ from mathutils import Matrix
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.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.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
@@ -72,83 +73,33 @@ def build_volume_fields(group):
return {"rim": rim, "center": center, "thickness": thickness}
-def build_volume_rounding(group, geometry, fields, uniform, normal_weight=1):
- links = group.links
-
- control = group.nodes.new("NodeGroupInput")
- profile = group.nodes.new("GeometryNodeInputNamedAttribute")
- 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")
+def build_shell_fields(group, geometry, fields, uniform, normal_weight=1):
+ """Evaluate rear directions on the center surface and select shell thickness."""
+ nodes, links = group.nodes, group.links
+ position = nodes.new("GeometryNodeInputPosition")
+ axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
- target = _math(group, "ADD", center, balloon)
- 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")
+ center_position = nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Y"):
links.new(axes.outputs[axis], center_position.inputs[axis])
- links.new(center, center_position.inputs["Z"])
- surface = group.nodes.new("GeometryNodeSetPosition")
+ links.new(fields["center"], center_position.inputs["Z"])
+ surface = nodes.new("GeometryNodeSetPosition")
links.new(geometry, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"])
- normal = group.nodes.new("GeometryNodeInputNormal")
- blur = group.nodes.new("GeometryNodeBlurAttribute")
+ normal = nodes.new("GeometryNodeInputNormal")
+ blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
+ blur.inputs["Iterations"].default_value = 100
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)
- index = group.nodes.new("GeometryNodeInputIndex")
- sample = group.nodes.new("GeometryNodeSampleIndex")
- sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT"
- 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")
+ rear_normal = sample_field(group, surface.outputs[0], normalized, "FLOAT_VECTOR")
+ control = group_input(nodes, {"Uniform Thickness"})
+ thickness = nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT"
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"])
- rounding = {
- "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
+ return {"thickness": thickness.outputs[0], "normal": rear_normal}
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)
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.data_type = "FLOAT_VECTOR"
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)
return {"structure": structure, "inflation_normal": normalized}
@@ -247,17 +194,21 @@ def build_inflation_position(group, position, distance, uniform, fields):
result.operation = "ADD"
links.new(position, result.inputs[0])
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):
links = group.links
normal = group.nodes.new("GeometryNodeInputNormal")
- control = group.nodes.new("NodeGroupInput")
blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
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)
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)
uniform_position = vector("ADD", front, thin_offset)
- # R = t * (front_z - center) + (1 - t) * front_delta, with t clamped.
- # This avoids division by Thickness and removes the old zero-thickness drift.
- control = group.nodes.new("NodeGroupInput")
+ control = group_input(group.nodes, {"Thickness"})
clamp = group.nodes.new("ShaderNodeClamp")
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0]
- center = fields["center"]
- delta = fields["front_delta"]
- radius = _math(group, "SUBTRACT", front_z, center)
- radius = _math(group, "MULTIPLY", radius, t)
- 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)
+ radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
+ rear = _math(group, "SUBTRACT", 1, read_float_attribute(group, "o_normal_scale"))
+ rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
+ direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
rear_shift = vector("SCALE", direction, rear_radius)
axis_offset = group.nodes.new("ShaderNodeCombineXYZ")
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(thin_offset, mix.inputs[4])
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.input_type = "VECTOR"
links.new(uniform, choose.inputs["Switch"])
@@ -351,11 +281,11 @@ def _build_depth_surface(group, geometry, controls):
scale = controls.outputs["Uniform Scale"]
original = geometry
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)
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(
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 = build_front_normal(group, normal_weight)
front = build_front_fields(group, volume["rim"], normal, normal_weight)
- rounded, rounding = build_volume_rounding(group, projection, volume, uniform, normal_weight)
- thickness = sample_field(group, projection, rounding["thickness"], "FLOAT")
+ shell_fields = build_shell_fields(group, projection, volume, uniform, normal_weight)
+ thickness = sample_field(group, projection, shell_fields["thickness"], "FLOAT")
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 = {
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({
name: sample_field(group, projection, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT",
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)
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.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY'
- group['anyimage_asset_revision'] = 18
+ group['anyimage_asset_revision'] = 23
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
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.min_value, iterations.max_value = (0, 20)
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, '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, '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)
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
diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py
index abf3d3f..5ae1034 100644
--- a/tools/nodes/groups/image_depth_panorama.py
+++ b/tools/nodes/groups/image_depth_panorama.py
@@ -39,7 +39,7 @@ def _quad_sphere(group, segments):
def build_image_depth_panorama_group():
group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
group.is_modifier = group.use_fake_user = True
- group["anyimage_asset_revision"] = 14
+ group["anyimage_asset_revision"] = 15
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
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)
data = group.interface.new_panel("Data", default_closed=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")
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(delete.outputs[0], selection.inputs["False"])
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"]
- geometry, corner_camera, cut_vertex = split_depth_surface(group, selection.outputs[0], depth(combine(face_u, face_v))[0], strength,
- _math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]),
- face_center=face.outputs["Value"])
+ face_camera = depth(combine(face_u, face_v))[0]
+ radial = nodes.new("ShaderNodeSeparateXYZ")
+ 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)
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(distance, axes.inputs[0])
diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py
index 7b787bd..bc259e5 100644
--- a/tools/nodes/groups/image_depth_plane.py
+++ b/tools/nodes/groups/image_depth_plane.py
@@ -25,7 +25,7 @@ def build_image_depth_plane_group(image_plane_group):
"GeometryNodeTree",
)
group.is_modifier = True
- group["anyimage_asset_revision"] = 14
+ group["anyimage_asset_revision"] = 16
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
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.default_value = 1.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.default_value = 0
iterations.min_value, iterations.max_value = 0, 20
valid_only = _interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool")
valid_only.default_value = 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.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"
- 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.default_value = 0.9
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(protected, cull.inputs["False"])
split_input = _group_input(nodes, {"Split Threshold"})
+ reference_input = _group_input(nodes, {"Reference Depth"})
geometry, corner_camera, cut_vertex = split_depth_surface(
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(
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"],
)
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(
group, surface, shell_input.outputs["Thickness"],
- normal_smooth=shell_input.outputs["Normal Smooth"],
+ normal_smooth=100,
)
positive = nodes.new("ShaderNodeMath")
positive.operation = "GREATER_THAN"
diff --git a/tools/nodes/preview/export.py b/tools/nodes/preview/export.py
index 4f89319..8fb195a 100644
--- a/tools/nodes/preview/export.py
+++ b/tools/nodes/preview/export.py
@@ -9,7 +9,7 @@ import bpy
from ..arrangement.dimensions import estimate_node_dimensions
from ..arrangement.flow import node_roles
from ..arrangement.connections import link_endpoints
-from ..arrangement.zones import node_zones
+from ..arrangement.zones import node_zones, repeat_regions
def _display_value(value):
@@ -30,7 +30,7 @@ def _display_value(value):
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)}
dimensions = {node: estimate_node_dimensions(node) for node in nodes}
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])
records.append({"id": ids[node], "name": node.name, "title": title,
"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),
"settings": settings, "sockets": sockets})
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:
source, target = link.from_node, link.to_node
(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])
zones = []
+ repeats = {frozenset(members): box for members, box in repeat_regions(node_group, positions, dimensions)}
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]
- 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
- 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
+ if entry.bl_idname == "GeometryNodeRepeatInput":
+ left, right, upper, lower = repeats[frozenset(members)]
+ top, bottom = -upper, -lower
zones.append({"title": entry.bl_label.removesuffix(" Input"),
"type": entry.bl_idname, "entry": ids[entry], "exit": ids[end],
"nodes": sorted(ids[node] for node in members),
"x": left, "y": top, "w": right - left, "h": bottom - top})
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):
diff --git a/tools/nodes/preview/template.html b/tools/nodes/preview/template.html
index 8c0c94b..06d4dbe 100644
--- a/tools/nodes/preview/template.html
+++ b/tools/nodes/preview/template.html
@@ -76,22 +76,33 @@
dy = (height - (bottom + top) * scale) / 2;
draw();
}
- function updateZoneViews() {
- view.querySelectorAll('[data-zone]').forEach(option => option.remove());
+ function updateRegionViews() {
+ view.querySelectorAll('[data-zone], [data-frame]').forEach(option => option.remove());
current.zones.forEach(zone => {
const option = document.createElement('option');
option.value = `zone:${zone.entry}`; option.dataset.zone = 'true';
option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`;
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() {
selected = null;
if (view.value === 'all') {
- fit(bounds([...current.nodes, ...current.zones]));
+ fit(bounds([...current.nodes, ...current.zones, ...current.frames]));
detail.textContent = '完整排列';
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 (current.zones.length) {
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.strokeRect(0.5, 0.5, width - 1, height - 1);
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) {
const x = zone.x * scale + dx, y = zone.y * scale + dy;
const w = zone.w * scale, h = zone.h * scale;
@@ -236,7 +258,9 @@
}
picker.addEventListener('change', () => {
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);
root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5));
@@ -274,6 +298,6 @@
}).observe(canvas);
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
- updateZoneViews();
+ updateRegionViews();
})();