feat: Add new Geometry Nodes groups for image relief and shadeless shader

- Implemented the "O Image Relief Plane" Geometry Nodes group to create a fixed-base image relief using depth images and various parameters.
- Created the "O Shadeless" shader node group to display color without scene lighting while preserving shadows and reflections.
- Introduced a preview feature for node groups, allowing for interactive visualization of node arrangements in an HTML format.
- Added a template HTML file for rendering node previews with customizable views and controls.
This commit is contained in:
2026-09-09 21:24:57 +08:00
parent f5aff50b13
commit 89087fc2e2
118 changed files with 5635 additions and 3130 deletions

View File

@@ -19,7 +19,7 @@
- 按业务职责梳理代码结构与命名;简单功能用单文件,复杂 Operator 功能包统一以 `operators.py` 为入口,`__init__.py` 负责导出与注册清单。
- 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。
- 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。
- 同步整理 `tools/node_assets`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。
- 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。
- 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或产物。
## 查询文档
@@ -88,22 +88,4 @@ uv run sync_models
## Blender 节点组规范
- 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`)
- 节点组通过[节点资产](docs/internals/node-assets.md)说明构建,禁止运行时调用 `bpy.data.node_groups.new()` 新建
- 节点组变更须同步构建脚本、验证、测试和节点资产文档
- 将节点组当作程序函数:先确定传递几何的主轴,主轴从左到右连续排列。主轴可适当宽松,支线内部尽量紧凑;同一目的的节点距离应明显小于不同部分之间的距离,用留白表达用途分组
- Join Geometry、Menu Switch、Combine XYZ 等节点前,承担同级输入的独立分支末端尽量排成一列、输出端对齐;XYZ 三轴也属于同级输入,菜单分支按输入顺序上下排列。共享上游留在公共位置,不为末端对齐搬动整条计算链
- 属性计算作为并行支线,就近汇入主轴,不因计算节点多而延长主轴。同一目的的计算聚在主轴同一侧,独立用途可在两侧错落展开;不追求两侧均衡,长短和距离随内容变化,优先利用附近空位
- 长算式适当横排,不强迫所有支线竖排或等长。支线可以长过对应的主链步骤,几何输入也不必是最左侧节点
- Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线
- 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV
- Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开
- 长距离前向几何引用使用少量 Reroute 分段引导;同源引用共用干线,在用途区域附近分叉,避免多条斜线横穿主轴和计算支线
- Switch 的原始几何旁路用 Reroute 沿主链外侧绕行,接近 Switch 时再汇入;旁路下置时,将该段属性计算集中在主链上方,留出连续通道
- 支线确需从主轴向左回接时直接连接,允许这些连线交叉;Reroute 用于明确路径与分叉,节点本体仍不得重叠或被遮挡
- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域
- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见
- Repeat、Simulation、For Each Geometry Element 等成对节点,确保位置正确,没有遮挡其他节点
- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE`
- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认
- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变
- 在 Blender 节点编辑器按实际绘制尺寸检查总览和局部步骤,确认主轴、支线长度、节点间距及配对区域清楚;
- 根据[节点资产](docs/internals/node-assets.md)说明构建节点组,禁止调用 `bpy.data.node_groups.new()` 新建

View File

@@ -34,7 +34,7 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何
`tools/pack.py` 从 `pyproject.toml` 读取依赖,按平台与 Blender Python 版本解析 wheel,使用 Blender 提供的 NumPy。产物命名为 `AnyImage.v<version>.<platform>.zip`,写入 `dist/`。本地 BlendJob wheel 内容变化后运行 `uv lock --refresh`,并核对锁文件、Manifest 与打包测试。
`tools/sync_models.py` 根据模型目录选择上游 ONNX 或本地导出流程;`model_exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`--prepare-only` 用于本地准备。
`tools/models/cli.py` 根据模型目录选择上游 ONNX 或本地导出流程;`tools/models/exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`--prepare-only` 用于本地准备。
## CI 与文档导航

View File

@@ -1,149 +1,51 @@
# 节点资产
生产节点组由脚本构建到 `src/anyimage/assets/O_AnyImage.blend`;运行时只加载,不创建节点组。
## 构建节点资产
## 构建
生产节点组由 `uv run build_node` 构建到 `src/anyimage/assets/O_AnyImage.blend`
在项目根目录执行:
节点组文件在 `tools/nodes/groups`
```bash
uv run build_node
```
节点组规则:
脚本依次构建资产、运行三项 Blender 验证和完整测试,任一步失败即退出。
- Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线
- 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV
- Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开
- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域
- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见
- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE`
- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认
- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变
脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置:
```dotenv
BLENDER_BIN="/absolute/path/to/blender"
```
`.env` 已被 Git 忽略。临时指定路径或跳过 Python 测试:
```bash
uv run build_node --blender "/path/to/blender"
uv run build_node --skip-tests
```
## 资产清单
| 节点组 | 构建模块 |
| --- | --- |
| `O Image Plane` | `image_plane.py` |
| `O Image Depth Plane` | `depth_plane.py` |
| `O Image Layer`、`O Image Depth Layer` | `material_layers.py` |
| `O Image Depth Plane` | `image_depth_plane.py` |
| `O Image Relief Plane` | `image_relief_plane.py` |
| `O Image Layer` | `image_layer.py` |
| `O Image Depth Layer` | `image_depth_layer.py` |
| `O Shadeless` | `shadeless.py` |
| `O Image Cutout`、`O Image Depth Cutout`、`O Image Depth Balloon` | `cutout.py` |
| `O Image Cutout` | `image_cutout.py` |
| `O Image Depth Cutout` | `image_depth_cutout.py` |
| `O Image Depth Balloon` | `image_depth_balloon.py` |
构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。
## 源码职责
## 构建资产脚本
`tools/node_assets/` 的模块按资产、几何计算、布局和验证组织:
`tools/nodes/build.py` 按依赖顺序构建资产,调用 `arrangement/` 统一排列几何节点组后保存。`common/` 提供共用构建函数,`preview/` 导出交互预览。
| 模块 | 职责 |
| --- | --- |
| `asset_library.py` | 构建全部节点组并保存唯一资产 |
| `image_plane.py`、`depth_plane.py` | 普通平面与 Relief / Camera 深度平面 |
| `material_layers.py`、`shadeless.py` | 图像材质层与无光照着色 |
| `cutout.py`、`cutout_shapes.py` | Cutout 修改器接口与各预设几何 |
| `depth_plane_camera.py`、`depth_surface.py` | Camera 平面、共享投影、深度分离与厚度 |
| `cutout_volume.py`、`cutout_rounding.py` | 平滑中心曲面与轮廓圆润 |
| `cutout_inflation.py`、`cutout_shell.py` | 正面膨胀、法线和正背面位置 |
| `plane_layouts.py`、`depth_plane_layout.py` | 平面资产与 Camera 分支布局 |
| `cutout_layout.py`、`cutout_shape_layouts.py`、`depth_cutout_layout.py` | Cutout 阶段布局、坐标与跨区域几何引用 |
| `node_graph.py`、`node_layout.py` | 共用字段节点、平滑构建、布局与边界检查 |
| `asset_validation.py` | 资产清单、接口、结构与布局检查 |
| `image_plane_validation.py`、`cutout_validation.py` | 平面和 Cutout 几何求值验证 |
保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。
`tools/build_node.py` 依次启动资产构建与三个验证脚本。运行时加载入口位于 `common/node.py`。
Depth Cutout 的布局由 `depth_cutout_layout.py` 编排:Split 属性采样位于主链上方,原始几何旁路沿下方的 Reroute 通道汇入 Switch;长距离投影引用共用分段干线,并在 Uniform 厚度与零厚度输出附近分叉。几何统计支线就近放置在其使用区域。
三个 Cutout 节点组直接作为修改器使用,统一接收局部 XY 平面,以 `-Z` 表示向图片内部的方向。每组在塑形与平滑后处理 **Inward Axis**:默认 `-Z` 直接输出原生几何,`+X` 分支执行一次 Z 到 X 的轴转换;运行时对象矩阵使用逆变换补偿,保证两种模式落在相同世界空间位置。
`O Image Depth Plane`、`O Image Depth Cutout` 与 `O Image Depth Balloon` 使用 **Reference Depth** 作为采样深度的运算基准。
### Cutout 预设与厚度
| 创建入口 | 修改器节点组 | 默认设置 |
| --- | --- | --- |
| Flat | O Image Cutout | Balloon,Thickness 0 |
| Solid(默认) | O Image Cutout | Balloon,Thickness 1 |
| Depth Solid | O Image Depth Cutout | Balloon,Thickness 1 |
| Depth Balloon | O Image Depth Balloon | Thickness 1,Double Sided 开启 |
前两种节点组提供 **Mode**,默认显示 Balloon。两个独立输入均显示为 **Thickness**:第一个是 Balloon 的无量纲倍率,第二个是 Uniform 的距离,各自保存数值。普通 Cutout 在零厚度时输出平面,深度 Cutout 输出深度表面。全部基础网格包含 POINT 域的 `o_balloon`,Flat 可直接调整为 Solid。
深度 Cutout 的 Balloon 厚度由 `cutout_volume.py` 构造平滑中心,再由 `cutout_rounding.py` 处理圆润轮廓与凸起方向。Options 中位于 Front Inflation 前的 **Edge Round** 默认 0.2,正背面共用 `1 − smootherstep(0, max(o_balloon), o_balloon)` 衰减权重。正面向中心加 Balloon 的轮廓靠拢,背面按 Balloon 值补充饱满度。Options 中的 **Normal Smooth** 默认 50:在中心曲面上平滑并归一化法线,背面沿该方向凸起,正面按边缘权重过渡。两个边缘范围及法线影响固定为 1。
中心位置以投影正面 Z 减去 `o_balloon` 初始化,固定轮廓与切口,在内部通过一个 Blur Attribute 执行 256 次邻域平滑,边界 Weight 为 0。圆润处理前,深凹处保留至少 `0.05 × o_balloon` 的厚度。轮廓零厚度点按来源身份合并,暂存字段使用 POINT 域 `o_balloon_surface_*` 并在输出前清理。`cutout_shell.py` 让薄厚度的 Balloon 从正面沿法线向内生长,再按平滑过渡达到 Thickness 为 1 的圆润体积;大于 1 时继续沿用该体积公式。Uniform 沿正面法线偏移指定距离,同样受 Normal Smooth 控制。Thickness 为 0 时返回原深度表面。整体 Smooth 复用公共构建函数,三个 Cutout 节点组的 Smooth 默认 0,Smooth Weight 默认 1。
`cutout_inflation.py` 在邻域中拟合深度已有的结构性鼓起,Options 中 **Front Inflation** 默认 1。Balloon 令结构量为 k、Thickness 与 Front Inflation 的乘积为 a,正面增加 `o_balloon × (cbrt((k+0.25)³+(a+0.25)³−0.25³)−k−0.25)`,通过有理化形式求值以保持小厚度精度。已有 1 倍鼓起、两个参数均为 1 时,额外增加约 0.323 倍 Balloon。Uniform 直接增加 `Thickness × Front Inflation`,不受结构量或轮廓权重限制;Front Inflation 为 1 时,正面边缘和中心的增量均等于背面的厚度距离。两种模式厚度为 0 时仍返回原表面,单层侧壁直接连接正背面。
**Normal Smooth** 同时控制正面、背面与 Uniform 的法线;正面膨胀在基础法线平滑之后使用 5.12 倍迭代范围,默认 50 对应额外 256 次,保持大膨胀量下的连续方向。设置为 0 时两阶段均停止平滑。Front Inflation 为 0 时关闭正面额外鼓起。
Normal Map 勾选控制 Flat 与 Solid 的 TANGENT 法线;Depth Solid 固定生成 OBJECT 法线,Depth Balloon 固定生成 TANGENT 法线。材质读取 `o_normal_scale`:普通 Cutout 和 Depth Balloon 使用 POINT 域的轮廓适配权重;Depth Cutout 使用 FACE 域的正面标记,让背面与切口保留几何法线。该属性属于输出材质协议,随 UVMap 与 o_balloon 保留。
Options 依次排列 Cleanup Threshold、Split Threshold、Reference Depth、Edge Round、Front Inflation、Normal Smooth、Smooth Weight;各节点组仅显示适用项,Depth Balloon 的 Depth Direction 位于末尾。
### Depth Cutout 分离与厚度
Options 中的 **Split Threshold** 范围为 `0–1`,默认 `0` 关闭分离,数值越大越容易分离。节点比较 `原始 Z 差 × Uniform Scale × Split Threshold > 原始面中心距离 × 2.5`;`0.5` 对应深度坡度阈值 `5`。**Uniform Scale** 将原始深度标定到网格单位,**Depth Scale** 仅控制最终置换幅度。
```mermaid
flowchart TD
A[基础网格与深度图片] --> D[共用 Cleanup 按原平面岛面积清理]
D --> B[面中心采样与共享边判别]
B --> C[存入面角命名属性并 Split Edges]
C --> E[切口顶点沿原相机射线采用所属侧深度]
E --> J[固定顶点数据并投影]
J --> L[选择平滑 Balloon 或 Uniform 厚度]
L --> F[生成正背面与单层四边侧壁]
F --> G[按原顶点及正背面身份连接]
G --> H[恢复场景坐标]
H --> K[移除 o_depth_* 暂存属性]
K --> I[边缘与整体平滑及方向变换]
```bash
uv run build_node --blender "/path/to/blender"
uv run build_node --skip-tests
```
三个节点组在塑形前复用 Cleanup,按原平面岛的总面积清理小区域。Depth Cutout 随后执行 Split,保留分离产生的小片;Split 关闭时使用原顶点采样。
脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置:
未关联选中切边的顶点使用原始 XYZ。切口顶点以 `原 XYZ × 所属侧 Z / 原 Z` 保持原相机射线,随后执行既有 Uniform Scale、Reference Depth 和 Depth Scale 投影。
厚度前固定顶点数据,使正背面和侧壁使用相同位置。连接时以原顶点编号和正背面身份构造临时整数坐标,合并同一身份的副本后恢复场景坐标,保持近距离分离区域各自独立。实现位于 `depth_surface.py`,由 `cutout.py` 构建进共享节点资产。
暂存值通过 Store Named Attribute 写入保留前缀 `o_depth_`:面中心与相机采样使用 FACE,所属侧相机采样与切口标记使用 CORNER,最终相机坐标、表面位置与源顶点编号使用 POINT。计算区域就近使用 Named Attribute,末尾用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,输出保留 UVMap 与用户属性。
### 整图 Depth Plane
`O Image Depth Plane` 提供默认 **Relief** 与 **Camera** 模式,共用完整矩形网格和 `depth.exr`。RGB 保存相机 XYZ,Alpha 保存有效性。Relief 与 Depth Balloon 读取 B/Z,Camera 与 Depth Cutout 读取 XYZ。用户控制为 **Mode**、**Subdivide**、**Thickness**、**Depth Scale** 和 Options 中的 **Reference Depth**、**Normal Smooth**;**Depth Image** 与 **Uniform Scale** 为隐藏数据输入。
```mermaid
flowchart TD
A[O Image Plane 零厚度矩形细分] --> B[按 Depth Scale 插值相机 XY 与相对 Z]
B --> C{Thickness}
C -- 0 --> D[单层曲面]
C -- 大于 0 --> E[沿平滑法线向内生成背面并连接边界]
D --> F[清理临时属性并输出]
E --> F
```dotenv
BLENDER_BIN="/absolute/path/to/blender"
```
两个独立输入均显示 Thickness,默认均为 0 m:第一个控制 Relief 的固定底面基础厚度,第二个控制 Camera 的向内壳厚度。上图描述 Camera 分支:Depth Scale 为 0 时恢复平面,1 时使用完整相机投影;正面 POINT 法线按 Normal Smooth 平滑并归一化,再按 Thickness 向内生成背面。Normal Smooth 范围 0–50、默认 50,仅影响 Camera。模式切换保留各自厚度值并复用图片。Camera 分支位于 `depth_plane_camera.py`,投影与厚度共用 `depth_surface.py`,布局由 `depth_plane_layout.py` 编排。
### 几何节点布局
全部五个几何节点组使用连续横排的几何主轴。主轴可适当宽松,支线内部紧凑,同一目的的计算聚在同一侧,内部间距明显小于与其他部分的距离;独立用途可在两侧错落展开。长算式横排,末端就近汇入主轴。Join Geometry、Menu Switch、Combine XYZ 的同级独立分支末端排成一列并对齐输出端,菜单分支按输入顺序上下排列,共享上游留在公共位置。必要的向左引用直接连接。Group Input 与 Named Attribute 就近放置,全接线的简单运算可折叠,常数和域设置保持可见。
`plane_layouts.py` 组织平面资产布局,`cutout_shape_layouts.py` 和 `depth_cutout_layout.py` 组织 Cutout 的局部计算阶段;`cutout_layout.py` 将阶段布局放入三个修改器,`node_layout.py` 验证节点边界与同级输入对齐。布局验收包含实际节点编辑器预览及几何求值。
## 修改节点组
1. 修改 `tools/node_assets/` 中对应资产、计算或布局模块;
2. 更新对应验证;
3. 运行一键构建脚本;
4. 搜索并清理旧名称、接口和路径;
5. 检查 `.blend`、代码、测试和文档差异。
接口、节点、连线、默认值或布局变化时必须重建。只修改运行时 Python、Markdown 或打包逻辑时无需重建。
`O_AnyImage.blend1` 等备份不应提交或打包。

View File

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

View File

@@ -0,0 +1,44 @@
## Context
`build_material_layer_group()` 共用构建 O Image Layer 和 O Image Depth Layer。普通层直接传出输入 Alpha,深度层使用前景阈值。`create_image_material()` 已集中创建材质,并通过 `configured_material_view_adaptation()` 配置颜色图片。
## Goals / Non-Goals
目标是将参考图的 Alpha 调整内置到 O Image Layer,支持连续强度调节,并在创建时读取现有偏好。变更范围为普通图像材质层;深度层继续使用其前景阈值协议。
## Decisions
### 曲线与接口
O Image Layer 的默认折叠面板 `Options` 依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix 类型为 NodeSocketFloat,subtype 为 FACTOR,范围 0–1,默认 0。直接连接 ShaderNodeFloatCurve 的 Factor,复用节点自身的混合语义:`output = (1 - factor) * alpha + factor * curve(alpha)`。
曲线使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、上方控制点 `(0.925, 0.925)` 和终点 `(1, 1)`。上方控制点为参考图估计值。采用自动钳制手柄、水平延伸及 0–1 的裁剪与采样区间。低端控制点之后快速上升,完整调整时输入 0.85 输出约 0.1593、输入 0.9 输出约 0.774;低端附近存在 Blender 曲线查表插值误差,测试容差为 0.0003。
```mermaid
flowchart TD
A[组输入 Alpha] --> C[Float Curve Value]
B[组输入 Alpha Fix] --> D[Float Curve Factor]
C --> E[曲线调整并按 Factor 混合]
D --> E
E --> F[组输出 Alpha]
F --> G[Principled Alpha 或 Shadeless 混合系数]
```
Alpha 曲线放在独立且就近的支线,保持曲线可见、默认节点标题和未使用输出隐藏;Color、Roughness 与 Normal 保持各自连接。
### 创建时应用偏好
`create_image_material()` 在普通层节点绑定后读取现有公共偏好函数,将节点实例的 `Alpha Fix` 设为 1 或 0。判断依据就是偏好值,因此开启偏好但颜色空间回退到 sRGB 时仍设为 1。手动拖入节点组时采用资产默认值 0。
用户随后可独立调整每个材质实例;偏好切换只影响后续创建。普通着色与 Shadeless 共用组输出 Alpha。Color 图片的颜色空间和 Alpha 模式继续由现有配置入口处理。
### 验证与交付
修改构建脚本、资产验证及材质测试;通过测试期间构建的节点组检查接口、连接与曲线采样。节点资产文档按节点组专项规范同步描述接口。用户已授权更新节点资产,O_AnyImage.blend 中的 O Image Layer 与运行时代码配套交付;在 Blender 节点编辑器确认实际尺寸、节点间距和连接,并运行全量测试及资产验证。
## Risks / Trade-offs
- 截图未展示所有精确坐标与手柄设置 → 用上述初值实现,检查曲线采样,并对照实际节点编辑器及透明边缘效果验收。
- 曲线会削弱中低 Alpha → 默认手动实例强度为 0,创建时按用户偏好启用,保留每个实例的强度控制。
- 工作区已有材质与节点资产修改 → 在当前实现上追加变更,保留已有修改并检查最终差异。
- 旧资产缺少输入 → 验证资产接口与运行时代码一致,正式发布配套资产。

View File

@@ -0,0 +1,27 @@
## Why
使用 inverse 类颜色空间时,用户观察到 Alpha 无法正确遮盖图片边缘,手动添加 Float Curve 后可改善。将这项调整放入 O Image Layer,让材质创建时自动应用,并允许用户调节强度。
## What Changes
- O Image Layer 内新增 Alpha Float Curve,依据参考图设置曲线。
- 默认折叠的 `Options` 面板依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`;Alpha Fix 连接 Float Curve 的 Factor,范围 0–1,默认 0。
- 创建使用 O Image Layer 的材质时,读取 `Adapt to Scene View Transform`:开启设为 1,关闭设为 0。
- 验证曲线、接口、材质创建和着色输出行为。
## Capabilities
### New Capabilities
- `image-layer-alpha-adjustment`: 材质层 Alpha 曲线调整、强度输入与创建时偏好联动。
### Modified Capabilities
无。
## Impact
- `tools/node_assets/material_layers.py`:节点组接口、曲线与布局。
- `src/anyimage/common/material.py`:创建节点实例时设置调整强度。
- `tools/node_assets/asset_validation.py` 及材质相关测试:接口、连接、曲线求值和偏好联动验证。
- 发布节点资产 `src/anyimage/assets/O_AnyImage.blend` 需要包含新接口;本轮仅生成提案。

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Image layer exposes alpha adjustment strength
O Image Layer SHALL 在默认折叠的 `Options` 面板中依次提供 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix SHALL 为浮点输入,范围 0–1,subtype 为 FACTOR,默认 0,并以该值控制内部 Alpha Float Curve 的 Factor。
#### Scenario: Options are initially collapsed
- **WHEN** 创建 O Image Layer 节点实例
- **THEN** Options 面板 SHALL 默认折叠,包含 Alpha Fix、Normal Scale、Object Space
- **AND** Color、Alpha、Normal、Bump Scale SHALL 位于面板外
#### Scenario: Adjustment is disabled
- **WHEN** Alpha Fix 为 0
- **THEN** 组输出 Alpha SHALL 等于输入 Alpha
#### Scenario: Adjustment is fully applied
- **WHEN** Alpha Fix 为 1
- **THEN** 组输出 Alpha SHALL 等于内部 Float Curve 对输入 Alpha 的求值
#### Scenario: Adjustment is partially applied
- **WHEN** Alpha Fix 为 0.5
- **THEN** 输出 SHALL 等于原始 Alpha 与完整曲线结果的平均值
### Requirement: Alpha curve follows the reference profile
Alpha 曲线 SHALL 使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、靠近右上方的平滑控制点和终点 `(1, 1)`,形成参考图中的快速上升轮廓。对输入范围 0–1,完整调整后的输出 SHALL 保持在 0–1,保持单调不减,并保持完全透明与完全不透明端值。
#### Scenario: Low alpha is suppressed
- **WHEN** 输入 Alpha 在 0–0.8 且调整强度为 1
- **THEN** 输出 Alpha SHALL 为 0,允许浮点求值误差
#### Scenario: Opaque alpha is preserved
- **WHEN** 输入 Alpha 为 1
- **THEN** 任意调整强度下输出 Alpha SHALL 为 1
### Requirement: Material creation initializes adjustment from preferences
创建使用 O Image Layer 的材质时,系统 SHALL 通过现有偏好读取入口,将 Alpha Fix 初始化为 `Adapt to Scene View Transform` 开启时的 1、关闭时的 0。
#### Scenario: Adaptation is enabled
- **WHEN** 开启适配后创建普通着色或 Shadeless 材质
- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 1
- **AND** 对应着色器 SHALL 使用该组调整后的 Alpha 输出
#### Scenario: Adaptation is disabled
- **WHEN** 关闭适配后创建材质
- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 0
#### Scenario: Color space falls back
- **WHEN** 适配开启且颜色空间解析回退到 sRGB
- **THEN** 新建 O Image Layer 实例的 Alpha Fix SHALL 仍为 1
#### Scenario: Existing material is manually adjusted
- **WHEN** 用户修改现有材质的 Alpha Fix 后切换偏好
- **THEN** 已有实例的调整值 SHALL 保留
- **AND** 后续新建材质 SHALL 使用当前偏好初始化

View File

@@ -0,0 +1,18 @@
## 1. Alpha 曲线与接口
- [x] 1.1 在 `material_layers.py` 的 O Image Layer 分支加入 Alpha Fix 输入及 Float Curve,按设计设置坐标、手柄、裁剪和连接。
- [x] 1.2 调整节点布局及未使用输出隐藏,在实际 Blender 节点编辑器检查总览与曲线局部。
- [x] 1.3 更新资产验证及对应测试,检查 socket 顺序、默认值、连接和曲线采样;覆盖强度 0、0.5、1、低 Alpha、端点、单调性与范围。
## 2. 材质创建联动
- [x] 2.1 在 `create_image_material()` 中复用偏好读取入口,为 O Image Layer 实例设置 Alpha Fix。
- [x] 2.2 补充材质行为测试,覆盖偏好开关、sRGB 回退、普通着色、Shadeless 及现有材质手动值保留,验证 O Image Depth Layer 的既有 Alpha 行为。
## 3. 验收
- [x] 3.1 对照参考图验证曲线形状,使用透明边缘图片比较强度 0、0.5、1 的效果,记录估计控制点的最终取值。
- [x] 3.2 按节点组专项规范同步 `docs/internals/node-assets.md` 的新输入与曲线说明。
- [x] 3.3 按用户授权更新 O Image Layer 节点资产,运行全量测试并确认正常退出,检查最终差异和接口引用;验证 Blender 节点资产,不构建文档或扩展发布包。
验证结果:766 passed、2 xfailed、50 subtests passed,退出码 0;Blender 4.5.10 资产验证与 OpenSpec 严格校验通过。

View File

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

View File

@@ -0,0 +1,57 @@
## Context
动机见 proposal.md。当前共享 Artifact 入口合成黑底;GeometryFrame 保存原始模型 mask;EXR 写入 XYZ 与 mask。补边按 alpha 分量组织,但用包含 mask 的来源集合计算内部距离,会把内部 mask 孔洞也纳入修补。Depth Plane 已支持读取 EXR Alpha,默认 Validity Threshold 为 0.9。
## Goals / Non-Goals
本设计将输入颜色、轮廓深度修补和输出有效性分别放在模型输入准备、深度处理、EXR 写入三个边界。实施范围为共享深度产物及消费者测试,节点结构和参数沿用当前实现。
## Decisions
### RGB 输入与 alpha 并行处理
```mermaid
flowchart TD
A[原始输入图] --> B[读取 RGB 后按输入上限缩放]
B --> C[MoGe2 预测]
C --> D[GeometryFrame:depth、points、validity 等]
A --> E[读取原图 alpha 并对齐预测尺寸]
D --> F[按 alpha 轮廓延伸深度]
E --> F
F --> G[EXR RGB:相机空间 XYZ]
D --> H[原图 alpha × validity]
E --> H
H --> I[EXR Alpha]
I --> J[Depth Plane 现有剔除节点]
```
`_limited_model_input` 先 `convert("RGB")` 再 `limit_image`,使缩放直接处理 RGB。原图 alpha 继续从业务输入图单独读取,使用现有 NEAREST 对齐预测尺寸。这里的原图是本次 Job 输入,包含 Remove BG 等已经完成的 alpha 编辑。
### alpha 决定修补范围,mask 筛选来源
在 `edge_depth.py` 中分别计算 alpha 轮廓区域与深度来源:可见分量沿用调用方 alpha_threshold;内部距离由 alpha 条件确定。保留现有内部 2 像素保护带、轮廓外 4 像素采样延伸及局部平滑。
来源同时满足 alpha 条件、原始 validity > 0.5、有限正深度,优先使用同一分量内部来源;细小分量沿用分量内来源回退。低 mask 仅影响来源资格,不扩大修补目标或侵蚀轮廓内部区域。alpha 全 1 时保持现有直接返回行为。
修改深度后按目标像素射线重建 XYZ,保持未修补区域原值。修补结果的 validity 仍为模型 mask。0.5 是来源筛选条件,Depth Plane 的 0.9 是最终几何筛选阈值,两者各自保持现有用途。
### 在 EXR 写入边界组合 alpha
`write_depth_texture` 显式接收已对齐的原图 alpha,检查尺寸并以 float32 写入 `alpha * frame.validity`。统一迁移调用点,参数必需。GeometryFrame 始终保留原始模型信号,供延伸、Normal 和 Metadata 使用。
XYZ 与有效性通道各自计算:补边只改变 XYZ,alpha 乘积只用于 EXR A。Metadata 继续使用原始模型有效性、可见区域和有限正深度计算参考深度。
### 消费者沿用职责
Depth Plane 的 Valid Only 读取组合后的 EXR Alpha,继续使用现有阈值、线性采样和面域规则。Cutout 曲面由自身轮廓生成并采样 XYZ。检查共享 EXR Alpha 的校准读取,保证 Cutout 和 Relief Plane 的有效样本筛选符合组合通道语义。
## Risks / Trade-offs
- 输入图的隐藏 RGB 可能已被清除 → 使用实际可用 RGB;用保留天空 RGB 的 Remove BG 输出验证输入一致性。
- 两个连续值相乘会降低边缘有效性,例如 0.95 × 0.95 = 0.9025 → 测试直接固定乘积与当前阈值的消费行为。
- alpha 延伸只能稳定轮廓附近的深度 → 用合成深度场验证边界与来源隔离;有原始风景图时再做模型对照,截图不作为预测正确性的证明。
- 工作区有其他进行中修改 → 以当前实现为基础,独立提交本提案文件,实施时保留已有修改。
## Migration Plan
依次修改 RGB 准备、alpha 延伸范围、EXR 写入及所有调用,运行针对性与全量测试并确认正常退出。重新执行 Job 获得新产物。仅回退本变更引入的修改即可恢复旧行为。

View File

@@ -0,0 +1,25 @@
## Why
透明图合成黑底后交给 MoGe2,会改变天空等背景及轮廓附近的 RGB,影响场景深度预测。Depth Plane 需要同时按原图覆盖范围和模型有效性剔除,Cutout 则需要稳定的 alpha 轮廓深度。
## What Changes
- MoGe2 输入先读取 RGB 再缩放,保留透明像素下的颜色。
- 深度延伸以原图 alpha 轮廓确定范围,模型 mask 用于筛选可靠来源。
- `GeometryFrame.validity` 保持模型 mask;导出 `depth.exr` 时 Alpha 为原图 alpha 与 validity 的乘积,RGB 保存延伸后的相机空间 XYZ。
- Depth Plane 通过现有 Valid Only 与 Validity Threshold 消费 EXR Alpha;Cutout 继续按自身轮廓生成几何。
## Capabilities
### New Capabilities
- `depth-alpha-semantics`: 定义 MoGe2 RGB 输入、alpha 轮廓延伸以及深度产物有效性通道。
### Modified Capabilities
## Impact
- 涉及 `server/geometry/prediction_artifacts.py`、`edge_depth.py`、`depth_texture.py` 及对应调用与测试。
- 检查 Depth Plane、Cutout、Relief Plane 对共享深度产物的消费,包括校准用途。
- 接续工作区已有 Depth Plane 与 Relief Plane 拆分实现,采用当前节点阈值和剔除规则。
- 深度产物需重新生成以获得新 Alpha 语义。实现只运行测试,不同步内部文档或构建节点资产、扩展包;无新增依赖。

View File

@@ -0,0 +1,65 @@
## Purpose
规定透明图进入 MoGe2 时的颜色语义、轮廓深度延伸范围和深度产物的有效性通道,使 Depth Plane 根据图像覆盖与模型判断剔除几何,同时为 Cutout 提供稳定的轮廓深度采样。
## ADDED Requirements
### Requirement: Model input preserves source RGB
系统 SHALL 使用输入图 RGB 准备 MoGe2 输入,缩放 SHALL 基于 RGB 执行,alpha MUST 保持为独立信号。
#### Scenario: Same RGB with different alpha
- **WHEN** 两张尺寸、RGB 相同的图仅 alpha 不同,并使用相同输入尺寸上限
- **THEN** 它们生成的模型 RGB 输入相同,包括触发缩放的情况
#### Scenario: Transparent sky retains color context
- **WHEN** Remove BG 输出保留天空 RGB 并将其 alpha 设为零
- **THEN** MoGe2 输入保留天空 RGB,输出深度产物对应像素的 Alpha 为零
### Requirement: Alpha silhouette determines depth extension
系统 SHALL 根据原图 alpha 确定可见分量和轮廓修补范围,使用同一分量的可靠深度延伸轮廓。模型有效性 MUST 用于排除不可靠来源,修补范围 SHALL 独立于模型 mask 孔洞。延伸 SHALL 保持原始 alpha、模型有效性与未修补区域的深度。
#### Scenario: Repair the alpha edge
- **WHEN** alpha 轮廓内侧保护带及轮廓外采样带存在不稳定深度,且分量内部存在可靠来源
- **THEN** 系统使用该分量可靠深度修补边界,并保持目标像素的相机投影射线
#### Scenario: Internal mask hole
- **WHEN** 一个低 mask 区域完全位于 alpha 轮廓保护带以外的内部
- **THEN** 该区域不会仅因 mask 偏低而成为延伸目标,其模型有效性保持原值
#### Scenario: Fully opaque input
- **WHEN** 输入 alpha 全为 1
- **THEN** 系统保留原始深度场,包括低 mask 区域
#### Scenario: Component lacks reliable depth
- **WHEN** 某个 alpha 分量没有可靠深度来源
- **THEN** 该分量保留原始深度,不从其他分量借用深度
### Requirement: Depth artifact alpha combines coverage and model validity
`depth.exr` SHALL 在 RGB 保存相机空间 XYZ,在 Alpha 保存对齐预测尺寸的原图 alpha 与原始模型有效性的逐像素乘积。两个信号 MUST 保留连续值,补边 MUST 保持乘积所用的原始信号。
#### Scenario: Continuous values
- **WHEN** 某像素原图 alpha 为 0.8,模型有效性为 0.75
- **THEN** 导出的 Alpha 为 0.6,XYZ 保留该像素的深度处理结果
#### Scenario: Invisible or model-invalid pixel
- **WHEN** 原图 alpha 或模型有效性任一为零
- **THEN** EXR Alpha 为零,XYZ 仍保存可供采样的数值场
### Requirement: Geometry consumers use depth channels by purpose
Depth Plane SHALL 通过现有 Valid Only 和 Validity Threshold 按 EXR Alpha 剔除。Cutout SHALL 根据自身轮廓生成曲面并读取 XYZ,EXR Alpha 不得引入额外曲面剔除。
#### Scenario: Depth plane culling
- **WHEN** Valid Only 开启且原图 alpha 或模型有效性使乘积低于当前阈值
- **THEN** 该采样位置按现有节点的面域规则参与剔除
#### Scenario: Culling disabled
- **WHEN** 用户关闭 Valid Only
- **THEN** 完整细分网格继续进入深度投影与厚度处理
#### Scenario: Cutout keeps its contour
- **WHEN** Cutout 轮廓内含低 EXR Alpha 区域
- **THEN** 曲面拓扑保持由 Cutout 轮廓决定,深度采样使用导出的 XYZ

View File

@@ -0,0 +1,21 @@
## 1. 模型输入
- [x] 1.1 将共享模型输入准备改为读取 RGB 后缩放,更新输入测试,验证相同 RGB、不同 alpha 在缩放和原尺寸情况下得到相同模型输入。
## 2. alpha 轮廓延伸
- [x] 2.1 将 alpha 轮廓与 mask 来源筛选分开,固定仅由 alpha 决定的修补范围;通过内部低 mask 孔洞及全不透明输入测试确认深度保持原值。
- [x] 2.2 验证轮廓内侧保护带、外侧 4 像素延伸、局部平滑、分量隔离、无可靠来源回退和 XYZ 射线一致性,并确认 alpha 与 validity 未改写。
## 3. 深度产物与消费者
- [x] 3.1 为深度 EXR 写入添加必需的原图 alpha 参数并迁移全部调用;验证尺寸检查、零值、连续乘积和 XYZ 原值保存,确认 GeometryFrame.validity 仍为模型 mask。
- [x] 3.2 更新 Artifact 集成测试,验证 alpha 对齐预测尺寸、Metadata 使用原始模型有效性与可见参考区域、Normal 使用原始预测。
- [x] 3.3 按用户要求保留全部几何节点相关内容;验证 EXR 图像读取保留组合 Alpha 与 XYZ,检查现有校准读取,运行消费者回归测试,节点调整由另一 agent 负责。
## 4. 整体验证
- [x] 4.1 运行针对性测试后执行 `uv run pytest`,确认正常退出;检查最终差异与旧调用引用,保留工作区其他修改,仅报告本变更结果,不同步内部文档或构建资产及扩展包。
验证记录:针对性测试 48 passed、2 subtests passed;全量测试 805 passed、2 xfailed,模型同步模块并行迁移导致 6 项初始化错误。迁移后单独重跑 tests/test_sync_models.py,6 passed,退出码 0。最终差异及写入调用检查通过;本变更未修改几何节点脚本、资产或节点测试。

View File

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

View File

@@ -0,0 +1,68 @@
## Context
两个转换入口分别使用相机投影和浮雕节点组。Server 生成相机 XYZ 深度与对应空间的法线,客户端创建矩形基础网格并加载节点组。depth.exr 的 Alpha 存储模型有效性,作为 Valid Only 的判定依据。
工作区已有材质和转换代码修改,实施须以当前代码为基础保留这些行为。
## Goals
提供独立的 Depth Plane 与 Relief Plane 转换;Depth Plane 在节点内按深度有效性 Alpha 剔除几何,保留动态细分,并使用 Object Space 法线。
## Decisions
### 入口与节点资产
| 入口 | Operator | 节点组 | 形状 | 法线空间 |
| --- | --- | --- | --- | --- |
| Convert to Depth Plane | `anyimage.convert_to_depth_plane` | `O Image Depth Plane` | 相机投影曲面 | OBJECT |
| Convert to Relief Plane | `anyimage.convert_to_relief_plane` | `O Image Relief Plane` | 固定底面浮雕 | TANGENT |
两个节点组各自保留 Subdivide、Thickness、Depth Scale、Reference Depth 及所需数据输入。Depth Plane 额外保留 Normal Smooth,增加默认开启的 Valid Only。Relief Plane 延续原 Relief 的厚度与位移语义。移除 Mode 与 Camera Thickness 命名,各组使用自己的 Thickness。
两个 Operator 共用图片准备、Job 调度和产物加载函数,通过可序列化业务参数区分转换类型。后端沿用整图深度 Job,并根据转换类型生成对应的单份法线产物。新类型纳入注册和逆序注销。
### 节点内 Alpha 剔除
```mermaid
flowchart TD
A[矩形基础网格与 UV] --> B[Subdivide 生成网格]
B --> C{Valid Only}
D[depth.exr 有效性 Alpha] --> E[Alpha 小于 1 后在 Point 域求值]
E --> F[剔除透明区域几何]
C -- 开启 --> F
C -- 关闭 --> G[相机 XYZ 投影]
F --> G
G --> H[曲面厚度]
H --> I[清理临时属性与输出]
```
剔除读取depth.exr 的有效性 Alpha,并使用原图 UV。直接使用 Data 中已有的 Depth Image 输入。用户只控制 Valid Only 和已有 Subdivide;内部使用 Linear / Extend 深度采样,Separate Color 的 Alpha 接 Float / Less Than(B = 1),再经 Boolean / Point 的 Evaluate on Domain 接 Face / All 的 Delete Geometry。仅全部顶点有效性均小于 1 的面删除,混合边界面保留。
剔除在几何节点中每次求值,避免将轮廓烘焙为固定网格。Valid Only 关闭时,投影与厚度接收完整细分网格。节点处理保持 UV、原图构图和世界位置。
### 最终几何平滑
Depth Plane 在投影、厚度及临时属性清理后直接调用 `node_graph.expand_smooth()`,复用 Depth Cutout 的 Smooth 模块。主接口 Smooth 默认 0、范围 0–20;Options 中 Smooth Weight 默认 1、范围 0–1,subtype 为 FACTOR。其后执行平滑着色与输出。
### 法线产物与材质
Depth Plane 请求并读取 `object_normal`,通过 `create_image_material(..., normal_space="OBJECT")` 创建材质;Relief Plane 请求并读取 `tangent_normal`,使用 TANGENT。复用现有法线编码和材质入口,确认相机轴到对象局部轴的转换与曲面投影一致。
深度继续使用 `depth`、`depth_metadata` 协议,RGB 保存相机 XYZ,Alpha 保存有效性。
## Risks / Trade-offs
- Alpha 边界受网格密度影响 → 用有效性为 0、介于 0 和 1、混合边界和细窄结构验证参考图的宽松规则,并检查调整 Subdivide 后的实际结果。
- Object Normal 与局部投影坐标不一致会造成明暗反向 → 验证法线轴向、材质空间设置及对象旋转后的表现。
- 裁剪后的开口边界影响曲面厚度 → 在零厚度和正厚度下验证边界、UV 与几何求值。
- 节点资产接口发生破坏性变化 → 构建脚本、验证及测试按两个独立资产同步更新,删除合并模式的旧连接与引用。
## Migration Plan
实现顺序为入口与请求分流、法线产物绑定、节点组拆分、Alpha 剔除、验证与测试。沿用现有 Depth Plane 标识作为 Camera 入口,新增 Relief 标识,不增加旧模式兼容层。节点接口变化同步节点资产文档。
代码阶段运行测试;资产构建与 Blender 节点编辑器视觉验收作为后续资产更新阶段单独执行,交付时分别说明完成状态。
## Reference Graph
用户参考图已确认:Separate Color / Alpha → Less Than 1 → Evaluate on Domain(Boolean、Point)→ Delete Geometry(Face、All)。Valid Only 通过几何 Switch 控制此段,关闭时使用完整细分网格。节点资产构建和 Blender 检查已获用户授权。

View File

@@ -0,0 +1,28 @@
## Why
整图相机投影与固定底面浮雕需要独立的转换入口和几何节点。相机投影生成背景模型时,透明区域需要参与几何剔除,同时保留修改器中调整网格密度的能力。
## What Changes
- **BREAKING**:拆分现有 Relief / Camera 模式,`Convert to Depth Plane` 专门生成相机投影曲面,新增 `Convert to Relief Plane` 生成固定底面浮雕。
- 两个入口分别加载 `O Image Depth Plane` 和 `O Image Relief Plane`,各自提供所需参数,移除 Mode 选择与跨模式参数。
- Depth Plane 在基础网格细分后、深度投影与厚度处理前,根据depth.exr 有效性 Alpha 宽松剔除几何。修改器提供默认开启的 `Valid Only` 开关,保留 `Subdivide` 动态调节。
- 宽松剔除使用深度有效性 Alpha < 1,经 Boolean / Point 的 Evaluate on Domain 后连接 Face / All 的 Delete Geometry。
- Depth Plane 生成并使用 Object Space Normal Map;Relief Plane 延续 Tangent Space Normal Map。
- Depth Plane 复用 Depth Cutout 的 Smooth 模块,在最终曲面或厚度几何上执行平滑,提供 Smooth 和 Smooth Weight。
## Capabilities
### New Capabilities
- `separate-depth-relief-planes`:独立转换入口、节点资产、参数及对应法线空间。
- `depth-plane-alpha-culling`:可开关、随细分重新求值的 Alpha 几何剔除。
### Modified Capabilities
无;当前 `openspec/specs/` 无已归档规范。本提案接续 `unify-depth-plane-and-artifact` 的现有实现。
## Impact
涉及转换 Operator、菜单与注册、Server Job 参数与法线产物、对象和材质创建、节点资产构建脚本、布局、验证及测试。节点资产文档随接口更新。复用现有深度协议与 Object Normal 生成能力,无需新增依赖。实施时保留工作区已有修改;执行测试,节点资产构建与 Blender 视觉验收单独安排。

View File

@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: Optional alpha culling defaults to enabled
Depth Plane 修改器 SHALL 提供默认开启的 Valid Only 开关,按 depth.exr 有效性 Alpha 剔除几何。该处理 MUST 在基础网格细分之后、深度投影和厚度之前执行。
#### Scenario: Default conversion
- **WHEN** 用户转换包含无效深度区域的图片为 Depth Plane
- **THEN** Valid Only 默认开启,深度有效性 Alpha 参与基础网格的几何剔除
#### Scenario: Disable alpha culling
- **WHEN** 用户关闭 Valid Only
- **THEN** 完整细分网格进入相机投影与厚度流程
#### Scenario: Fully valid depth
- **WHEN** depth.exr 有效性 Alpha 全部为 1
- **THEN** Alpha 剔除保留完整基础网格
### Requirement: Culling remains responsive to subdivision
系统 SHALL 在节点内重新求值 Alpha 剔除,允许用户继续通过 Subdivide 调节网格。剔除后的几何 MUST 保留正确 UV,并进入现有相机投影和曲面厚度计算。
#### Scenario: Change subdivision
- **WHEN** 用户修改 Subdivide
- **THEN** 节点重新生成网格并依据该网格重新检测 Alpha,无需重新运行 AI
#### Scenario: Apply thickness after culling
- **WHEN** 用户对已剔除的曲面设置正 Thickness
- **THEN** 厚度从保留的曲面生成,无效区域已删除的基础面不会重新出现
### Requirement: Permissive culling follows the reference graph
Alpha 剔除 MUST 使用用户参考图中的宽松判定连接,且只公开 Valid Only 开关,网格精度使用已有 Subdivide。
深度图片 SHALL 使用 Linear / Extend 采样;Separate Color 的 Alpha SHALL 接 Float / Less Than(B = 1),结果通过 Boolean / Point 的 Evaluate on Domain 后接 Delete Geometry(Face、All)。一个面仅在所有顶点都满足 Alpha < 1 时删除;至少一个顶点 Alpha 为 1 的边界面保留。
#### Scenario: Verify the reference rule
- **WHEN** 实现 Alpha 判定
- **THEN** 节点连接、运算、默认值和域设置与参考图一致,有效与无效区域交界网格的保留结果符合该图规则

View File

@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Independent conversion entries and assets
系统 SHALL 提供 Convert to Depth Plane 和 Convert to Relief Plane 两个入口,分别加载 `O Image Depth Plane` 与 `O Image Relief Plane`。Depth Plane MUST 使用相机投影,Relief Plane MUST 使用固定底面浮雕。两组 SHALL 提供各自参数并移除 Mode 选择。
#### Scenario: Convert an image to a depth plane
- **WHEN** 用户对静态 Image Empty 执行 Convert to Depth Plane
- **THEN** 系统创建相机投影曲面,绑定独立 Depth Plane 节点组,并保留原图构图、UV 与世界位置
#### Scenario: Convert an image to a relief plane
- **WHEN** 用户对静态 Image Empty 执行 Convert to Relief Plane
- **THEN** 系统创建固定底面浮雕,绑定独立 Relief Plane 节点组,Thickness 与 Depth Scale 延续原 Relief 语义
#### Scenario: Inspect modifier controls
- **WHEN** 用户查看两种结果的修改器
- **THEN** 两者各自提供 Subdivide、Thickness、Depth Scale、Reference Depth,Depth Plane 提供 Normal Smooth,两者均无 Mode 选择
### Requirement: Normal space follows conversion type
系统 SHALL 为 Depth Plane 生成并消费 `object_normal`,材质使用 OBJECT 空间;为 Relief Plane 生成并消费 `tangent_normal`,材质使用 TANGENT 空间。法线编码 MUST 与对应对象局部坐标一致。
#### Scenario: Depth material uses object normals
- **WHEN** Depth Plane Job 完成并创建材质
- **THEN** 材质加载 Object Normal 产物并启用 Object Space,法线轴向与相机投影后的局部几何一致
#### Scenario: Relief material uses tangent normals
- **WHEN** Relief Plane Job 完成并创建材质
- **THEN** 材质加载 Tangent Normal 产物并使用 Tangent Space
### Requirement: Shared conversion lifecycle
两个转换 SHALL 使用静态图与 AI 就绪检查、统一深度产物协议、对象替换及失败清理流程。新增 Operator MUST 纳入注册与逆序注销。
#### Scenario: Failed result creation
- **WHEN** 任一转换在加载产物或创建对象时失败
- **THEN** 系统保留可用源对象,并清理本次创建且未被使用的数据块
#### Scenario: Extension lifecycle
- **WHEN** 扩展注册后逆序注销
- **THEN** 两个转换入口均正确注册与移除
### Requirement: Depth plane shares Cutout smoothing
Depth Plane SHALL 在投影与厚度之后复用 Depth Cutout 的 Smooth 模块。Smooth MUST 默认 0、范围 0–20;Options 中的 Smooth Weight MUST 默认 1、范围 0–1,使用 FACTOR 子类型。
#### Scenario: Smooth final geometry
- **WHEN** 用户增加 Smooth 并使用正 Smooth Weight
- **THEN** 最终顶点位置按 Cutout 的共用规则平滑,面连接与 UV 保持一致
#### Scenario: Disable smoothing
- **WHEN** Smooth 为 0 或 Smooth Weight 为 0
- **THEN** 几何位置与未平滑结果相同

View File

@@ -0,0 +1,39 @@
## 1. 补齐 Alpha 规则
- [x] 1.1 获取用户节点连接参考图,记录 Alpha 采样、阈值、字段域、删除模式和宽松边界结果,补齐 design 与 Alpha 规格;本项完成后实施第 4 组任务。
## 2. 转换入口与法线
- [x] 2.1 新增 ConvertToReliefPlane、菜单项与注册清单,ConvertToDepthPlane 改为相机投影语义,复用整图任务调度函数。
- [x] 2.2 为整图 Job 增加可序列化转换类型参数,Depth 生成 object_normal,Relief 生成 tangent_normal,验证各自产物键与深度协议。
- [x] 2.3 按转换类型加载节点组和法线,绑定 OBJECT / TANGENT 材质空间,保持对象替换、Undo 与失败清理行为。
## 3. 独立节点资产
- [x] 3.1 将相机投影与浮雕分别整理为 O Image Depth Plane 和 O Image Relief Plane 构建脚本,删除 Mode 与跨模式接口、旧连接及无用引用。
- [x] 3.2 整理两组布局与验证脚本,确认接口顺序、参数默认值、未使用输出隐藏,以及独立 Thickness 和 Normal Smooth 的归属。
## 4. 节点内 Alpha 剔除
- [x] 4.1 为 Depth Plane 添加默认开启的 Valid Only ,复用现有 Depth Image 输入读取 depth.exr 的有效性 Alpha。
- [x] 4.2 按已确认参考图在细分后、投影前连接宽松剔除,关闭开关时旁路完整网格,保留 UV 并衔接厚度计算。
- [x] 4.3 验证全无效、全有效、中间有效性、混合边界与细窄区域,检查 Subdivide 改变后重新求值及零厚度、正厚度结果。
## 5. 测试与接口说明
- [x] 5.1 更新入口、注册注销、Job 产物、对象创建与材质测试,覆盖 Object Normal 轴向和 Relief 行为。
- [x] 5.2 更新节点结构与几何求值测试,按参考图检查运算、默认值、域及连接,覆盖开关和动态细分。
- [x] 5.3 同步节点资产文档中的两个资产接口与处理流程,检查当前源码和资产脚本中的旧模式引用。
- [ ] 5.4 完成最终全量测试并确认正常退出。当前全量运行在 tests/test_node_arrangement.py 调用 node_arrangement._route_branch_exits 时发生 Blender access violation,独立复核仍复现。本次节点资产与转换相关 69 项测试通过,退出码 0。
## 6. 后续节点资产更新阶段
- [x] 6.1 已按用户授权构建 src/anyimage/assets/O_AnyImage.blend,Blender 4.5.10 的资产清单、接口、布局与几何验证通过。
- [ ] 6.2 在 Blender 中按实际节点尺寸检查总览与局部布局,并以风景透明图检查剔除轮廓、厚度、细分调节和对象旋转后的法线表现。当前界面检查受阻:Computer Use 返回“Computer Use was not approved to use Blender”;待获得界面控制授权后完成。
## 7. 复用 Cutout Smooth
- [x] 7.1 在 Depth Plane 最终几何上复用 expand_smooth,沿用 Smooth 与 Smooth Weight 的默认值、范围及接口归属。
- [x] 7.2 覆盖零厚度和正厚度的平滑、关闭平滑、面连接与 UV 保持,并同步节点资产说明。
- [ ] 7.3 节点资产已重建,Blender 资产验证和 69 项相关测试通过;全量测试受自动布局工具 access violation 阻塞,待该工具修复后复验。

View File

@@ -27,8 +27,8 @@ dev = [
[project.scripts]
pack = "tools.pack:main"
build_node = "tools.build_node:main"
sync_models = "tools.sync_models:main"
build_node = "tools.nodes.cli:main"
sync_models = "tools.models.cli:main"
docs = "tools.docs:main"
[build-system]

Binary file not shown.

View File

@@ -91,9 +91,9 @@ def material_node_group(depth_plane=False):
def create_image_material(
source_object,
source_image,
color_image,
depth_scale,
depth_scale=0.0,
displacement_image=None,
displacement_midlevel=0.0,
displacement_scale=1.0,
@@ -107,11 +107,10 @@ def create_image_material(
use_cyclic=None,
normal_image=None,
normal_space="TANGENT",
ior=None,
scene=None,
):
material_image = configure_material_color_image(color_image, scene)
material = bpy.data.materials.new(image_base_name(source_object.data))
material = bpy.data.materials.new(image_base_name(source_image))
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
@@ -155,6 +154,10 @@ def create_image_material(
material_inputs.node_tree = material_node_group(is_depth_plane)
material_inputs.location = (-300.0, 350.0)
material_inputs.inputs["Bump Scale"].default_value = depth_scale
if not is_depth_plane:
material_inputs.inputs["Alpha Fix"].default_value = float(
configured_material_view_adaptation()
)
if "Object Space" in material_inputs.inputs:
if normal_space not in {"TANGENT", "OBJECT"}:
raise ValueError(f"Unsupported normal space: {normal_space}")
@@ -201,8 +204,7 @@ def create_image_material(
else:
shader = nodes.new("ShaderNodeBsdfPrincipled")
shader.location = (250.0, 400.0)
if ior is not None:
shader.inputs["IOR"].default_value = float(ior)
shader.inputs["IOR"].default_value = 1.2
links.new(
material_inputs.outputs["Color"],

View File

@@ -23,6 +23,11 @@ class AnyImageImageMenu(bpy.types.Menu):
text="Convert to Depth Plane",
icon="MOD_DISPLACE",
)
layout.operator(
"anyimage.convert_to_relief_plane",
text="Convert to Relief Plane",
icon="MOD_DISPLACE",
)
layout.separator()
layout.operator(
"anyimage.remove_image_background",

View File

@@ -8,6 +8,7 @@ from .mask_tool import EditImageAlpha
from .rectify_tool import RectifyImagePerspective
from .convert_to_plane import (
ConvertToDepthPlane,
ConvertToReliefPlane,
ConvertToPlane,
GenerateDepthPlane,
)
@@ -38,4 +39,5 @@ CLASSES = (
UpscaleImage,
ConvertToPlane,
ConvertToDepthPlane,
ConvertToReliefPlane,
)

View File

@@ -4,7 +4,7 @@ import tempfile
import bpy
from ...common.material import configure_material_color_image
from ...common.material import create_image_material
CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256"
CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture"
@@ -150,7 +150,9 @@ def add_image_plane(
from ..convert_to_plane.image_plane import create_image_plane_object
half_width, half_height = _plane_half_dimensions(width, height)
material = _create_image_material(image, shadeless, scene=context.scene)
material = create_image_material(
image, image, shadeless=shadeless, scene=context.scene
)
plane = create_image_plane_object(
context,
image.name,
@@ -198,52 +200,6 @@ def _plane_half_dimensions(width, height):
return width / height, 1.0
def _create_image_material(image, shadeless=False, *, scene=None):
material_image = configure_material_color_image(image, scene)
material = bpy.data.materials.new(name=f"{image.name} Material")
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
image_node = nodes.new("ShaderNodeTexImage")
image_node.image = material_image
image_node.location = (-620.0, 80.0)
output_node = nodes.new("ShaderNodeOutputMaterial")
output_node.location = (220.0, 80.0)
links = material.node_tree.links
if shadeless:
_create_shadeless_nodes(nodes, links, image_node, output_node)
return material
shader_node = nodes.new("ShaderNodeBsdfPrincipled")
shader_node.location = (-160.0, 80.0)
links.new(image_node.outputs["Color"], shader_node.inputs["Base Color"])
links.new(image_node.outputs["Alpha"], shader_node.inputs["Alpha"])
links.new(shader_node.outputs["BSDF"], output_node.inputs["Surface"])
return material
def _create_shadeless_nodes(nodes, links, image_node, output_node):
transparent_node = nodes.new("ShaderNodeBsdfTransparent")
transparent_node.location = (-360.0, -100.0)
emission_node = nodes.new("ShaderNodeEmission")
emission_node.location = (-360.0, 120.0)
mix_node = nodes.new("ShaderNodeMixShader")
mix_node.location = (-80.0, 80.0)
links.new(image_node.outputs["Color"], emission_node.inputs["Color"])
links.new(image_node.outputs["Alpha"], mix_node.inputs[0])
links.new(transparent_node.outputs["BSDF"], mix_node.inputs[1])
links.new(emission_node.outputs["Emission"], mix_node.inputs[2])
links.new(mix_node.outputs["Shader"], output_node.inputs["Surface"])
def _select_only(context, plane):
for selected_object in getattr(context, "selected_objects", ()):
selected_object.select_set(False)

View File

@@ -2,12 +2,14 @@
from .operators import (
ConvertToDepthPlane,
ConvertToReliefPlane,
ConvertToPlane,
GenerateDepthPlane,
)
__all__ = (
"ConvertToDepthPlane",
"ConvertToReliefPlane",
"ConvertToPlane",
"GenerateDepthPlane",
)

View File

@@ -67,8 +67,12 @@ def create_depth_plane_object(
material,
depth_image,
depth_metadata,
plane_type,
):
node_group = load_node_group("O Image Depth Plane")
node_group = load_node_group({
"DEPTH": "O Image Depth Plane",
"RELIEF": "O Image Relief Plane",
}[plane_type])
source_name = source_object.name
bounds = image_empty_bounds(source_object)
mesh = create_image_plane_mesh(source_name, bounds)
@@ -80,7 +84,7 @@ def create_depth_plane_object(
plane = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane)
plane.matrix_world = build_image_plane_matrix(source_object.matrix_world, bounds)
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
plane["anyimage_mesh_shape"] = f"{plane_type}_PLANE"
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
@@ -108,6 +112,7 @@ def create_depth_plane_object(
def create_depth_plane_from_result(context, result, operator):
normal_space = {"DEPTH": "OBJECT", "RELIEF": "TANGENT"}[operator.plane_type]
source_object = require_image_empty(operator.source_object_name)
color_image = load_color_result_image(source_object, result.directory)
color_image.name = color_data_name(source_object)
@@ -122,16 +127,14 @@ def create_depth_plane_from_result(context, result, operator):
depth_metadata,
)
normal_image = load_normal_result_image(
result.file("tangent_normal"),
result.file(f"{normal_space.lower()}_normal"),
source_object,
)
material = create_image_material(
source_object,
source_object.data,
color_image,
0.0,
normal_image=normal_image,
normal_space="TANGENT",
ior=1.2,
normal_space=normal_space,
scene=context.scene,
)
create_depth_plane_object(
@@ -141,6 +144,7 @@ def create_depth_plane_from_result(context, result, operator):
material,
depth_image,
depth_metadata,
operator.plane_type,
)
except Exception:
if material is not None and material.users == 0:
@@ -149,6 +153,6 @@ def create_depth_plane_from_result(context, result, operator):
if image is not None and image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise
label = "Convert to Depth Plane"
label = f"Convert to {operator.plane_type.title()} Plane"
if "FINISHED" not in bpy.ops.ed.undo_push(message=label):
raise RuntimeError(f"Unable to create the {label} Undo step")

View File

@@ -50,6 +50,11 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
input_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
plane_type: bpy.props.EnumProperty(
items=(("DEPTH", "Depth Plane", ""), ("RELIEF", "Relief Plane", "")),
default="DEPTH",
options=_HIDDEN,
)
mesh_detail: bpy.props.IntProperty(default=6, min=0, max=10, options=_HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=_HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN)
@@ -71,6 +76,7 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"max_input_size": int(self.max_input_size),
"plane_type": self.plane_type,
}
parameters.update(
moge2_parameters(
@@ -85,13 +91,13 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
def response(self, context, result):
create_depth_plane_from_result(context, result, self)
return "Depth Plane is ready"
return f"{self.plane_type.title()} Plane is ready"
def cleanup(self):
cleanup_image_input(self.input_path, self.delete_input)
def start_depth_plane_job(operator, context):
def start_depth_plane_job(operator, context, plane_type):
source_object = context.object
if video_total_frames(source_object.data) != 1:
operator.report({"ERROR"}, "Depth conversion currently supports still images")
@@ -112,6 +118,7 @@ def start_depth_plane_job(operator, context):
input_path=str(source_path),
source_object_name=source_object.name,
mesh_detail=operator.mesh_detail,
plane_type=plane_type,
model=model,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
@@ -141,9 +148,8 @@ class ConvertToPlane(bpy.types.Operator):
source_object
)
material = create_image_material(
source_object,
source_object.data,
color_image,
1.0,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
@@ -157,7 +163,7 @@ class ConvertToPlane(bpy.types.Operator):
class ConvertToDepthPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_depth_plane"
bl_label = "Convert to Depth Plane"
bl_description = "Convert the complete Image Empty into a Relief or Camera depth plane"
bl_description = "Convert the complete Image Empty into a camera-projected depth plane"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10)
@@ -168,8 +174,23 @@ class ConvertToDepthPlane(bpy.types.Operator):
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, context, _event):
return self.execute(context)
def execute(self, context):
return start_depth_plane_job(self, context, "DEPTH")
class ConvertToReliefPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_relief_plane"
bl_label = "Convert to Relief Plane"
bl_description = "Convert the complete Image Empty into a fixed-base relief plane"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=6, min=0, max=10)
@classmethod
def poll(cls, context):
from ...runtime import runtime
return is_image_empty(context.object) and not runtime.server_busy()
def execute(self, context):
return start_depth_plane_job(self, context)
return start_depth_plane_job(self, context, "RELIEF")

View File

@@ -192,16 +192,14 @@ def create_shape_object(
mesh_object = None
try:
material = create_image_material(
source_object,
source_object.data,
color_image,
0.0,
normal_image=normal_image,
normal_space=(
NormalMode.TANGENT.value
if normal_mode == NormalMode.NONE
else normal_mode.value
),
ior=1.2,
scene=context.scene,
)
depth_reference = None

View File

@@ -71,19 +71,22 @@ def _write_float_exr(rgba, output_path):
return output_path
def write_depth_texture(frame, output_path):
"""Write a frame's camera-space XYZ and validity as float RGBA."""
def write_depth_texture(frame, output_path, *, alpha):
"""Write camera-space XYZ and alpha-weighted model validity as float RGBA."""
import numpy as np
if frame.points is None:
raise ValueError("Depth requires a point field")
points = np.asarray(frame.points, dtype=np.float32)
validity = np.asarray(frame.validity, dtype=np.float32)
alpha = np.asarray(alpha, dtype=np.float32)
if alpha.shape != validity.shape:
raise ValueError("Depth Alpha must match the validity dimensions")
if not np.isfinite(points).all():
raise ValueError("Point field contains non-finite values")
rgba = np.empty((*validity.shape, 4), dtype=np.float32)
rgba[..., :3] = points
rgba[..., 3] = validity
rgba[..., 3] = alpha * validity
return _write_float_exr(rgba, output_path)
@@ -101,7 +104,7 @@ def write_depth_metadata(frame, reference_valid, output_path):
raise ValueError("Depth metadata reference mask dimensions do not match")
if intrinsics.shape != (3, 3):
raise ValueError("Depth metadata requires 3x3 camera intrinsics")
valid = (validity > 0.0) & np.isfinite(depth) & (depth > 0.0)
valid = (validity > 0.5) & np.isfinite(depth) & (depth > 0.0)
reference_valid &= valid
if not valid.any():
raise ValueError("Geometry frame contains no valid depth pixels")

View File

@@ -1,4 +1,4 @@
"""Extend reliable Cutout depth along each visible component's silhouette."""
"""Extend reliable depth along each visible component's silhouette."""
from dataclasses import replace
@@ -18,19 +18,20 @@ def extend_edge_depth(frame, alpha, alpha_threshold=0.9):
return frame
visible = (alpha > 0.0) & (alpha >= alpha_threshold)
opaque = visible & (alpha >= 0.95)
candidates = (
visible
& (alpha >= 0.95)
& (frame.validity > 0.0)
opaque
& (frame.validity > 0.5)
& np.isfinite(depth)
& (depth > 0.0)
)
if not candidates.any():
return frame
interior_distance = ndimage.distance_transform_edt(
np.pad(candidates, 3, mode="edge")
np.pad(opaque, 3, mode="edge")
)[3:-3, 3:-3]
core = candidates & (interior_distance > 2.0)
interior = opaque & (interior_distance > 2.0)
core = candidates & interior
del interior_distance
labels, _ = ndimage.label(visible, structure=np.ones((3, 3), dtype=bool))
corrected = depth.copy()
@@ -59,7 +60,7 @@ def extend_edge_depth(frame, alpha, alpha_threshold=0.9):
& (distance <= 4.0)
& (distance < background_distance[region])
)
target = (component & ~donors) | background
target = (component & ~interior[region] & ~donors) | background
corrected[region][target] = smoothed[target]
background_distance[region][background] = distance[background]

View File

@@ -14,10 +14,8 @@ def _limited_model_input(source_path, output_path, max_input_size):
from PIL import Image
with Image.open(source_path) as opened:
limited = limit_image(opened.convert("RGBA"), max_input_size)
background = Image.new("RGBA", limited.size, (0, 0, 0, 255))
normalized = Image.alpha_composite(background, limited).convert("RGB")
normalized.save(output_path, format="PNG", compress_level=1)
limited = limit_image(opened.convert("RGB"), max_input_size)
limited.save(output_path, format="PNG", compress_level=1)
return output_path
@@ -58,7 +56,6 @@ def generate_moge2_artifacts(
*,
generate_depth=False,
normal_mode="NONE",
correct_edge_depth=False,
alpha_threshold=0.9,
):
"""Run MoGe-2 once and write only the requested file artifacts."""
@@ -85,17 +82,13 @@ def generate_moge2_artifacts(
if generate_depth:
context.progress(0.45, "Generating depth texture")
alpha = _image_alpha(input_path, frame.depth.shape)
depth_frame = (
extend_edge_depth(frame, alpha, alpha_threshold)
if correct_edge_depth else frame
)
depth_frame = extend_edge_depth(frame, alpha, alpha_threshold)
result["depth"] = write_depth_texture(
depth_frame, context.directory / "depth.exr",
depth_frame, context.directory / "depth.exr", alpha=alpha,
)
result["depth_metadata"] = write_depth_metadata(
depth_frame,
(alpha > 0.0) & (alpha >= alpha_threshold)
if correct_edge_depth else alpha >= 0.1,
(alpha > 0.0) & (alpha >= alpha_threshold),
context.directory / "depth.json",
)

View File

@@ -19,7 +19,6 @@ def run(context, parameters):
input_path,
generate_depth=generate_depth,
normal_mode=normal_mode,
correct_edge_depth=generate_depth,
alpha_threshold=float(parameters.get("alpha_threshold", 0.9)),
)
result.update({key: path.name for key, path in artifacts.items()})

View File

@@ -5,6 +5,9 @@ from ..media import copy_color_images, input_frames
def run(context, parameters):
plane_type = parameters["plane_type"]
if plane_type not in {"DEPTH", "RELIEF"}:
raise ValueError(f"Unsupported plane type: {plane_type}")
context.progress(0.05, "Preparing depth conversion")
context.directory.mkdir(parents=True, exist_ok=True)
with input_frames(context, parameters) as images:
@@ -16,7 +19,7 @@ def run(context, parameters):
parameters,
images[0],
generate_depth=True,
normal_mode="TANGENT",
normal_mode="OBJECT" if plane_type == "DEPTH" else "TANGENT",
)
context.check_cancelled()
context.progress(1.0, "Depth conversion data is ready")

View File

@@ -114,11 +114,11 @@ def postprocess(raw_output):
points = np.asarray(raw_output["points"][0], dtype=np.float32)
normal = np.asarray(raw_output["normal"][0], dtype=np.float32)
mask = np.asarray(raw_output["mask"][0] > 0.5)
mask = np.asarray(raw_output["mask"][0], dtype=np.float32)
metric_scale = np.float32(np.asarray(raw_output["metric_scale"]).reshape(-1)[0])
height, width = mask.shape
aspect_ratio = width / height
focal, shift = recover_focal_shift(points, mask)
focal, shift = recover_focal_shift(points, mask > 0.5)
diagonal_factor = (1.0 + aspect_ratio**2) ** 0.5
fx = focal / 2.0 * diagonal_factor / aspect_ratio
fy = focal / 2.0 * diagonal_factor
@@ -127,7 +127,6 @@ def postprocess(raw_output):
dtype=np.float32,
)
depth = points[..., 2] + shift
mask &= depth > 0.0
depth = depth * metric_scale
points = depth_to_points(depth, intrinsics)
return {

5
tests/conftest.py Normal file
View File

@@ -0,0 +1,5 @@
"""Expose project source packages to the test suite."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

View File

@@ -11,14 +11,13 @@ ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "tools" / "node_assets"))
from cutout import build_cutout_group
from node_asset_test_support import CUTOUT_BUILDERS
def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_cutout_group("DEPTH_SOLID")
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
# Two columns with a complete depth jump along their common boundary.
points = [
(x * size / resolution, y * size / resolution, 0)

View File

@@ -0,0 +1,22 @@
"""Shared assertions and asset selection for node tests."""
from tools.nodes.groups.image_cutout import build_image_cutout_group
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_balloon import build_image_depth_balloon_group
CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group,
"SOLID": build_image_cutout_group,
"DEPTH_SOLID": build_image_depth_cutout_group,
"DEPTH_BALLOON": build_image_depth_balloon_group,
}
def validate_node_structure(node_group):
"""Check visible sockets, default titles, and paired execution endpoints."""
nodes = [node for node in node_group.nodes if node.bl_idname not in {"NodeReroute", "NodeFrame"}]
assert all(not node.label for node in nodes), node_group.name
assert not any(node.bl_idname == "FunctionNodeInputString" for node in nodes), node_group.name
assert all(socket.is_linked or socket.hide for node in node_group.nodes for socket in node.outputs), node_group.name
for node in nodes:
if node.bl_idname in {"GeometryNodeRepeatInput", "GeometryNodeSimulationInput", "GeometryNodeForeachGeometryElementInput"}:
assert node.paired_output in node_group.nodes.values(), (node_group.name, node.name)

View File

@@ -21,6 +21,7 @@ class AddonRegistrationTest(BlenderTestCase):
operator = self.convert_to_plane.ConvertToPlane
self.assertIn(operator, self.anyimage.CLASSES)
self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES)
self.assertIn(self.convert_to_plane.ConvertToReliefPlane, self.anyimage.CLASSES)
self.assertFalse(hasattr(self.convert_to_plane, "ConvertToDepthSurface"))
self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")

View File

@@ -396,43 +396,23 @@ class ClipboardImageTest(unittest.TestCase):
finally:
clipboard_image._native_copy_token = original_token
def test_shadeless_nodes_mix_emission_with_transparency(self):
created_nodes = {}
def test_plane_paste_uses_shared_material_for_both_shading_modes(self):
from anyimage.operators.convert_to_plane import image_plane
def create_node(node_type):
node = SimpleNamespace(location=None, inputs={}, outputs={})
if node_type == "ShaderNodeBsdfTransparent":
node.outputs["BSDF"] = object()
elif node_type == "ShaderNodeEmission":
node.inputs["Color"] = object()
node.outputs["Emission"] = object()
else:
node.inputs = [object(), object(), object()]
node.outputs["Shader"] = object()
created_nodes[node_type] = node
return node
nodes = SimpleNamespace(new=Mock(side_effect=create_node))
links = SimpleNamespace(new=Mock())
image_node = SimpleNamespace(
outputs={"Color": object(), "Alpha": object()},
image = SimpleNamespace(name="Clipboard", size=(16, 8))
context = SimpleNamespace(mode="OBJECT", scene=object())
for shadeless in (False, True):
with (
patch.object(self.actions, "create_image_material", return_value="Material") as create_material,
patch.object(image_plane, "create_image_plane_object") as create_plane,
patch.object(self.actions, "_place_in_view"),
patch.object(self.actions, "_select_only"),
):
self.actions.add_image_plane(context, image, shadeless=shadeless)
create_material.assert_called_once_with(
image, image, shadeless=shadeless, scene=context.scene,
)
output_node = SimpleNamespace(inputs={"Surface": object()})
self.actions._create_shadeless_nodes(
nodes,
links,
image_node,
output_node,
)
mix_node = created_nodes["ShaderNodeMixShader"]
links.new.assert_any_call(image_node.outputs["Alpha"], mix_node.inputs[0])
links.new.assert_any_call(
created_nodes["ShaderNodeEmission"].outputs["Emission"],
mix_node.inputs[2],
)
self.assertEqual(links.new.call_count, 5)
self.assertEqual(create_plane.call_args.args[3], "Material")
def test_reference_import_routes_to_image_empty(self):
context = self.make_context("VIEW_3D")

View File

@@ -123,7 +123,8 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
points=points,
)
path = tmp_path / "depth.exr"
write_depth_texture(frame, path)
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
write_depth_texture(frame, path, alpha=alpha)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = depth_data.load_depth_result_image(path, source, metadata)
@@ -132,7 +133,10 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[..., :3], points)
assert np.array_equal(packed[..., 3], validity)
assert np.array_equal(packed[..., 3], alpha * validity)
camera_depth, valid = depth_data.camera_depth_values(image)
np.testing.assert_array_equal(camera_depth, points[..., 2])
np.testing.assert_array_equal(valid, alpha * validity > 0.5)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
@@ -149,7 +153,7 @@ def test_depth_texture_keeps_points_outside_positive_validity(tmp_path):
intrinsics=np.eye(3, dtype=np.float32),
)
path = tmp_path / "depth.exr"
write_depth_texture(frame, path)
write_depth_texture(frame, path, alpha=np.ones_like(frame.validity))
image = depth_data.load_depth_result_image(
path,

View File

@@ -7,10 +7,9 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "tools/node_assets"))
from cutout import build_cutout_group
from cutout_volume import build_volume_fields
from cutout_rounding import build_volume_rounding
from node_asset_test_support import CUTOUT_BUILDERS
from tools.nodes.groups.image_depth_cutout import build_volume_fields
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from anyimage.operators.cutout_tool.operators import normal_mode_for_shape
from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile
@@ -47,7 +46,7 @@ def test_refined_boundary_ears_have_interior_balloon_samples():
def test_depth_rounding_options_are_consolidated():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_cutout_group("DEPTH_SOLID")
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
for name, default in (("Edge Round", 0.2), ("Front Inflation", 1), ("Normal Smooth", 50)):
assert inputs[name].default_value == pytest.approx(default)
@@ -131,7 +130,7 @@ def test_rounding_shares_front_and_back_falloff(amount):
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
result = build_volume_fields(group, source.outputs["Geometry"])
result = build_volume_rounding(group, result, (5000, 0))
result = build_volume_rounding(group, result)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Round", "NODES").node_group = group
bpy.context.view_layer.update()

View File

@@ -1,6 +1,5 @@
"""Evaluate complete-image projection and rectangular surface topology."""
import sys
from collections import Counter
from pathlib import Path
import bpy
@@ -8,23 +7,35 @@ import numpy as np
import pytest
ROOT = Path(__file__).parents[1]
sys.path[:0] = [str(ROOT / "src"), str(ROOT / "tools" / "node_assets")]
from image_plane import build_image_plane_group
from depth_plane import build_depth_plane_group
from node_layout import validate_node_layout
from tools.nodes.groups.image_plane import build_image_plane_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
from node_asset_test_support import validate_node_structure
from tools.nodes.arrangement.flow import origin_socket
@pytest.fixture(params=("definition", "asset"))
def surface(request):
return create_surface(request.param, "DEPTH")
@pytest.fixture(params=("definition", "asset"))
def relief(request):
return create_surface(request.param, "RELIEF")
def create_surface(source, plane_type):
bpy.ops.wm.read_factory_settings(use_empty=True)
if request.param == "asset":
with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target):
target.node_groups = ["O Image Depth Plane"]
group_name = f"O Image {plane_type.title()} Plane"
if source == "asset":
with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (library, target):
target.node_groups = [group_name]
group = target.node_groups[0]
plane = group.nodes["Group"].node_tree
else:
plane = build_image_plane_group()
group = build_depth_plane_group(plane)
builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group
group = builder(plane)
mesh = bpy.data.meshes.new("Rectangle")
mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)])
material = bpy.data.materials.new("Image")
@@ -42,21 +53,16 @@ def surface(request):
pixels[..., 0] = ((xx + 0.5) - 5) / 4
pixels[..., 1] = (3 - (yy + 0.5)) / 4
pixels[..., 2] = 2 + (xx + 0.5) / 16
pixels[..., 3] = xx >= 8
pixels[..., 3] = 1
image.pixels.foreach_set(pixels.ravel())
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
thickness_inputs = [item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
and item.name == "Thickness"]
inputs["Relief Thickness"] = thickness_inputs[0]
def set_value(name, value):
modifier[inputs[name].identifier] = value
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
for name, value in (("Mode", 1), ("Subdivide", 2), ("Depth Image", image),
for name, value in (("Subdivide", 2), ("Depth Image", image),
("Uniform Scale", 0.5), ("Reference Depth", 1.2),
("Depth Scale", 0.0), ("Thickness", 0.0)):
set_value(name, value)
@@ -70,7 +76,7 @@ def evaluated(obj):
return (
np.array([v.co[:] for v in mesh.vertices]),
[tuple(face.vertices) for face in mesh.polygons],
np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]),
np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]) if mesh.uv_layers else np.empty((0, 2)),
[material.name if material else None for material in mesh.materials],
[face.material_index for face in mesh.polygons],
[attr.name for attr in mesh.attributes],
@@ -157,10 +163,9 @@ def test_depth_jump_keeps_rectangular_faces(surface):
@pytest.mark.parametrize("thickness", [0.0, 0.4])
def test_relief_reads_z_and_keeps_the_base(surface, thickness):
obj, set_value, _, inputs, image = surface
set_value("Mode", 0)
set_value("Relief Thickness", thickness)
def test_relief_reads_z_and_keeps_the_base(relief, thickness):
obj, set_value, _, inputs, image = relief
set_value("Thickness", thickness)
set_value("Depth Scale", 1.0)
set_value("Reference Depth", 2.0)
pixels = np.ones((8, 16, 4), dtype=np.float32)
@@ -174,16 +179,9 @@ def test_relief_reads_z_and_keeps_the_base(surface, thickness):
pixels[..., 0], pixels[..., 1] = 31, -22
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Normal Smooth", 0)
set_value("Uniform Scale", 0.5)
np.testing.assert_allclose(evaluated(obj)[0], first[0])
original_images = set(bpy.data.images)
set_value("Mode", 1)
camera_points = evaluated(obj)[0]
assert camera_points.shape != first[0].shape or not np.allclose(camera_points, first[0])
set_value("Mode", 0)
np.testing.assert_allclose(evaluated(obj)[0], first[0])
assert set(bpy.data.images) == original_images
assert obj.modifiers[0][inputs["Relief Thickness"].identifier] == pytest.approx(thickness)
assert obj.modifiers[0][inputs["Thickness"].identifier] == pytest.approx(thickness)
@pytest.mark.parametrize("normal_smooth", [0, 2, 50])
@@ -228,48 +226,43 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
assert roughness(normals) < roughness(raw_normals)
@pytest.mark.parametrize("mode,active,inactive", [
(0, "Relief Thickness", "Thickness"),
(1, "Thickness", "Relief Thickness"),
])
def test_modes_keep_independent_thickness_values(surface, mode, active, inactive):
obj, set_value, _, inputs, _ = surface
set_value("Depth Scale", 1.0)
set_value("Mode", mode)
set_value(active, 0.2)
before = evaluated(obj)
set_value(inactive, 0.7)
np.testing.assert_allclose(evaluated(obj)[0], before[0])
assert evaluated(obj)[1] == before[1]
set_value("Mode", 1 - mode)
set_value("Mode", mode)
np.testing.assert_allclose(evaluated(obj)[0], before[0])
assert obj.modifiers[0][inputs[active].identifier] == pytest.approx(0.2)
assert obj.modifiers[0][inputs[inactive].identifier] == pytest.approx(0.7)
def test_surface_interface_and_authored_layout(surface):
def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface
assert list(inputs) == ["Geometry", "Mode", "Subdivide", "Thickness", "Depth Scale",
"Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image", "Relief Thickness"]
assert [name for name, item in inputs.items() if not item.hide_in_modifier][1:] == [
"Mode", "Subdivide", "Thickness", "Depth Scale", "Reference Depth", "Normal Smooth", "Relief Thickness"]
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only",
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
assert inputs["Validity Threshold"].default_value == pytest.approx(0.9)
assert inputs["Validity Threshold"].parent.name == "Options"
assert inputs["Validity Threshold"].subtype == "FACTOR"
assert inputs["Valid Only"].default_value is True
assert inputs["Smooth"].default_value == 0
assert inputs["Smooth"].min_value == 0 and inputs["Smooth"].max_value == 20
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 inputs["Thickness"].default_value == inputs["Relief Thickness"].default_value == 0
assert inputs["Thickness"].identifier != inputs["Relief Thickness"].identifier
actual_inputs = [item.name for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"]
assert actual_inputs == ["Geometry", "Mode", "Subdivide", "Thickness", "Thickness",
"Depth Scale", "Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image"]
assert inputs["Mode"].default_value == "Relief"
assert inputs["Thickness"].default_value == 0
assert inputs["Subdivide"].default_value == 6
assert inputs["Depth Scale"].default_value == 1
assert all(item.structure_type == "SINGLE" for item in inputs.values())
assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE"
validate_node_layout(obj.modifiers[0].node_group)
assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier
validate_node_structure(obj.modifiers[0].node_group)
def test_relief_interface_and_node_structure(relief):
obj, _, _, inputs, _ = relief
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale",
"Reference Depth", "Uniform Scale", "Depth Image"]
assert inputs["Thickness"].default_value == 0
assert inputs["Subdivide"].default_value == 6
assert inputs["Depth Scale"].default_value == 1
assert all(item.structure_type == "SINGLE" for item in inputs.values())
assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE"
assert inputs["Uniform Scale"].hide_in_modifier and inputs["Depth Image"].hide_in_modifier
validate_node_structure(obj.modifiers[0].node_group)
def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch):
@@ -293,7 +286,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf
metadata = {"image_size": (16, 8), "reference_depth": 2.0,
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
result = conversion.create_depth_plane_object(
bpy.context, source, 2, material, image, metadata)
bpy.context, source, 2, material, image, metadata, "DEPTH")
assert result.name == "Offset Image"
assert result["anyimage_mesh_shape"] == "DEPTH_PLANE"
assert bpy.context.view_layer.objects.active == result
@@ -305,8 +298,8 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf
np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6)
modifier = result.modifiers[0]
assert modifier[inputs["Thickness"].identifier] == 0
assert modifier[inputs["Relief Thickness"].identifier] == 0
assert modifier[inputs["Depth Image"].identifier] == image
assert modifier[inputs["Valid Only"].identifier] is True
assert modifier[inputs["Reference Depth"].identifier] == pytest.approx(
2 * modifier[inputs["Uniform Scale"].identifier])
assert result.data.materials[0] == material
@@ -331,7 +324,109 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa
metadata = {"image_size": (16, 8), "reference_depth": 2,
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
with pytest.raises(RuntimeError, match="input failed"):
conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata)
conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH")
assert set(bpy.data.objects) == before_objects
assert set(bpy.data.meshes) == before_meshes
assert material.users == 0
@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0])
@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"])
def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold):
obj, set_value, _, inputs, _ = surface
set_value("Validity Threshold", threshold)
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
pixels = np.ones((8, 16, 4), dtype=np.float32)
alpha = np.ones(16, dtype=np.float32)
if pattern == "invalid":
alpha[:] = 0
elif pattern == "partial":
alpha[:] = 0.5
elif pattern == "half":
alpha[:8] = 0
elif pattern == "stripe":
alpha[:] = 0
alpha[8] = 1
pixels[..., 3] = alpha
depth_image.pixels.foreach_set(pixels.ravel())
depth_image.update()
for subdivisions in (0, 2, 5):
set_value("Subdivide", subdivisions)
set_value("Valid Only", False)
base = evaluated(obj)
u = (base[0][:, 0] + 2) / 4
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
expected = [face for face in base[1] if any(sampled[index] >= threshold for index in face)]
set_value("Valid Only", True)
culled = evaluated(obj)
assert len(culled[1]) == len(expected)
expected_points = {tuple(base[0][index]) for face in expected for index in face}
assert {tuple(point) for point in culled[0]} == expected_points
assert np.isfinite(culled[2]).all()
if pattern == "half" and subdivisions >= 2 and threshold == 1.0:
assert 0 <= min(point[0] for point in culled[0]) < 0.125
set_value("Valid Only", False)
np.testing.assert_allclose(evaluated(obj)[0], base[0])
def test_valid_only_precedes_closed_shell_generation(surface):
obj, set_value, _, inputs, _ = surface
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
pixels[:, :8, 3] = 0
depth_image.pixels.foreach_set(pixels.ravel())
depth_image.update()
set_value("Depth Scale", 1.0)
front = evaluated(obj)[0]
set_value("Thickness", 0.2)
points, faces, uv, *_ = evaluated(obj)
assert len(points) == 2 * len(front)
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
assert set(edges.values()) == {2}
assert np.isfinite(points).all() and np.isfinite(uv).all()
def test_validity_graph_matches_reference_domains_and_connections(surface):
obj, _, _, _, _ = surface
group = obj.modifiers[0].node_group
delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry")
assert (delete.domain, delete.mode) == ("FACE", "ALL")
points = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
assert (points.bl_idname, points.data_type, points.domain) == ("GeometryNodeFieldOnDomain", "BOOLEAN", "POINT")
compare = origin_socket(points.inputs["Value"].links[0].from_socket).node
assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN")
assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold"
alpha = origin_socket(compare.inputs["A"].links[0].from_socket)
assert alpha.name == "Alpha" and alpha.node.bl_idname == "FunctionNodeSeparateColor"
color = origin_socket(alpha.node.inputs["Color"].links[0].from_socket).node
assert origin_socket(color.inputs["Image"].links[0].from_socket).name == "Depth Image"
assert len([node for node in color.id_data.nodes if node.bl_idname == "GeometryNodeImageTexture"]) == 1
consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute"
and origin_socket(link.from_socket) == color.outputs["Color"]]
assert len(consumers) == 2
assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane"
@pytest.mark.parametrize("thickness", [0.0, 0.2])
def test_depth_smooth_changes_positions_and_preserves_topology(surface, thickness):
obj, set_value, _, _, depth_image = surface
pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
pixels[..., 2] += (np.arange(16) % 2) * 0.3
pixels[:, :4, 3] = 0
depth_image.pixels.foreach_set(pixels.ravel())
depth_image.update()
set_value("Depth Scale", 1.0)
set_value("Thickness", thickness)
base = evaluated(obj)
set_value("Smooth", 3)
smoothed = evaluated(obj)
assert smoothed[1] == base[1]
np.testing.assert_allclose(smoothed[2], base[2])
assert smoothed[3:5] == base[3:5]
assert np.isfinite(smoothed[0]).all()
assert not np.allclose(smoothed[0], base[0])
set_value("Smooth Weight", 0.0)
np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6)
set_value("Smooth Weight", 1.0)
set_value("Smooth", 0)
np.testing.assert_allclose(evaluated(obj)[0], base[0], atol=1e-6)

View File

@@ -12,9 +12,8 @@ ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "tools" / "node_assets"))
from cutout import build_cutout_group
from node_asset_test_support import CUTOUT_BUILDERS
from depth_surface_test_support import assert_closed, component_count, evaluated, surface
@@ -114,7 +113,7 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale):
@pytest.mark.parametrize("shape", ["SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"])
def test_each_asset_cleans_the_original_geometry_before_shaping(shape):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_cutout_group(shape)
group = CUTOUT_BUILDERS[shape]()
links = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"]
assert len(links) == 1
assert links[0].to_node.bl_idname == "GeometryNodeDeleteGeometry"

View File

@@ -120,13 +120,38 @@ def test_diagonal_pixels_share_a_component():
assert result.depth[5, 5] == 2
def test_invalid_donors_are_excluded():
@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5])
def test_invalid_donors_are_excluded(validity):
frame, alpha = silhouette()
frame.depth[10, 12] = 100
frame.validity[10, 12] = 0
frame.depth[10, 6] = 100
frame.validity[10, 6] = validity
result = extend_edge_depth(frame, alpha)
assert result.depth[10, 12] == 2
assert result.validity[10, 12] == 0
assert result.depth[10, 4] == 2
assert result.depth[10, 6] == 100
assert result.validity[10, 6] == np.float32(validity)
def test_internal_mask_hole_does_not_expand_alpha_repair():
frame, alpha = silhouette()
frame.depth[9:14, 10:15] = 100
frame.validity[9:14, 10:15] = 0
original = frame.depth.copy()
result = extend_edge_depth(frame, alpha)
np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20])
assert result.depth[12, 24] == 2
def test_opaque_image_keeps_low_mask_depth():
frame = make_frame(np.ones((10, 10)))
frame.depth[5, 5] = 100
frame.validity[5, 5] = 0
assert extend_edge_depth(frame, np.ones((10, 10))) is frame
def test_component_without_valid_donors_keeps_its_depth():
frame, alpha = silhouette()
frame.validity[alpha > 0] = 0
assert extend_edge_depth(frame, alpha) is frame
def test_local_smoothing_reduces_donor_step_and_preserves_core():
@@ -159,16 +184,16 @@ def test_alpha_resize_retains_threshold_values(tmp_path):
@pytest.mark.parametrize(
"generate_depth,normal_mode,correct",
"generate_depth,normal_mode",
[
(True, "OBJECT", True),
(True, "TANGENT", True),
(True, "TANGENT", False),
(False, "OBJECT", True),
(True, "OBJECT"),
(True, "TANGENT"),
(True, "NONE"),
(False, "OBJECT"),
],
)
def test_artifacts_use_corrected_depth_and_original_normals(
tmp_path, generate_depth, normal_mode, correct
tmp_path, generate_depth, normal_mode
):
frame, alpha = silhouette()
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
@@ -197,17 +222,18 @@ def test_artifacts_use_corrected_depth_and_original_normals(
):
artifacts.generate_moge2_artifacts(
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
correct_edge_depth=correct,
)
if normal_mode != "NONE":
assert normal.call_args.args[0] is frame
assert repair.call_count == int(correct and generate_depth)
else:
normal.assert_not_called()
assert repair.call_count == int(generate_depth)
if generate_depth:
written_frame = depth.call_args.args[0]
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
assert metadata.call_args.args[0] is written_frame
if correct:
assert written_frame.depth[12, 24] == 2
else:
assert written_frame is frame
assert written_frame.validity is frame.validity
else:
metadata.assert_not_called()
depth.assert_not_called()

View File

@@ -6,6 +6,30 @@ from unittest.mock import patch
from server_runtime_test_support import ServerTestCase
class GeometryArtifactsTest(ServerTestCase):
def test_depth_alpha_multiplies_coverage_without_changing_geometry(self):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
GeometryFrame = importlib.import_module("server.models.geometry").GeometryFrame
validity = np.asarray([[0.75, 0, 1, 1]], dtype=np.float32)
alpha = np.asarray([[0.8, 1, 0, 1]], dtype=np.float32)
points = np.arange(12, dtype=np.float32).reshape(1, 4, 3)
frame = GeometryFrame(
depth=points[..., 2], points=points,
validity=validity.copy(), intrinsics=np.eye(3, dtype=np.float32),
)
output = self.storage_root / "depth.exr"
with patch.object(depth, "_write_float_exr") as write:
depth.write_depth_texture(frame, output, alpha=alpha)
packed = write.call_args.args[0]
np.testing.assert_allclose(packed[..., 3], [[0.6, 0, 0, 1]])
np.testing.assert_array_equal(packed[..., :3], points)
np.testing.assert_array_equal(frame.validity, validity)
self.assertEqual(packed.dtype, np.float32)
with self.assertRaisesRegex(ValueError, "Alpha.*dimensions"):
depth.write_depth_texture(frame, output, alpha=np.ones((4, 1)))
self.assertEqual(write.call_count, 1)
def test_depth_artifacts_store_texture_and_compact_metadata(self):
import numpy as np
@@ -30,7 +54,7 @@ class GeometryArtifactsTest(ServerTestCase):
intrinsics=np.eye(3, dtype=np.float32),
)
depth.write_depth_texture(frame, texture_path)
depth.write_depth_texture(frame, texture_path, alpha=np.ones_like(validity))
depth.write_depth_metadata(frame, calibration, metadata_path)
self.assertTrue(texture_path.is_file())
@@ -93,7 +117,7 @@ class GeometryArtifactsTest(ServerTestCase):
)
def test_moge_model_input_composites_rgba_over_black(self):
def test_moge_model_input_preserves_rgb_independently_of_alpha(self):
import numpy as np
from PIL import Image
@@ -105,30 +129,24 @@ class GeometryArtifactsTest(ServerTestCase):
dtype=np.uint8,
)
second = first.copy()
second[0, 0, :3] = (1, 2, 3)
second[..., 3] = 255
Image.fromarray(first, mode="RGBA").save(first_path)
Image.fromarray(second, mode="RGBA").save(second_path)
first_output = artifacts._limited_model_input(
first_path,
self.storage_root / "first-model.png",
2048,
)
second_output = artifacts._limited_model_input(
second_path,
self.storage_root / "second-model.png",
2048,
)
with Image.open(first_output) as opened:
first_rgb = np.asarray(opened, dtype=np.uint8)
with Image.open(second_output) as opened:
second_rgb = np.asarray(opened, dtype=np.uint8)
np.testing.assert_array_equal(first_rgb, second_rgb)
np.testing.assert_array_equal(
first_rgb,
np.asarray([[(0, 0, 0), (100, 50, 25), (9, 8, 7)]], dtype=np.uint8),
for max_size in (2048, 2):
with self.subTest(max_size=max_size):
outputs = []
for source in (first_path, second_path):
output = artifacts._limited_model_input(
source, source.with_name(f"model-{source.name}"), max_size,
)
with Image.open(output) as opened:
self.assertEqual(opened.mode, "RGB")
outputs.append(np.asarray(opened))
np.testing.assert_array_equal(*outputs)
self.assertEqual(outputs[0].shape[1], min(3, max_size))
if max_size == 2048:
np.testing.assert_array_equal(outputs[0], first[..., :3])
def test_geometry_writers_reject_missing_or_non_finite_fields(self):
@@ -148,6 +166,7 @@ class GeometryArtifactsTest(ServerTestCase):
depth.write_depth_texture(
GeometryFrame(**base),
self.storage_root / "missing-points.exr",
alpha=base["validity"],
)
point_frame = GeometryFrame(
**base,
@@ -157,6 +176,7 @@ class GeometryArtifactsTest(ServerTestCase):
depth.write_depth_texture(
point_frame,
self.storage_root / "nan-points.exr",
alpha=base["validity"],
)
depth_frame = GeometryFrame(
**base, points=np.asarray([[(0, 0, 1), (0, 0, np.inf)]], dtype=np.float32),
@@ -165,6 +185,7 @@ class GeometryArtifactsTest(ServerTestCase):
depth.write_depth_texture(
depth_frame,
self.storage_root / "inf-depth.exr",
alpha=base["validity"],
)
with self.assertRaisesRegex(ValueError, "normal field"):
normal.write_camera_normal_texture(
@@ -204,7 +225,9 @@ class GeometryArtifactsTest(ServerTestCase):
"server.models.geometry"
).GeometryFrame
input_path = self.storage_root / "selection.png"
Image.new("RGBA", (2, 2), (255, 128, 64, 255)).save(input_path)
source = Image.new("RGBA", (4, 4), (255, 128, 64, 255))
source.paste((255, 128, 64, 0), (2, 0, 4, 2))
source.save(input_path)
output_directory = self.storage_root / "job"
output_directory.mkdir()
context = SimpleNamespace(
@@ -221,7 +244,11 @@ class GeometryArtifactsTest(ServerTestCase):
points=np.ones((2, 2, 3), dtype=np.float32),
)
with patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer:
texture = importlib.import_module("server.geometry.depth_texture")
with (
patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer,
patch.object(texture, "_write_float_exr", wraps=texture._write_float_exr) as write,
):
result = artifacts.generate_moge2_artifacts(
context,
{"max_input_size": 2048},
@@ -230,6 +257,9 @@ class GeometryArtifactsTest(ServerTestCase):
normal_mode="OBJECT",
)
np.testing.assert_array_equal(write.call_args.args[0][..., 3], [[1, 0], [1, 1]])
np.testing.assert_array_equal(frame.validity, np.ones((2, 2)))
self.assertEqual(json.loads(result["depth_metadata"].read_text())["reference_depth"], 1)
infer.assert_called_once()
self.assertEqual(
set(result),
@@ -288,6 +318,5 @@ class GeometryArtifactsTest(ServerTestCase):
input_path,
generate_depth=True,
normal_mode="OBJECT",
correct_edge_depth=True,
alpha_threshold=0.9,
)

View File

@@ -0,0 +1,139 @@
import sys
from pathlib import Path
from types import SimpleNamespace
import bpy
import numpy as np
import pytest
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from anyimage.common import material
from tools.nodes.groups.image_layer import build_image_layer_group
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
@pytest.fixture
def image_layer():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_image_layer_group()
yield group
bpy.ops.wm.read_factory_settings(use_empty=True)
def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
inputs = {
item.name: item for item in image_layer.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert list(inputs) == [
"Color", "Alpha", "Normal", "Bump Scale", "Alpha Fix",
"Normal Scale", "Object Space",
]
panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"]
assert len(panels) == 1
assert panels[0].name == "Options" and panels[0].default_closed
assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"]
strength = inputs["Alpha Fix"]
assert (strength.default_value, strength.min_value, strength.max_value) == (0, 0, 1)
assert strength.subtype == "FACTOR"
output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT")
curve = output.inputs["Alpha"].links[0].from_node
assert curve.type == "CURVE_FLOAT"
assert curve.inputs["Value"].links[0].from_socket.name == "Alpha"
assert curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix"
mapping = curve.mapping
np.testing.assert_allclose(
[point.location[:] for point in mapping.curves[0].points],
[(0, 0), (0.8, 0), (0.925, 0.925), (1, 1)],
atol=1e-6,
)
samples = np.array([
mapping.evaluate(mapping.curves[0], float(alpha))
for alpha in np.linspace(0, 1, 1001)
])
np.testing.assert_allclose(samples[:801], 0, atol=3e-4)
assert samples[-1] == pytest.approx(1)
assert np.all(np.diff(samples) >= -1e-6)
assert np.all((samples >= 0) & (samples <= 1))
assert mapping.evaluate(mapping.curves[0], 0.85) == pytest.approx(0.1593, abs=1e-3)
assert mapping.evaluate(mapping.curves[0], 0.925) == pytest.approx(0.925, abs=5e-4)
assert all(socket.is_linked or socket.hide for node in image_layer.nodes for socket in node.outputs)
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("shadeless", [False, True])
@pytest.mark.parametrize("view", ["AgX", "Unknown"])
def test_material_alpha_strength_follows_preference(image_layer, monkeypatch, enabled, shadeless, view):
monkeypatch.setattr(material, "material_node_group", lambda depth_plane=False: image_layer)
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: enabled)
color = bpy.data.images.new("Alpha edge", width=2, height=2, alpha=True)
scene = SimpleNamespace(view_settings=SimpleNamespace(view_transform=view))
result = material.create_image_material(color, color, shadeless=shadeless, scene=scene)
layer = next(node for node in result.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert layer.inputs["Alpha Fix"].default_value == float(enabled)
alpha_link = layer.outputs["Alpha"].links[0]
assert alpha_link.to_node.type == ("MIX_SHADER" if shadeless else "BSDF_PRINCIPLED")
assert alpha_link.to_socket.name == ("Fac" if shadeless else "Alpha")
layer.inputs["Alpha Fix"].default_value = 0.5
monkeypatch.setattr(material, "configured_material_view_adaptation", lambda: not enabled)
second = material.create_image_material(color, color, shadeless=shadeless, scene=scene)
second_layer = next(node for node in second.node_tree.nodes if node.type == "GROUP" and node.node_tree == image_layer)
assert second_layer.inputs["Alpha Fix"].default_value == float(not enabled)
assert layer.inputs["Alpha Fix"].default_value == 0.5
def test_depth_layer_keeps_foreground_alpha_threshold(image_layer):
depth = build_image_depth_layer_group()
assert "Alpha Fix" not in {
item.name for item in depth.interface.items_tree if item.item_type == "SOCKET"
}
output = next(node for node in depth.nodes if node.type == "GROUP_OUTPUT")
threshold = output.inputs["Alpha"].links[0].from_node
assert threshold.operation == "GREATER_THAN"
assert threshold.inputs[1].default_value == 0.5
assert threshold.inputs[0].links[0].from_socket.name == "Alpha"
def test_alpha_adjustment_shader_blends_curve_strength(image_layer, tmp_path):
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 1
scene.cycles.use_denoising = False
scene.render.resolution_x = 96
scene.render.resolution_y = 32
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_depth = "32"
scene.render.filepath = str(tmp_path / "alpha-strength.exr")
bpy.ops.object.camera_add(location=(0, 0, 5))
camera = bpy.context.object
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3
scene.camera = camera
for index, factor in enumerate((0, 0.5, 1)):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
result = bpy.data.materials.new(f"Alpha {factor}")
result.use_nodes = True
nodes = result.node_tree.nodes
nodes.clear()
layer = nodes.new("ShaderNodeGroup")
layer.node_tree = image_layer
layer.inputs["Alpha"].default_value = 0.9
layer.inputs["Alpha Fix"].default_value = factor
emission = nodes.new("ShaderNodeEmission")
output = nodes.new("ShaderNodeOutputMaterial")
result.node_tree.links.new(layer.outputs["Alpha"], emission.inputs["Color"])
result.node_tree.links.new(emission.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(result)
bpy.ops.render.render(write_still=True)
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(rendered.pixels[:]).reshape(32, 96, 4)
values = pixels[16, [16, 48, 80], 0]
assert values[0] == pytest.approx(0.9, abs=1e-4)
assert values[2] == pytest.approx(0.77396, abs=1e-3)
assert values[1] == pytest.approx((values[0] + values[2]) / 2, abs=1e-4)
finally:
bpy.data.images.remove(rendered)

View File

@@ -68,7 +68,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
color = bpy.data.images.new("Color", width=2, height=2)
normal_image = bpy.data.images.new("Normal", width=2, height=2)
object_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Object Normal")),
SimpleNamespace(name="Object Normal"),
color,
1.0,
normal_image=normal_image,
@@ -78,7 +78,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
assert layer.inputs["Object Space"].default_value is True
tangent_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")),
SimpleNamespace(name="Tangent Normal"),
color,
1.0,
normal_image=normal_image,
@@ -127,13 +127,15 @@ def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
material = material_data.create_image_material(source, image, 0.0)
material = material_data.create_image_material(source.data, image)
shader = next(
node
for node in material.node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
assert image_layer.inputs["Bump Scale"].default_value == 0.0
assert any(
link.from_node == image_layer
and link.from_socket == image_layer.outputs["Color"]
@@ -143,7 +145,7 @@ def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
)
shadeless = material_data.create_image_material(
source,
source.data,
image,
0.0,
shadeless=True,
@@ -163,7 +165,7 @@ def test_cutout_material_uses_flat_tangent_normal():
normal = bpy.data.images.new("Normal", width=1, height=1)
result = material_data.create_image_material(
source,
source.data,
color,
0.0,
normal_image=normal,

View File

@@ -45,6 +45,7 @@ class ImageMenuTest(BlenderTestCase):
[
("anyimage.convert_to_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_relief_plane", True),
("separator", True),
("anyimage.remove_image_background", True),
("anyimage.upscale_image", True),
@@ -101,6 +102,7 @@ class ImageMenuTest(BlenderTestCase):
)
operators = (
(self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane),
(self.convert_to_plane, self.convert_to_plane.ConvertToReliefPlane),
(self.remove_background, self.remove_background.RemoveImageBackground),
)

View File

@@ -145,7 +145,7 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
}
with patch.object(preferences, "addon_preferences", return_value=settings):
if entry == "clipboard":
first = clipboard._create_image_material(source, shadeless, scene=scene)
first = clipboard.create_image_material(source, source, shadeless=shadeless, scene=scene)
else:
if entry == "plane":
color = create_color_image(owner)
@@ -153,16 +153,16 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data,
color = create_cropped_color_image(owner, (0, 0, 2, 2), images.image_rgba(source))
else:
color = source
first = material.create_image_material(owner, color, 0, shadeless=shadeless, scene=scene, **data_textures)
first = material.create_image_material(owner.data, color, 0, shadeless=shadeless, scene=scene, **data_textures)
original_color = color_texture(first)
assert original_color.colorspace_settings.name == "AgX Base sRGB"
assert original_color.alpha_mode == "STRAIGHT"
settings.adapt_material_to_view_transform = False
assert original_color.alpha_mode == "STRAIGHT"
if entry == "clipboard":
second = clipboard._create_image_material(original_color, shadeless, scene=scene)
second = clipboard.create_image_material(original_color, original_color, shadeless=shadeless, scene=scene)
else:
second = material.create_image_material(owner, original_color, 0, shadeless=shadeless, scene=scene)
second = material.create_image_material(owner.data, original_color, 0, shadeless=shadeless, scene=scene)
new_color = color_texture(second)
assert new_color != original_color
assert new_color.colorspace_settings.name == "sRGB"

View File

@@ -31,7 +31,7 @@ def test_geometry_frame_requires_matching_float32_fields():
def test_moge_infer_returns_geometry_frames_with_model_validity():
mask = np.asarray(((True, False), (True, True)))
mask = np.asarray(((1.0, 0.2), (0.7, 0.0)), dtype=np.float32)
prediction = {
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.zeros((2, 2, 3), dtype=np.float32),

View File

@@ -184,11 +184,26 @@ class Moge2OnnxTest(unittest.TestCase):
"metric_scale": np.array((metric_scale,), dtype=np.float32),
}
raw["mask"][0, 0, :4] = [0.0, 0.2, 0.7, 1.0]
result = self.moge.postprocess(raw)
np.testing.assert_allclose(result["depth"], depth * metric_scale, rtol=1e-3)
np.testing.assert_allclose(result["points"][..., 2], result["depth"], rtol=1e-5)
self.assertTrue(result["mask"].all())
np.testing.assert_array_equal(result["mask"], raw["mask"][0])
def test_postprocess_preserves_mask_for_nonpositive_depth(self):
mask = np.array([[[0.2, 0.7], [0.0, 1.0]]], dtype=np.float32)
raw = {
"points": np.zeros((1, 2, 2, 3), dtype=np.float32),
"normal": np.zeros((1, 2, 2, 3), dtype=np.float32),
"mask": mask,
"metric_scale": np.ones(1, dtype=np.float32),
}
with patch.object(self.moge, "recover_focal_shift", return_value=(1.0, -1.0)) as recover:
result = self.moge.postprocess(raw)
np.testing.assert_array_equal(recover.call_args.args[1], mask[0] > 0.5)
np.testing.assert_array_equal(result["mask"], mask[0])
self.assertTrue((result["depth"] < 0).all())
if __name__ == "__main__":

View File

@@ -0,0 +1,683 @@
"""Exercise automatic arrangement without changing geometry or publishing assets."""
import bpy
import numpy as np
import pytest
from tools.nodes.groups.image_plane import build_image_plane_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
from node_asset_test_support import CUTOUT_BUILDERS
from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes
from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links
from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions
@pytest.fixture(params=("plane", "depth_plane", "relief_plane", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"))
def node_group(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
if request.param == "plane":
return build_image_plane_group()
if request.param == "depth_plane":
return build_image_depth_plane_group(build_image_plane_group())
if request.param == "relief_plane":
return build_image_relief_plane_group(build_image_plane_group())
return CUTOUT_BUILDERS[request.param]()
def _source(socket):
while socket.node.bl_idname == "NodeReroute":
socket = socket.node.inputs[0].links[0].from_socket
node = socket.node
return ("INPUT" if node.bl_idname == "NodeGroupInput" else node.name, socket.identifier)
def _value(value):
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, bpy.types.ID):
return value.as_pointer()
return tuple(value)
def _program(node_group):
nodes = {}
for node in node_group.nodes:
if node.bl_idname in {"NodeReroute", "NodeGroupInput"}:
continue
settings = {
name: _value(getattr(node, name))
for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp",
"interpolation", "interpolation_type", "extension", "pattern_mode")
if hasattr(node, name)
}
inputs = [
(socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None,
[_source(link.from_socket) for link in sorted(
socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)])
for socket in node.inputs
]
paired = getattr(node, "paired_output", None)
nodes[node.name] = (node.bl_idname, settings, inputs, paired.name if paired else None)
interface = [
(item.identifier, item.name, item.in_out, item.socket_type,
_value(item.default_value) if hasattr(item, "default_value") else None)
for item in node_group.interface.items_tree if item.item_type == "SOCKET"
]
return nodes, interface
def _surface(node_group):
mesh = bpy.data.meshes.new("Layout Surface")
mesh.from_pydata(
[(x - 2, y - 1, 0) for y in range(3) for x in range(5)], [],
[(y * 5 + x, y * 5 + x + 1, (y + 1) * 5 + x + 1, (y + 1) * 5 + x)
for y in range(2) for x in range(4)],
)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
x, y, _ = mesh.vertices[loop.vertex_index].co
uv.data[loop.index].uv = ((x + 2) / 4, (y + 1) / 2)
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
for vertex, value in zip(mesh.vertices, profile.data):
value.value = 0.25 * (1 - abs(vertex.co.x) / 2) * (1 - abs(vertex.co.y))
mesh.materials.append(bpy.data.materials.new("Image"))
obj = bpy.data.objects.new("Layout Surface", mesh)
bpy.context.collection.objects.link(obj)
modifier = obj.modifiers.new("Surface", "NODES")
modifier.node_group = node_group
image = bpy.data.images.new("Layout Depth", width=16, height=8, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
yy, xx = np.mgrid[:8, :16]
pixels = np.ones((8, 16, 4), dtype=np.float32)
pixels[..., 0] = ((xx + 0.5) / 16 - 0.5) * 4
pixels[..., 1] = -((yy + 0.5) / 8 - 0.5) * 2
pixels[..., 2] = 2 + xx * 0.02
image.pixels.foreach_set(pixels.ravel())
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
"Uniform Scale": 1.0, "Reference Depth": 2.0, "Depth Scale": 0.8,
"Smooth": 1, "Normal Smooth": 2, "Split Threshold": 0.25}
mode = None
for item in node_group.interface.items_tree:
if item.item_type != "SOCKET" or item.in_out != "INPUT":
continue
if item.name in values:
modifier[item.identifier] = values[item.name]
elif item.name == "Mode":
mode = item.identifier
def evaluate():
results = []
for choice in range(2 if mode else 1):
if mode:
modifier[mode] = choice
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated = result.to_mesh()
try:
assert len(evaluated.vertices) > 0
results.append((np.array([vertex.co[:] for vertex in evaluated.vertices]),
[tuple(face.vertices) for face in evaluated.polygons]))
finally:
result.to_mesh_clear()
return results
return evaluate
def test_arrangement_preserves_each_asset_program(node_group):
program = _program(node_group)
evaluate = _surface(node_group)
geometry = evaluate()
arrange_nodes(node_group)
assert _program(node_group) == program
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
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
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
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]
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 all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name
def test_dimensions_include_unlinked_vector_controls_and_collapsed_sockets():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Dimensions", "GeometryNodeTree")
source = node_group.nodes.new("GeometryNodeInputPosition")
vector = node_group.nodes.new("ShaderNodeVectorMath")
vector.operation = "ADD"
expanded = estimate_node_dimensions(vector)
node_group.links.new(source.outputs[0], vector.inputs[0])
linked = estimate_node_dimensions(vector)
assert expanded.height - linked.height == 3 * RULES["row"]
vector.hide = True
collapsed = estimate_node_dimensions(vector)
assert collapsed.height < linked.height
assert all(0 < offset < collapsed.height for offset in collapsed.sockets.values())
def test_local_inputs_preserve_identifiers_with_duplicate_display_names():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Controls", "GeometryNodeTree")
first = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat")
second = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat")
first.default_value, second.default_value = 0.25, 0.75
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
controls = node_group.nodes.new("NodeGroupInput")
grid = node_group.nodes.new("GeometryNodeMeshGrid")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(controls.outputs[first.identifier], grid.inputs["Size X"])
node_group.links.new(controls.outputs[second.identifier], grid.inputs["Size Y"])
node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
@pytest.mark.parametrize("branch_lengths", [(1, 1), (1, 3), (3, 1), (1, 2, 3)])
def test_geometry_paths_order_join_branches_and_separate_their_shared_input(branch_lengths):
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Parallel Geometry", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
join = node_group.nodes.new("GeometryNodeJoinGeometry")
output = node_group.nodes.new("NodeGroupOutput")
branches = []
for length in branch_lengths:
geometry = source.outputs["Mesh"]
branch = []
for _ in range(length):
position = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(geometry, position.inputs["Geometry"])
geometry = position.outputs["Geometry"]
branch.append(position)
node_group.links.new(geometry, join.inputs["Geometry"])
branches.append(branch)
node_group.links.new(source.outputs["Mesh"], join.inputs["Geometry"])
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
geometry_parents, paths = geometry_paths(node_group)
assert set(geometry_parents) == {join, output, *(node for branch in branches for node in branch)}
assert [join, output] in paths
assert all(branch in paths for branch in branches)
assert source not in geometry_parents
assert sum(len(path) for path in paths) == len(geometry_parents)
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]
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}
field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"]
calculations = [field.node]
for _ in range(30):
operation = node_group.nodes.new("ShaderNodeVectorMath")
operation.operation = "ADD"
node_group.links.new(field, operation.inputs[0])
field = operation.outputs["Vector"]
calculations.append(operation)
node_group.links.new(field, branches[0][0].inputs["Offset"])
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
def test_geometry_used_for_field_sampling_stays_with_the_field_branch():
from tools.nodes.preview.export import _node_group_data
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Sampled Geometry", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
separate = node_group.nodes.new("GeometryNodeSeparateGeometry")
sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
position = node_group.nodes.new("GeometryNodeSetPosition")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(source.outputs["Mesh"], separate.inputs["Geometry"])
node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"])
node_group.links.new(sample.outputs["Value"], position.inputs["Position"])
node_group.links.new(source.outputs["Mesh"], position.inputs["Geometry"])
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
geometry_parents, paths = geometry_paths(node_group)
assert set(geometry_parents) == {source, position, output}
assert paths == [[source, position, output]]
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]}
assert roles[separate.name] == roles[sample.name] == "field"
assert roles[position.name] == "geometry"
node_group.links.new(separate.outputs["Selection"], output.inputs["Geometry"])
roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]}
assert roles[separate.name] == "geometry"
def test_shared_field_references_branch_near_their_consumers():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Shared Field", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeInputPosition")
geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
consumers = []
for index in range(8):
node = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(geometry, node.inputs["Geometry"])
if index in (0, 4, 7):
node_group.links.new(source.outputs["Position"], node.inputs["Offset"])
consumers.append(node)
geometry = node.outputs["Geometry"]
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(geometry, output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
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 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
for link in node_group.links
if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute")
@pytest.mark.parametrize("side", ["upper", "lower"])
def test_field_modules_reserve_lane_and_exit_clearance(side):
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Branch Rectangles", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
join = node_group.nodes.new("GeometryNodeJoinGeometry")
output = node_group.nodes.new("NodeGroupOutput")
branches = []
for _ in range(2):
geometry, branch = source.outputs["Mesh"], []
for _ in range(5):
node = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(geometry, node.inputs["Geometry"])
geometry = node.outputs["Geometry"]
branch.append(node)
branches.append(branch)
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)
lane = branches[0 if side == "upper" else -1]
heights = [node.location.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]
branch = [value.node]
for _ in range(8):
node = node_group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
node_group.links.new(value, node.inputs[0])
value = node.outputs["Vector"]
branch.append(node)
node_group.links.new(value, target.inputs["Offset"])
fields.append(branch)
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]
core, _ = geometry_paths(node_group)
core_left = min(node.location.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
else:
assert top <= lane[0].location.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)
if side == "lower":
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
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"]]
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
y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t**2*y2 + t**3*y2
left, right, top, bottom = other
assert not np.any((left < x) & (x < right) & (bottom < y) & (y < top))
def test_boolean_operands_follow_socket_order_and_center_the_junction():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Boolean Operands", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
comparisons = [node_group.nodes.new("FunctionNodeCompare") for _ in range(2)]
operation = node_group.nodes.new("FunctionNodeBooleanMath")
operation.operation = "OR"
grid = node_group.nodes.new("GeometryNodeMeshGrid")
output = node_group.nodes.new("NodeGroupOutput")
for source, socket in zip(comparisons, operation.inputs):
node_group.links.new(source.outputs[0], socket)
node_group.links.new(operation.outputs[0], grid.inputs["Size X"])
node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"])
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]
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)
def test_preview_is_read_only_and_escapes_node_names(tmp_path):
from tools.nodes.preview.export import write_node_preview
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = build_image_plane_group()
arrange_nodes(node_group)
node_group.nodes[0].name = '</script><script>alert("node")</script>'
program = _program(node_group)
positions = {node.name: tuple(node.location) 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("<!doctype html>")
assert '__NODE_DATA__' not in markup
assert '<script>alert("node")</script>' 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
def test_geometry_bypass_has_two_level_corners_next_to_processing():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Geometry Bypass", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
transform = node_group.nodes.new("GeometryNodeTransform")
switch = node_group.nodes.new("GeometryNodeSwitch")
switch.input_type = "GEOMETRY"
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"])
node_group.links.new(source.outputs["Mesh"], switch.inputs["False"])
node_group.links.new(transform.outputs["Geometry"], switch.inputs["True"])
node_group.links.new(switch.outputs["Output"], output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
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 _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):
from tools.nodes.preview.export import _node_group_data
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Zone Preview", "GeometryNodeTree")
source = node_group.nodes.new("GeometryNodeMeshGrid")
entry = node_group.nodes.new(f"GeometryNode{zone_type}Input")
end = node_group.nodes.new(f"GeometryNode{zone_type}Output")
entry.pair_with_output(end)
body = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"])
geometry = next(socket for socket in entry.outputs if socket.type == "GEOMETRY")
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)
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}
for index in zone["nodes"]:
node = data["nodes"][index]
assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"]
assert zone["y"] <= node["y"] and node["y"] + node["h"] <= zone["y"] + zone["h"]
def test_repeat_geometry_collector_stays_in_the_local_body():
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)
entry = node_group.nodes["Repeat Input"]
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
def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Shared Geometry References", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
shared = node_group.nodes.new("GeometryNodeMeshGrid")
geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
samplers = []
for index in range(10):
node = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(geometry, node.inputs["Geometry"])
geometry = node.outputs["Geometry"]
if index in (0, 5, 9):
sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
node_group.links.new(shared.outputs["Mesh"], sample.inputs["Geometry"])
node_group.links.new(sample.outputs["Value"], node.inputs["Offset"])
samplers.append(sample)
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(geometry, output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
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 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)
assert _program(node_group) == program
def test_reused_geometry_processing_keeps_its_upstream_result_path():
from tools.nodes.arrangement.flow import node_roles
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Reused Processing", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = node_group.nodes.new("GeometryNodeMeshGrid")
transform = node_group.nodes.new("GeometryNodeTransform")
position = node_group.nodes.new("GeometryNodeSetPosition")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"])
node_group.links.new(transform.outputs["Geometry"], position.inputs["Geometry"])
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
for name in ("Selection", "Offset"):
separate = node_group.nodes.new("GeometryNodeSeparateGeometry")
sample = node_group.nodes.new("GeometryNodeSampleIndex")
node_group.links.new(transform.outputs["Geometry"], separate.inputs["Geometry"])
node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"])
node_group.links.new(sample.outputs["Value"], position.inputs[name])
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 all(roles[node] == "field" for node in node_group.nodes
if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"})
def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input():
from tools.nodes.arrangement.flow import node_roles
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Public Prepared Geometry", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = node_group.nodes.new("GeometryNodeMeshGrid")
prepare = node_group.nodes.new("GeometryNodeTransform")
join = node_group.nodes.new("GeometryNodeJoinGeometry")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(grid.outputs["Mesh"], prepare.inputs["Geometry"])
branches = []
for _ in range(3):
node = node_group.nodes.new("GeometryNodeSetPosition")
node_group.links.new(prepare.outputs["Geometry"], node.inputs["Geometry"])
node_group.links.new(node.outputs["Geometry"], join.inputs["Geometry"])
branches.append(node)
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
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)
def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
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)
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
for node in node_group.nodes:
if node in core or node.bl_idname == "NodeReroute":
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
for link in node_group.links:
if link.from_socket.type != "GEOMETRY":
continue
start, target = link_endpoints(link, positions, sizes)
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}:
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
def test_expression_skip_links_have_passages_between_intermediate_nodes():
from tools.nodes.arrangement.connections import link_endpoints
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Expression Passages", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = node_group.nodes.new("GeometryNodeMeshGrid")
position = node_group.nodes.new("GeometryNodeSetPosition")
output = node_group.nodes.new("NodeGroupOutput")
seed = node_group.nodes.new("GeometryNodeInputIndex")
previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0]
fields = [seed, previous.node]
for _ in range(7):
operation = node_group.nodes.new("ShaderNodeMath")
operation.operation = "ADD"
node_group.links.new(previous, operation.inputs[0])
node_group.links.new(seed.outputs[0], operation.inputs[1])
previous = operation.outputs[0]
fields.append(operation)
node_group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"])
node_group.links.new(previous, position.inputs["Offset"])
node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
positions = {node: tuple(node.location) 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:
continue
(x1, y1), (x2, y2) = link_endpoints(link, positions, sizes)
bend = max(40, abs(x2 - x1) * 0.42)
for t in np.linspace(0, 1, 100):
x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t*t*(x2-bend) + t**3*x2
y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t*t*y2 + t**3*y2
for node in fields:
if node in {link.from_node, link.to_node}:
continue
left, top = positions[node]
assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name

View File

@@ -1,29 +1,22 @@
import sys
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from node_asset_test_support import validate_node_structure
from node_layout import validate_node_layout
from anyimage.common.material import (
DEPTH_MATERIAL_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
)
from anyimage.common.node import load_node_group, node_asset_path
from anyimage.operators.cutout_tool.shape import (
CUTOUT_NODE_GROUP_NAMES,
)
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane"
def input_names(group):
return [
@@ -33,10 +26,11 @@ def input_names(group):
]
def main():
def test_saved_asset_contracts():
bpy.ops.wm.read_factory_settings(use_empty=True)
image_plane = load_node_group("O Image Plane")
depth_plane = load_node_group(DEPTH_PLANE_NODE_GROUP_NAME)
relief_plane = load_node_group(RELIEF_PLANE_NODE_GROUP_NAME)
image_layer = load_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME)
depth_layer = load_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME)
shadeless = load_node_group("O Shadeless")
@@ -46,6 +40,7 @@ def main():
expected_names = {
"O Image Plane",
DEPTH_PLANE_NODE_GROUP_NAME,
RELIEF_PLANE_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
DEPTH_MATERIAL_NODE_GROUP_NAME,
"O Shadeless",
@@ -62,55 +57,55 @@ def main():
"Subdivide",
"Thickness",
]
assert depth_plane.bl_idname == "GeometryNodeTree"
assert depth_plane.is_modifier
assert input_names(depth_plane) == [
"Geometry",
"Mode",
"Subdivide",
"Thickness",
"Thickness",
"Depth Scale",
"Reference Depth",
"Normal Smooth",
"Uniform Scale",
"Depth Image",
for group, normal_controls in ((depth_plane, ["Normal Smooth", "Smooth Weight", "Validity Threshold"]), (relief_plane, [])):
assert group.bl_idname == "GeometryNodeTree" and group.is_modifier
assert input_names(group) == [
"Geometry", "Subdivide", "Thickness", "Depth Scale",
*(["Smooth", "Valid Only"] if group == depth_plane else []), "Reference Depth",
*normal_controls, "Uniform Scale", "Depth Image",
]
thickness_inputs = [
item for item in depth_plane.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Thickness"
]
assert len({item.identifier for item in thickness_inputs}) == 2
assert all(item.default_value == 0 and item.subtype == "DISTANCE" for item in thickness_inputs)
depth_panels = [
item for item in depth_plane.interface.items_tree if item.item_type == "PANEL"
]
assert [panel.name for panel in depth_panels] == ["Options", "Data"]
assert [item.name for item in depth_panels[0].interface_items] == [
"Reference Depth", "Normal Smooth"
]
assert [item.name for item in depth_panels[1].interface_items] == [
"Uniform Scale",
"Depth Image",
]
assert all(panel.default_closed for panel in depth_panels)
assert all(item.hide_in_modifier for item in depth_panels[1].interface_items)
depth_subdivide = next(
item
for item in depth_plane.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Subdivide"
)
assert depth_subdivide.default_value == 6
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
assert inputs["Thickness"].default_value == 0
assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE"
assert inputs["Subdivide"].default_value == 6
assert all(item.structure_type == "SINGLE" for item in inputs.values())
panels = [item for item in group.interface.items_tree if item.item_type == "PANEL"]
assert [panel.name for panel in panels] == ["Options", "Data"]
assert [item.name for item in panels[0].interface_items] == ["Reference Depth", *normal_controls]
assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"]
assert all(panel.default_closed for panel in panels)
assert all(item.hide_in_modifier for item in panels[1].interface_items)
assert image_layer.bl_idname == "ShaderNodeTree"
assert input_names(image_layer) == [
"Color",
"Alpha",
"Normal",
"Bump Scale",
"Alpha Fix",
"Normal Scale",
"Object Space",
"Bump Scale",
]
assert depth_layer.bl_idname == "ShaderNodeTree"
adjustment = next(
item for item in image_layer.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
and item.name == "Alpha Fix"
)
assert adjustment.default_value == 0.0
assert (adjustment.min_value, adjustment.max_value, adjustment.subtype) == (0, 1, "FACTOR")
image_output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT")
alpha_curve = image_output.inputs["Alpha"].links[0].from_node
assert alpha_curve.bl_idname == "ShaderNodeFloatCurve"
assert alpha_curve.inputs["Value"].links[0].from_socket.name == "Alpha"
assert alpha_curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix"
panels = [item for item in image_layer.interface.items_tree if item.item_type == "PANEL"]
assert len(panels) == 1 and panels[0].name == "Options" and panels[0].default_closed
assert [item.name for item in panels[0].interface_items] == ["Alpha Fix", "Normal Scale", "Object Space"]
alpha_points = alpha_curve.mapping.curves[0].points
assert len(alpha_points) == 4
for point, expected in zip(alpha_points, ((0, 0), (0.8, 0), (0.925, 0.925), (1, 1))):
assert all(abs(actual - value) < 1e-6 for actual, value in zip(point.location, expected))
assert input_names(depth_layer) == [
"Color",
"Alpha",
@@ -130,6 +125,7 @@ def main():
public_groups = {
image_plane,
depth_plane,
relief_plane,
image_layer,
depth_layer,
shadeless,
@@ -138,7 +134,7 @@ def main():
loaded = {*public_groups, *cutout_groups.values()}
for group in loaded:
if group.bl_idname == "GeometryNodeTree":
validate_node_layout(group)
validate_node_structure(group)
assert all(group.use_fake_user for group in public_groups)
assert all(group.users > 0 for group in cutout_groups.values())
if bpy.app.version >= (5, 0, 0):
@@ -147,7 +143,3 @@ def main():
f"Validated Blender {bpy.app.version_string}: "
+ ", ".join(sorted(group.name for group in loaded))
)
if __name__ == "__main__":
main()

View File

@@ -1,29 +1,23 @@
"""Validate direct Cutout assets and their evaluated defaults."""
from pathlib import Path
import sys
from collections import Counter
import bpy
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(
0,
str(next((ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from node_layout import validate_node_layout
from node_asset_test_support import validate_node_structure
def main():
def test_saved_cutout_defaults():
bpy.ops.wm.read_factory_settings(use_empty=True)
groups = {shape: load_cutout_node_group(shape) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")}
assert groups["FLAT"] == groups["SOLID"]
assert len(set(groups.values())) == 3
for group in set(groups.values()):
validate_node_layout(group)
validate_node_structure(group)
assert group.is_modifier and group.get("anyimage_asset_revision") == 11
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"}
@@ -87,7 +81,3 @@ def main():
finally:
evaluated.to_mesh_clear()
print("Validated three Cutout assets, four presets, both thickness modes and closed defaults")
if __name__ == "__main__":
main()

View File

@@ -5,7 +5,7 @@ import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = PROJECT_ROOT / "tools" / "build_node.py"
SCRIPT_PATH = PROJECT_ROOT / "tools" / "nodes" / "cli.py"
def load_launcher():
@@ -82,11 +82,8 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp
launcher.main()
scripts = [Path(command[-1]) for command in calls[:-1]]
assert [path.name for path in scripts] == [
"asset_library.py", "asset_validation.py",
"image_plane_validation.py", "cutout_validation.py",
]
assert all(path.is_file() for path in scripts)
assert all(command[1:4] == ("--factory-startup", "--background", "--python") for command in calls[:-1])
assert len(calls) == 3
assert "runpy.run_module('tools.nodes.build', run_name='__main__')" in calls[0][-1]
assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1]
assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1])
assert calls[-1][1:] == ("-m", "pytest")

View File

@@ -1,20 +1,10 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.common.object import (
modifier_input_identifier,
set_modifier_input,
)
from anyimage.operators.convert_to_plane.image_plane import (
add_image_plane_processing,
create_image_plane_mesh,
@@ -27,7 +17,7 @@ def evaluated_mesh(obj):
return evaluated, evaluated.to_mesh()
def main():
def test_saved_image_plane_evaluation():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = create_image_plane_mesh("Image Plane", (-1.0, 1.0, -0.5, 0.5))
assert len(mesh.polygons) == 1
@@ -114,7 +104,3 @@ def main():
if bpy.app.version >= (5, 0, 0):
assert group.library is not None
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,4 @@
import unittest
import sys
from pathlib import Path
@@ -8,11 +7,11 @@ SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage"
class NodeAssetsTest(unittest.TestCase):
def test_geometry_assets_pass_layout_overlap_checks(self):
def test_geometry_assets_preserve_node_structure(self):
import bpy
sys.path.insert(0, str(PROJECT_ROOT / "tools" / "node_assets"))
from node_layout import validate_node_layout
from node_asset_test_support import validate_node_structure
from tools.nodes.arrangement.arrange import arrange_nodes
bpy.ops.wm.read_factory_settings(use_empty=True)
with bpy.data.libraries.load(
@@ -24,9 +23,15 @@ class NodeAssetsTest(unittest.TestCase):
for group in target.node_groups
if group.bl_idname == "GeometryNodeTree"
]
self.assertEqual(len(groups), 5)
self.assertEqual(len(groups), 6)
for group in groups:
validate_node_layout(group)
validate_node_structure(group)
positions = {node.name: tuple(node.location) 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):
self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}")
def test_production_code_does_not_create_node_groups(self):
offenders = []

View File

@@ -16,6 +16,9 @@ from anyimage.operators.clipboard_image import actions as clipboard_actions
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from image_object_test_support import _depth_image, _depth_metadata, _evaluated_positions, _inputs
from tools.nodes.groups.image_plane import build_image_plane_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
@@ -76,6 +79,7 @@ def test_clipboard_plane_uses_the_mesh_material_slot():
def test_converted_planes_center_the_origin_without_moving_world_corners():
bpy.ops.wm.read_factory_settings(use_empty=True)
build_image_depth_plane_group(build_image_plane_group())
def create_source(name):
image = bpy.data.images.new(name, width=200, height=100)
@@ -146,6 +150,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
(2, 2),
1.0,
),
"DEPTH",
)
assert_centered(depth_plane, depth_bounds, depth_matrix)
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
@@ -167,8 +172,9 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
)
def test_depth_plane_builds_a_fixed_base_terrain_block():
def test_relief_plane_builds_a_fixed_base_terrain_block():
bpy.ops.wm.read_factory_settings(use_empty=True)
build_image_relief_plane_group(build_image_plane_group())
image = bpy.data.images.new("Terrain", width=4, height=4)
source = bpy.data.objects.new("Terrain", None)
source.empty_display_type = "IMAGE"
@@ -191,7 +197,10 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
(4, 4),
1.0,
),
"RELIEF",
)
assert result["anyimage_mesh_shape"] == "RELIEF_PLANE"
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
modifier = result.modifiers[0]
group = modifier.node_group

View File

@@ -55,7 +55,8 @@ class PlaneOperatorsTest(BlenderTestCase):
prepare.assert_not_called()
def test_depth_plane_starts_one_job_without_a_format_selection(self):
def test_depth_and_relief_start_jobs_with_their_plane_type(self):
for plane_type, operator_class in (("DEPTH", self.convert_to_plane.ConvertToDepthPlane), ("RELIEF", self.convert_to_plane.ConvertToReliefPlane)):
module = self.convert_to_plane
source = SimpleNamespace(name="Source", data=object())
context = SimpleNamespace(object=source, scene=object())
@@ -69,12 +70,14 @@ class PlaneOperatorsTest(BlenderTestCase):
patch.object(module, "configured_max_ai_input_size", return_value=1024),
patch.object(module.bpy, "ops", SimpleNamespace(anyimage=SimpleNamespace(generate_depth_plane=generate)), create=True),
):
self.assertEqual(module.ConvertToDepthPlane.execute(operator, context), {"FINISHED"})
self.assertEqual(operator_class.execute(operator, context), {"FINISHED"})
self.assertNotIn("depth_mode", generate.call_args.kwargs)
self.assertEqual(generate.call_args.kwargs["source_object_name"], "Source")
self.assertEqual(generate.call_args.kwargs["plane_type"], plane_type)
def test_depth_plane_result_connects_the_tangent_normal_material(self):
def test_depth_and_relief_results_use_their_normal_space(self):
for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")):
normal_key = f"{normal_space.lower()}_normal"
module = self.convert_to_plane_object
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
color_image = SimpleNamespace(name="", users=0)
@@ -85,13 +88,13 @@ class PlaneOperatorsTest(BlenderTestCase):
files = {
"depth_metadata": Path("depth.json"),
"depth": Path("depth.exr"),
"tangent_normal": Path("tangent-normal.png"),
normal_key: Path(f"{normal_key}.png"),
}
result = SimpleNamespace(
directory=Path("result"),
file=lambda key: files[key],
)
operator = SimpleNamespace(source_object_name="Source", mesh_detail=6, generate_depth=True)
operator = SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type=plane_type)
context = SimpleNamespace(scene=object())
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
@@ -118,15 +121,13 @@ class PlaneOperatorsTest(BlenderTestCase):
module.create_depth_plane_from_result(context, result, operator)
load_depth.assert_called_once_with(files["depth"], source, metadata)
ops.ed.undo_push.assert_called_once_with(message="Convert to Depth Plane")
load_normal.assert_called_once_with(files["tangent_normal"], source)
ops.ed.undo_push.assert_called_once_with(message=f"Convert to {plane_type.title()} Plane")
load_normal.assert_called_once_with(files[normal_key], source)
create_material.assert_called_once_with(
source,
source.data,
color_image,
0.0,
normal_image=normal_image,
normal_space="TANGENT",
ior=1.2,
normal_space=normal_space,
scene=context.scene,
)
create_object.assert_called_once_with(
@@ -136,6 +137,7 @@ class PlaneOperatorsTest(BlenderTestCase):
material,
depth_image,
metadata,
plane_type,
)
@@ -149,7 +151,7 @@ class PlaneOperatorsTest(BlenderTestCase):
files = {
"depth_metadata": Path("depth.json"),
"depth": Path("depth.exr"),
"tangent_normal": Path("tangent-normal.png"),
"object_normal": Path("object-normal.png"),
}
result = SimpleNamespace(
directory=Path("result"),
@@ -184,7 +186,7 @@ class PlaneOperatorsTest(BlenderTestCase):
module.create_depth_plane_from_result(
SimpleNamespace(scene=object()),
result,
SimpleNamespace(source_object_name="Source", mesh_detail=6, generate_depth=True),
SimpleNamespace(source_object_name="Source", mesh_detail=6, plane_type="DEPTH"),
)
remove_material.assert_called_once_with(material, do_unlink=True)

View File

@@ -404,6 +404,7 @@ class PreferencesTest(BlenderTestCase):
def test_depth_plane_request_includes_the_global_ai_input_limit(self):
operator = self.convert_to_plane.GenerateDepthPlane()
operator.plane_type = "DEPTH"
operator.input_path = __file__
operator.analysis_input_path = __file__
operator.source_object_name = "Source"

View File

@@ -36,7 +36,9 @@ class ServerJobsTest(unittest.TestCase):
)
def test_depth_plane_job_requests_depth_texture_and_tangent_normal_together(self):
def test_plane_job_requests_depth_and_matching_normal_space(self):
for plane_type, normal_space in (("DEPTH", "OBJECT"), ("RELIEF", "TANGENT")):
normal_key = f"{normal_space.lower()}_normal"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input.png"
@@ -44,8 +46,8 @@ class ServerJobsTest(unittest.TestCase):
context = FakeJobContext(root / "result", "generate-depth-plane-geometry")
depth_texture = context.directory / "depth.exr"
metadata = context.directory / "depth.json"
tangent_normal = context.directory / "tangent-normal.png"
parameters = {"input": str(input_path), "generate_depth": True}
normal_texture = context.directory / f"{normal_space.lower()}-normal.png"
parameters = {"input": str(input_path), "plane_type": plane_type}
with (
patch.object(
self.depth_plane_job,
@@ -59,7 +61,7 @@ class ServerJobsTest(unittest.TestCase):
return_value={
"depth": depth_texture,
"depth_metadata": metadata,
"tangent_normal": tangent_normal,
normal_key: normal_texture,
},
) as generate,
):
@@ -70,7 +72,7 @@ class ServerJobsTest(unittest.TestCase):
{
"depth": "depth.exr",
"depth_metadata": "depth.json",
"tangent_normal": "tangent-normal.png",
normal_key: f"{normal_space.lower()}-normal.png",
},
)
generate.assert_called_once_with(
@@ -78,7 +80,7 @@ class ServerJobsTest(unittest.TestCase):
parameters,
input_path,
generate_depth=True,
normal_mode="TANGENT",
normal_mode=normal_space,
)

View File

@@ -9,7 +9,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_sync_models():
path = PROJECT_ROOT / "tools" / "sync_models.py"
path = PROJECT_ROOT / "tools" / "models" / "cli.py"
spec = importlib.util.spec_from_file_location("sync_models", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
@@ -87,7 +87,7 @@ class SyncModelsTests(unittest.TestCase):
self.assertEqual(command[-2], model.remote)
def test_verify_file_checks_size_and_sha256(self):
path = PROJECT_ROOT / "tools" / "sync_models.py"
path = PROJECT_ROOT / "tools" / "models" / "cli.py"
size = path.stat().st_size
digest = self.sync.file_sha256(path)
self.assertTrue(self.sync.verify_file(path, size, digest))

View File

@@ -15,8 +15,8 @@ class ToolCommandsTests(unittest.TestCase):
project["scripts"],
{
"pack": "tools.pack:main",
"build_node": "tools.build_node:main",
"sync_models": "tools.sync_models:main",
"build_node": "tools.nodes.cli:main",
"sync_models": "tools.models.cli:main",
"docs": "tools.docs:main",
},
)

1
tools/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Model preparation, export, and upload tools."""

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from urllib.request import Request, urlopen
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = Path(__file__).resolve().parents[2]
MODELS_DIR = PROJECT_ROOT / "models"
MODEL_CACHE_DIR = PROJECT_ROOT / ".model-cache"
SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage"
@@ -169,7 +169,7 @@ def prepare_source(build):
def build_model(model):
from tools.model_exports import export_model
from tools.models.exports import export_model
source = prepare_source(model.build)
destination = model.destination
@@ -195,7 +195,7 @@ def prepare_model(model):
return
if model.key == "PISA_SR":
subprocess.run(
["uv", "run", "--script", str(PROJECT_ROOT / "tools/pisa_sr_export.py")],
["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py"))],
cwd=PROJECT_ROOT, check=True,
)
if not verify_file(model.destination, model.size, model.sha256):

View File

@@ -40,7 +40,7 @@ def finalize_assets(destination):
"source_commit": SOURCE_COMMIT,
})
onnx.save(model, path)
for path in (Path(__file__).parent / "model_licenses/pisa-sr").glob("*.txt"):
for path in (Path(__file__).parent / "licenses/pisa-sr").glob("*.txt"):
shutil.copyfile(path, destination / path.name)

View File

@@ -1,47 +0,0 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from cutout import (
CUTOUT_NODE_GROUP_NAMES,
build_cutout_group,
)
from depth_plane import build_depth_plane_group
from material_layers import build_material_layer_group
from image_plane import build_image_plane_group
from shadeless import build_shadeless_group
OUTPUT_PATH = (
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "assets"
/ "O_AnyImage.blend"
)
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
image_plane = build_image_plane_group()
build_depth_plane_group(image_plane)
build_material_layer_group()
build_material_layer_group(depth_plane=True)
build_shadeless_group()
for shape in CUTOUT_NODE_GROUP_NAMES:
build_cutout_group(shape)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)
if __name__ == "__main__":
main()

View File

@@ -1,252 +0,0 @@
"""Build the three directly editable Cutout modifier assets."""
import bpy
from types import SimpleNamespace
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_NODE_GROUP_NAMES, CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from cutout_shapes import (
_socket, _float_input, _bool_input, _vector_input, _group_input, _math,
_island_cleanup, _build_cutout, _build_balloon, _build_depth_balloon,
_build_depth_surface, _shade_output,
)
from cutout_shape_layouts import LAYOUTS
from depth_cutout_layout import POSITIONS, INPUT_REGIONS, layout_geometry_references
from cutout_layout import layout_stage, finish_layout
from cutout_volume import build_volume_fields, shift_stage
from cutout_rounding import build_volume_rounding
from cutout_inflation import build_front_fields
from cutout_shell import build_front_normals, build_shell_position
from node_graph import expand_smooth
def _menu(group, control, names, kind, location):
selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None)
if selector is None:
selector = group.nodes.new("GeometryNodeMenuSwitch")
selector.data_type = "BOOLEAN"
selector.location = (location[0] - 240, location[1] + 450)
for item, name in zip(selector.enum_items, names):
item.name = name
selector.inputs[names[0]].default_value = False
selector.inputs[names[1]].default_value = True
local = _group_input(group.nodes, (location[0] - 480, location[1] + 450), {control})
group.links.new(local.outputs[control], selector.inputs["Menu"])
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = kind
node.location = location
group.links.new(selector.outputs[0], node.inputs["Switch"])
return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs)
def _read(group, name, location):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT"
node.inputs["Name"].default_value = name
node.location = location
return node.outputs["Attribute"]
def _store(group, geometry, name, value, location, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT"
node.location = location
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
if isinstance(value, (int, float)):
node.inputs["Value"].default_value = value
else:
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def _depth_volume(group, stage):
"""Insert the volume field ahead of identity-based shell construction."""
links = group.links
projection = stage["Set Position"].outputs["Geometry"]
shell_input = stage["Store Named Attribute.005"].inputs["Geometry"]
for node in group.nodes:
if node.location.x >= 4150:
node.location.x += 5100
projection = build_front_normals(group, projection)
volume = build_volume_fields(group, projection, (4150, 0))
shared = build_front_fields(group, volume.node.inputs["Geometry"].links[0].from_socket)
links.new(shared, volume.node.inputs["Geometry"])
uniform_control = _group_input(group.nodes, (8210, -330), {"Uniform Thickness"})
uniform = _store(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"], (8490, -330))
volume.node.location.x = uniform.node.location.x
choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (9000, 0))
links.new(volume, choose.inputs["Balloon"])
links.new(uniform, choose.inputs["Uniform"])
links.new(choose.outputs[0], shell_input)
thickness = _read(group, "o_balloon_surface_thickness", (12000, -1100))
links.new(thickness, stage["Math.018"].inputs[1])
control = _group_input(group.nodes, (13300, 700), {"Thickness", "Uniform Thickness"})
amount = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (13600, 500))
links.new(control.outputs["Thickness"], amount.inputs["Balloon"])
links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"])
links.new(amount.outputs[0], stage["Math.019"].inputs[0])
identity_thickness = _read(group, "o_balloon_surface_thickness", (11100, 1500))
positive = _math(group.nodes, "GREATER_THAN", (11340, 1500))
positive.inputs[1].default_value = 1e-12
links.new(identity_thickness, positive.inputs[0])
layer = _math(group.nodes, "MULTIPLY", (11570, 1200))
links.new(positive.outputs[0], layer.inputs[0])
links.new(stage["Separate XYZ.006"].outputs["Z"], layer.inputs[1])
links.new(layer.outputs[0], stage["Combine XYZ.002"].inputs["Z"])
for node in group.nodes:
if node.location.x >= 12070:
node.location.x += 400
for name in ("Flip Faces", "Flip Faces.001"):
source = stage[name].outputs["Mesh"]
outgoing = [link for link in links if link.from_socket == source]
targets = [link.to_socket for link in outgoing]
for link in outgoing:
links.remove(link)
masked = _store(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, (12100, stage[name].location.y), domain="FACE")
for target in targets:
links.new(masked, target)
for node in group.nodes:
if node.location.x >= 8490:
node.location.x += 3200
volume.node.location.x = 8290
rounded = build_volume_rounding(group, volume, (8600, 0))
rounded.node.location.x = uniform.node.location.x + 40
links.new(rounded, choose.inputs["Balloon"])
restored = stage["Vector Math.004"].outputs[0]
targets = [link.to_socket for link in restored.links]
uniform_mode = choose.outputs[0].node.inputs["Switch"].links[0].from_socket
oriented = build_shell_position(group, restored, uniform_mode, (14100, -2100))
for target in targets:
links.new(oriented, target)
def build_cutout_group(shape):
name = CUTOUT_NODE_GROUP_NAMES[shape]
existing = bpy.data.node_groups.get(name)
if existing is not None:
return existing
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
depth = shape in {"DEPTH_SOLID", "DEPTH_BALLOON"}
dual = shape != "DEPTH_BALLOON"
_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
if dual:
_socket(group, "Mode", "INPUT", "NodeSocketMenu")
_float_input(group, "Thickness", 1.0).description = "Balloon profile multiplier; zero returns the sheet"
if dual:
_float_input(group, "Uniform Thickness", 0.0, subtype="DISTANCE").description = "Distance used only by Uniform thickness mode"
if depth:
_float_input(group, "Depth Scale", 1.0)
if shape == "DEPTH_BALLOON":
_bool_input(group, "Double Sided", True)
iterations = _socket(group, "Smooth", "INPUT", "NodeSocketInt")
iterations.default_value = 0
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)
if shape == "DEPTH_SOLID":
_float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options)
if depth:
_float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
if shape == "DEPTH_SOLID":
_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 = _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)
if shape == "DEPTH_BALLOON":
_vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION", parent=options)
data = group.interface.new_panel(name="Data", default_closed=True)
_socket(group, "Inward Axis", "INPUT", "NodeSocketMenu", data).hide_in_modifier = True
if depth:
_float_input(group, "Uniform Scale", 1.0, parent=data).hide_in_modifier = True
_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
controls = _group_input(group.nodes, (-5000, 0), set())
previous = set(group.nodes)
if shape == "DEPTH_SOLID":
geometry = _build_depth_surface(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Split Threshold"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"])
stage = layout_stage(group, previous, POSITIONS, INPUT_REGIONS)
_depth_volume(group, stage)
end = 18000
elif shape == "DEPTH_BALLOON":
geometry = _build_depth_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Double Sided"], controls.outputs["Uniform Scale"], controls.outputs["Depth Direction"], controls.outputs["Depth Image"], controls.outputs["Reference Depth"])
layout_stage(group, previous, *LAYOUTS["DEPTH_BALLOON"])
end = 3000
else:
balloon = _build_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"])
layout_stage(group, previous, *LAYOUTS["BALLOON"])
previous = set(group.nodes)
uniform = _build_cutout(group, controls.outputs["Geometry"], controls.outputs["Uniform Thickness"])
layout_stage(group, previous, *LAYOUTS["UNIFORM"], offset=(0, -2400))
balloon.node.location.x = uniform.node.location.x
choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (3200, 0))
group.links.new(balloon, choose.inputs["Balloon"])
group.links.new(uniform, choose.inputs["Uniform"])
geometry, end = choose.outputs[0], 3700
planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"]
base = _group_input(group.nodes, (-4800, 0), {"Geometry", "Cleanup Threshold"})
area = _math(group.nodes, "MULTIPLY", (-4520, 500))
area.inputs[1].default_value = 0.01
group.links.new(base.outputs["Cleanup Threshold"], area.inputs[0])
cleaned = _island_cleanup(group, base.outputs["Geometry"], area.outputs[0], (-4520, 0))
if shape == "DEPTH_SOLID":
cleaned = _store(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, (-3540, 0), domain="FACE")
for target in planar_inputs:
group.links.new(cleaned, target)
previous = set(group.nodes)
geometry = expand_smooth(group, geometry, iterations_name="Smooth", weight_name="Smooth Weight")
shift_stage(group, previous, (end, -550))
end += 3450
if shape == "DEPTH_SOLID":
remove = group.nodes.new("GeometryNodeRemoveAttribute")
remove.pattern_mode = "WILDCARD"
remove.inputs["Name"].default_value = "o_balloon_surface_*"
remove.location = (end, 0)
group.links.new(geometry, remove.inputs["Geometry"])
geometry, end = remove.outputs[0], end + 300
else:
profile = _read(group, "o_balloon", (end, 800))
scale = profile
if dual:
amount = _group_input(group.nodes, (end, 1100), {"Thickness"})
enabled = _math(group.nodes, "GREATER_THAN", (end + 250, 1100))
enabled.inputs[1].default_value = 1e-6
group.links.new(amount.outputs["Thickness"], enabled.inputs[0])
inflated = group.nodes.new("GeometryNodeSwitch")
inflated.input_type = "FLOAT"
inflated.location = (end + 480, 800)
inflated.inputs["False"].default_value = 1
group.links.new(enabled.outputs[0], inflated.inputs["Switch"])
group.links.new(profile, inflated.inputs["True"])
mode = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (end + 750, 800))
mode.inputs["Uniform"].default_value = 1
group.links.new(inflated.outputs[0], mode.inputs["Balloon"])
scale = mode.outputs[0]
geometry = _store(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale, (end + 1050, 0))
end += 1350
orient = group.nodes.new("GeometryNodeTransform")
orient.location = (end, -350)
orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs["Geometry"])
axis = _menu(group, "Inward Axis", ("-Z", "+X"), "GEOMETRY", (end + 300, 0))
group.links.new(geometry, axis.inputs["-Z"])
group.links.new(orient.outputs[0], axis.inputs["+X"])
_shade_output(group, axis.outputs[0], (end + 600, 0))
if shape == "DEPTH_SOLID":
layout_geometry_references(group, stage)
finish_layout(group)
for item in group.interface.items_tree:
if item.item_type != "SOCKET" or item.in_out != "INPUT":
continue
if item.name == "Mode":
item.default_value = "Balloon"
elif item.name == "Inward Axis":
item.default_value = "-Z"
elif item.name == "Uniform Thickness":
item.name = "Thickness"
return group

View File

@@ -1,142 +0,0 @@
"""Estimate existing front volume and add continuous normal-directed inflation."""
from cutout_volume import node
def _math(group, operation, a, b, location):
result = node(group, "ShaderNodeMath", location)
result.operation = operation
for index, value in enumerate((a, b)):
if hasattr(value, "node"):
group.links.new(value, result.inputs[index])
else:
result.inputs[index].default_value = value
return result.outputs[0]
def _read(group, name, location, kind="FLOAT"):
result = node(group, "GeometryNodeInputNamedAttribute", location)
result.data_type = kind
result.inputs["Name"].default_value = "o_balloon_surface_" + name
return result.outputs["Attribute"]
def _store(group, geometry, name, value, location, kind="FLOAT"):
result = node(group, "GeometryNodeStoreNamedAttribute", location)
result.data_type, result.domain = kind, "POINT"
result.inputs["Name"].default_value = "o_balloon_surface_" + name
group.links.new(geometry, result.inputs["Geometry"])
group.links.new(value, result.inputs["Value"])
return result.outputs["Geometry"]
def build_front_fields(group, geometry):
"""Fit structural amplitude over neighbouring points before creating the shell."""
links = group.links
def math(operation, a, b, x, y):
return _math(group, operation, a, b, (4800 + x, -2500 + y))
def blur(value, x, y, weight=None):
result = node(group, "GeometryNodeBlurAttribute", (4800 + x, -2500 + y))
result.data_type = "FLOAT"
result.inputs["Iterations"].default_value = 256
links.new(value, result.inputs["Value"])
if weight is not None:
links.new(weight, result.inputs["Weight"])
return result.outputs[0]
profile_node = node(group, "GeometryNodeInputNamedAttribute", (4800, -2500))
profile_node.data_type = "FLOAT"
profile_node.inputs["Name"].default_value = "o_balloon"
profile = profile_node.outputs["Attribute"]
rim = _read(group, "rim", (4800, -3050))
interior = math("SUBTRACT", 1, rim, 240, -550)
position = node(group, "GeometryNodeInputPosition", (4800, -1900))
axes = node(group, "ShaderNodeSeparateXYZ", (5040, -1900))
links.new(position.outputs[0], axes.inputs[0])
depth = axes.outputs["Z"]
depth_slow = blur(depth, 480, 600, interior)
profile_slow = blur(profile, 480, 0, interior)
depth_bump = math("SUBTRACT", depth, depth_slow, 720, 600)
profile_bump = math("SUBTRACT", profile, profile_slow, 720, 0)
numerator = math("MULTIPLY", depth_bump, profile_bump, 960, 600)
denominator = math("MULTIPLY", profile_bump, profile_bump, 960, 0)
numerator = blur(numerator, 1200, 600)
denominator = blur(denominator, 1200, 0)
denominator = math("MAXIMUM", denominator, 1e-10, 1440, 0)
structure = math("DIVIDE", numerator, denominator, 1680, 600)
structure = math("MAXIMUM", structure, 0, 1920, 600)
carrier = _store(group, geometry, "structure", structure, (7300, 0))
normal = _read(group, "front_normal", (7100, -600), "FLOAT_VECTOR")
controls = node(group, "NodeGroupInput", (6860, -1100))
# 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, (7100, -1100))
smooth = node(group, "GeometryNodeBlurAttribute", (7340, -600))
smooth.data_type = "FLOAT_VECTOR"
links.new(normal, smooth.inputs["Value"])
links.new(spread, smooth.inputs["Iterations"])
normalize = node(group, "ShaderNodeVectorMath", (7580, -600))
normalize.operation = "NORMALIZE"
links.new(smooth.outputs[0], normalize.inputs[0])
carrier = _store(group, carrier, "inflation_normal", normalize.outputs[0], (7770, 0), "FLOAT_VECTOR")
return carrier
def build_inflation_position(group, position, distance, uniform):
"""Grow both modes continuously while leaving the rear cap in place."""
previous = set(group.nodes)
links = group.links
def math(operation, a, b, x, y):
return _math(group, operation, a, b, (x, y))
structure = _read(group, "structure", (14000, -4300))
control = node(group, "NodeGroupInput", (13760, -5000))
a = math("MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"], 14240, -5000)
# A shifted cubic blend preserves continuous growth and height order.
shifted = math("ADD", a, 0.75, 14480, -5000)
quadratic = math("MULTIPLY", a, shifted, 14720, -5000)
quadratic = math("ADD", quadratic, 0.1875, 14960, -5000)
q = math("MULTIPLY", a, quadratic, 15200, -5000)
k = math("ADD", structure, 0.25, 14240, -4300)
square = math("MULTIPLY", k, k, 14480, -4300)
cube = math("MULTIPLY", square, k, 14720, -4300)
total = math("ADD", cube, q, 14960, -4300)
root = math("POWER", total, 1 / 3, 15200, -4300)
root_square = math("MULTIPLY", root, root, 15440, -4000)
product = math("MULTIPLY", root, k, 15440, -4300)
denominator = math("ADD", root_square, product, 15680, -4300)
denominator = math("ADD", denominator, square, 15920, -4300)
response = math("DIVIDE", q, denominator, 15920, -4800)
profile = node(group, "GeometryNodeInputNamedAttribute", (15920, -3700))
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
balloon = math("MULTIPLY", profile.outputs["Attribute"], response, 16160, -4250)
uniform_control = node(group, "NodeGroupInput", (15920, -5100))
uniform_amount = math("MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"], 16160, -5100)
selected = node(group, "GeometryNodeSwitch", (16400, -4550))
selected.input_type = "FLOAT"
links.new(uniform, selected.inputs["Switch"])
links.new(balloon, selected.inputs["False"])
links.new(uniform_amount, selected.inputs["True"])
displacement = selected.outputs[0]
height = _read(group, "thickness", (14000, -5500))
layer = math("DIVIDE", distance, height, 14240, -5500)
layer = math("ADD", 1, layer, 14480, -5500)
layer = math("MAXIMUM", layer, 0, 14720, -5500)
layer = math("MINIMUM", layer, 1, 14960, -5500)
displacement = math("MULTIPLY", displacement, layer, 16640, -4550)
normal = _read(group, "inflation_normal", (16640, -4000), "FLOAT_VECTOR")
offset = node(group, "ShaderNodeVectorMath", (16880, -4550))
offset.operation = "SCALE"
links.new(normal, offset.inputs[0])
links.new(displacement, offset.inputs[3])
result = node(group, "ShaderNodeVectorMath", (17120, -4550))
result.operation = "ADD"
links.new(position, result.inputs[0])
links.new(offset.outputs[0], result.inputs[1])
for item in set(group.nodes) - previous:
item.location.y = -3550 + (item.location.y + 3700) * 0.7
return result.outputs[0]

View File

@@ -1,53 +0,0 @@
"""Place independent Cutout stages using authored local layouts."""
from collections import Counter
def route_geometry(group, source, positions):
"""Guide a shared forward geometry reference through authored waypoints."""
for position in positions:
reroute = group.nodes.new("NodeReroute")
reroute.location = position
group.links.new(source, reroute.inputs[0])
source = reroute.outputs[0]
return source
def layout_stage(group, previous, positions, input_regions, offset=(0, 0)):
"""Map stage-local default names without renaming Blender nodes."""
counts, stage = Counter(), {}
for node in group.nodes:
if node in previous or node.bl_idname == "NodeGroupInput":
continue
base, dot, suffix = node.name.rpartition(".")
base = base if dot and suffix.isdigit() else node.name
index = counts[base]
counts[base] += 1
stage[base if index == 0 else f"{base}.{index:03d}"] = node
assert set(stage) == set(positions) - {"Set Shade Smooth", "Group Output"}, (group.name, set(stage) ^ set(positions))
for name, node in stage.items():
x, y = positions[name]
node.location = (x + offset[0], y + offset[1])
inputs = [(link.to_socket, link.from_socket.name) for link in group.links if link.to_node in stage.values() and link.from_node.bl_idname == "NodeGroupInput"]
for targets, (x, y) in input_regions:
sockets = [(socket, name) for socket, name in inputs if socket.node in [stage[key] for key in targets if key in stage]]
if not sockets:
continue
control = group.nodes.new("NodeGroupInput")
control.location = (x + offset[0], y + offset[1])
for socket, name in sockets:
group.links.new(control.outputs[name], socket)
return stage
def finish_layout(group):
"""Expose linked fields and retain readable constants."""
for node in group.nodes:
node.select = False
node.width = 200 if "Attribute" in node.bl_idname else 160
if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}:
node.hide = all(socket.is_linked for socket in node.inputs if socket.enabled and not socket.hide)
for socket in node.outputs:
socket.hide = not socket.is_linked
for node in list(group.nodes):
if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs):
group.nodes.remove(node)

View File

@@ -1,101 +0,0 @@
"""Round depth volume around the smoothed center-surface normals."""
from cutout_volume import node, shift_stage
def build_volume_rounding(group, volume, offset):
previous = set(group.nodes)
links = group.links
def math(operation, a, b, location):
result = node(group, "ShaderNodeMath", location)
result.operation = operation
for index, value in enumerate((a, b)):
if isinstance(value, (int, float)):
result.inputs[index].default_value = value
else:
links.new(value, result.inputs[index])
return result.outputs[0]
def read(name, location, kind="FLOAT"):
result = node(group, "GeometryNodeInputNamedAttribute", location)
result.data_type = kind
result.inputs["Name"].default_value = "o_balloon_surface_" + name
return result.outputs["Attribute"]
def store(geometry, name, value, location, kind="FLOAT"):
result = node(group, "GeometryNodeStoreNamedAttribute", location)
result.data_type, result.domain = kind, "POINT"
result.inputs["Name"].default_value = "o_balloon_surface_" + name
links.new(geometry, result.inputs["Geometry"])
links.new(value, result.inputs["Value"])
return result.outputs["Geometry"]
control = node(group, "NodeGroupInput", (0, 1250))
profile = node(group, "GeometryNodeInputNamedAttribute", (0, 850))
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
balloon = profile.outputs["Attribute"]
maximum = node(group, "GeometryNodeAttributeStatistic", (240, 850))
maximum.data_type, maximum.domain = "FLOAT", "POINT"
links.new(volume, maximum.inputs["Geometry"])
links.new(balloon, maximum.inputs["Attribute"])
limit = math("MAXIMUM", maximum.outputs["Max"], 1e-8, (480, 850))
falloff = node(group, "ShaderNodeMapRange", (720, 850))
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("MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"], (960, 850))
center = read("center", (0, 1900))
position = node(group, "GeometryNodeInputPosition", (0, 2250))
axes = node(group, "ShaderNodeSeparateXYZ", (240, 2250))
links.new(position.outputs[0], axes.inputs[0])
target = math("ADD", center, balloon, (480, 1900))
delta = math("SUBTRACT", target, axes.outputs["Z"], (720, 1900))
delta = math("MULTIPLY", delta, weight, (960, 1900))
enabled = math("MINIMUM", control.outputs["Thickness"], 1, (480, 1300))
enabled = math("MAXIMUM", enabled, 0, (720, 1300))
delta = math("MULTIPLY", delta, enabled, (1200, 1900))
carrier = store(volume, "edge_weight", weight, (1200, 0))
carrier = store(carrier, "front_delta", delta, (1440, 0))
rear = math("MULTIPLY", balloon, weight, (1200, 850))
rear = math("MULTIPLY", rear, control.outputs["Thickness"], (1440, 850))
thickness = read("thickness", (1440, 1250))
total = math("ADD", thickness, delta, (1680, 1250))
total = math("ADD", total, rear, (1920, 1250))
carrier = store(carrier, "thickness", total, (1920, 0))
# Evaluate normals on a separate center surface with identical point indices.
center_position = node(group, "ShaderNodeCombineXYZ", (480, -550))
for axis in ("X", "Y"):
links.new(axes.outputs[axis], center_position.inputs[axis])
links.new(center, center_position.inputs["Z"])
surface = node(group, "GeometryNodeSetPosition", (720, -550))
links.new(volume, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"])
normal = node(group, "GeometryNodeInputNormal", (720, -1000))
blur = node(group, "GeometryNodeBlurAttribute", (960, -1000))
blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"])
local = node(group, "NodeGroupInput", (720, -1300))
links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"])
normalize = node(group, "ShaderNodeVectorMath", (1200, -1000))
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
index = node(group, "GeometryNodeInputIndex", (1200, -1350))
sample = node(group, "GeometryNodeSampleIndex", (1440, -550))
sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT"
links.new(surface.outputs[0], sample.inputs["Geometry"])
links.new(normalize.outputs[0], sample.inputs["Value"])
links.new(index.outputs[0], sample.inputs["Index"])
carrier = store(carrier, "normal", sample.outputs[0], (2160, 0), "FLOAT_VECTOR")
captured = read("front_delta", (2160, 850))
displacement = node(group, "ShaderNodeCombineXYZ", (2400, 850))
links.new(captured, displacement.inputs["Z"])
front = node(group, "GeometryNodeSetPosition", (2640, 0))
links.new(carrier, front.inputs["Geometry"])
links.new(displacement.outputs[0], front.inputs["Offset"])
shift_stage(group, previous, offset)
return front.outputs[0]

View File

@@ -1,141 +0,0 @@
"""Authored local positions for Cutout shape stages."""
LAYOUTS = {'BALLOON': ({'Named Attribute': (-950, 440),
'Attribute Statistic': (-730, 1020),
'Math': (-500, 1020),
'Math.001': (-280, 1020),
'Map Range': (-50, 1020),
'Math.002': (-500, 440),
'Math.003': (-500, 240),
'Math.004': (-280, 240),
'Extrude Mesh': (0, 0),
'Switch': (220, 440),
'Position': (20, 240),
'Separate XYZ': (220, 240),
'Combine XYZ': (420, 340),
'Set Position': (690, 0),
'Separate Geometry': (910, 0),
'Flip Faces': (1140, 0),
'Combine XYZ.001': (910, 530),
'Set Position.001': (1140, 300),
'Combine XYZ.002': (690, -550),
'Set Position.002': (910, -300),
'Flip Faces.001': (1140, -300),
'Join Geometry': (1430, 0),
'Merge by Distance': (1650, 0),
'Math.005': (2100, 310),
'Switch.001': (2100, 0),
'Set Shade Smooth': (2320, 0),
'Group Output': (2550, 0)},
((('Extrude Mesh',), (-220, 0)),
(('Attribute Statistic',), (-950, 1020)),
(('Math.002', 'Math.003'), (-730, 240)),
(('Set Position.001',), (910, 300)),
(('Set Position.002',), (690, -300)),
(('Math.005',), (1870, 340)),
(('Switch.001',), (1870, -240)))),
'UNIFORM': ({'Math': (-90, 680),
'Math.001': (140, 680),
'Edge Vertices': (-1630, -340),
'Vector Math': (-1410, -340),
'Edge Neighbors': (-1630, -560),
'Math.002': (-1410, -560),
'Attribute Statistic': (-1190, -340),
'Math.003': (-960, -340),
'Math.004': (-740, -340),
'Float to Integer': (-520, -340),
'Integer Math': (-290, -340),
'Integer Math.001': (-70, -340),
'Math.005': (190, -580),
'Math.006': (410, -580),
'Combine XYZ': (1630, 480),
'Set Position': (1860, 260),
'Repeat Input': (410, 0),
'Extrude Mesh': (640, 0),
'Separate Geometry': (860, 0),
'Join Geometry': (1080, -180),
'Repeat Output': (1310, 0),
'Combine XYZ.001': (1410, 270),
'Set Position.001': (1630, 0),
'Flip Faces': (1860, 0),
'Combine XYZ.002': (1410, -470),
'Set Position.002': (1630, -260),
'Flip Faces.001': (1860, -260),
'Join Geometry.001': (2150, 0),
'Merge by Distance': (2370, 0),
'Math.007': (2820, 290),
'Switch': (2820, 0),
'Set Shade Smooth': (3040, 0),
'Group Output': (3270, 0)},
((('Math',), (-310, 680)),
(('Attribute Statistic',), (-1410, -170)),
(('Math.004',), (-960, -610)),
(('Math.005',), (-30, -580)),
(('Repeat Input',), (190, 0)),
(('Set Position',), (1630, 260)),
(('Set Position.002',), (1410, -260)),
(('Math.007',), (2600, 310)),
(('Switch',), (2600, -240)))),
'DEPTH_BALLOON': ({'Named Attribute': (-1120, 650),
'Attribute Statistic': (-890, 650),
'Math': (-670, 650),
'Math.001': (-450, 650),
'Map Range': (-220, 650),
'Named Attribute.001': (-1790, -440),
'Image Texture': (-1570, -440),
'Separate XYZ': (-1340, -440),
'Math.002': (-1120, -440),
'Vector Math': (-2240, -950),
'Separate XYZ.001': (-2010, -950),
'Position': (-2240, -1260),
'Separate XYZ.002': (-2010, -1260),
'Math.003': (-1790, -950),
'Math.004': (-1790, -1160),
'Math.005': (-1570, -950),
'Math.006': (-1340, -950),
'Math.007': (-1120, -950),
'Math.008': (-890, -440),
'Math.009': (-670, -440),
'Math.010': (-450, -440),
'Math.011': (-670, 310),
'Math.012': (-670, 70),
'Math.013': (-450, 70),
'Mix': (20, 440),
'Math.014': (240, 710),
'Switch': (460, 710),
'Extrude Mesh': (0, 0),
'Switch.001': (460, 310),
'Position.001': (280, 530),
'Separate XYZ.003': (460, 530),
'Combine XYZ': (660, 430),
'Set Position': (930, 0),
'Separate Geometry': (1150, 0),
'Flip Faces': (1600, 0),
'Combine XYZ.001': (1380, 570),
'Set Position.001': (1600, 330),
'Combine XYZ.002': (1150, -570),
'Set Position.002': (1380, -320),
'Flip Faces.001': (1600, -320),
'Join Geometry': (1870, 0),
'Merge by Distance': (2320, 0),
'Position.002': (1600, 1120),
'Separate XYZ.004': (1820, 1120),
'Combine XYZ.003': (2050, 950),
'Set Position.003': (2320, 680),
'Math.015': (2560, -270),
'Switch.002': (2560, 0),
'Set Shade Smooth': (2790, 0),
'Group Output': (3010, 0)},
((('Extrude Mesh',), (-220, 0)),
(('Attribute Statistic',), (-1120, 850)),
(('Image Texture',), (-1790, -270)),
(('Math.002',), (-1340, -750)),
(('Vector Math',), (-2460, -950)),
(('Math.007',), (-1340, -1160)),
(('Math.010',), (-670, -650)),
(('Math.011', 'Math.012'), (-890, 310)),
(('Switch',), (240, 920)),
(('Set Position.001',), (1380, 330)),
(('Set Position.002',), (1150, -320)),
(('Set Position.003',), (2100, 610)),
(('Math.015',), (2340, -270))))}

View File

@@ -1,571 +0,0 @@
"""Construct the planar and depth-based Cutout shape stages."""
from depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, thicken_depth_surface
from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME
def _socket(group, name, in_out, socket_type, parent=None):
socket = group.interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
parent=parent,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def _group_input(nodes, location, visible_names):
node = nodes.new("NodeGroupInput")
node.location = location
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def _float_input(
group,
name,
default,
minimum=0.0,
maximum=None,
subtype=None,
parent=None,
):
socket = _socket(group, name, "INPUT", "NodeSocketFloat", parent)
socket.default_value = default
socket.min_value = minimum
if maximum is not None:
socket.max_value = maximum
if subtype is not None:
socket.subtype = subtype
return socket
def _bool_input(group, name, default=False, parent=None):
socket = _socket(group, name, "INPUT", "NodeSocketBool", parent)
socket.default_value = default
return socket
def _vector_input(group, name, default, subtype=None, parent=None):
socket = _socket(group, name, "INPUT", "NodeSocketVector", parent)
socket.default_value = default
if subtype is not None:
socket.subtype = subtype
return socket
def _math(nodes, operation, location):
node = nodes.new("ShaderNodeMath")
node.operation = operation
node.location = location
return node
def _island_cleanup(group, geometry, cleanup, location):
nodes = group.nodes
links = group.links
x, y = location
mesh_island = nodes.new("GeometryNodeInputMeshIsland")
mesh_island.location = (x, y + 250.0)
face_area = nodes.new("GeometryNodeInputMeshFaceArea")
face_area.location = (x, y + 100.0)
island_area = nodes.new("GeometryNodeAccumulateField")
island_area.data_type = "FLOAT"
island_area.domain = "FACE"
island_area.location = (x + 220.0, y + 200.0)
links.new(face_area.outputs["Area"], island_area.inputs["Value"])
links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"])
tiny = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0))
links.new(island_area.outputs["Total"], tiny.inputs[0])
links.new(cleanup, tiny.inputs[1])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain = "FACE"
delete.location = (x + 700.0, y)
links.new(geometry, delete.inputs["Geometry"])
links.new(tiny.outputs[0], delete.inputs["Selection"])
return delete.outputs["Geometry"]
def _profile_field(group, geometry, location):
nodes = group.nodes
links = group.links
x, y = location
profile = nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME
profile.location = (x, y + 250.0)
profile_max = nodes.new("GeometryNodeAttributeStatistic")
profile_max.data_type = "FLOAT"
profile_max.domain = "POINT"
profile_max.location = (x + 220.0, y)
links.new(geometry, profile_max.inputs["Geometry"])
links.new(profile.outputs["Attribute"], profile_max.inputs["Attribute"])
safe_max = _math(nodes, "MAXIMUM", (x + 450.0, y))
safe_max.inputs[1].default_value = 1e-6
links.new(profile_max.outputs["Max"], safe_max.inputs[0])
normalized = _math(nodes, "DIVIDE", (x + 450.0, y + 250.0))
links.new(profile.outputs["Attribute"], normalized.inputs[0])
links.new(safe_max.outputs[0], normalized.inputs[1])
smooth = nodes.new("ShaderNodeMapRange")
smooth.data_type = "FLOAT"
smooth.interpolation_type = "SMOOTHERSTEP"
smooth.clamp = True
smooth.inputs["From Min"].default_value = 0.0
smooth.inputs["From Max"].default_value = 1.0
smooth.inputs["To Min"].default_value = 0.0
smooth.inputs["To Max"].default_value = 1.0
smooth.location = (x + 650.0, y + 250.0)
links.new(normalized.outputs[0], smooth.inputs["Value"])
return profile.outputs["Attribute"], smooth.outputs["Result"]
def _extrude_layers(group, geometry, location):
node = group.nodes.new("GeometryNodeExtrudeMesh")
node.mode = "FACES"
node.inputs["Individual"].default_value = False
node.inputs["Offset"].default_value = (0.0, 0.0, 1.0)
# Keep distinct front and back vertices before overriding their Z positions.
node.inputs["Offset Scale"].default_value = 1.0
node.location = location
group.links.new(geometry, node.inputs["Mesh"])
return node
def _offset_geometry_z(group, geometry, z_value, location):
nodes = group.nodes
links = group.links
offset = nodes.new("ShaderNodeCombineXYZ")
offset.location = location
links.new(z_value, offset.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (location[0] + 200.0, location[1])
links.new(geometry, set_position.inputs["Geometry"])
links.new(offset.outputs["Vector"], set_position.inputs["Offset"])
return set_position.outputs["Geometry"]
def _set_layer_z(group, geometry, extrude, front_z, back_z, location):
nodes = group.nodes
links = group.links
choose_z = nodes.new("GeometryNodeSwitch")
choose_z.input_type = "FLOAT"
choose_z.location = (location[0], location[1] - 200.0)
links.new(extrude.outputs["Top"], choose_z.inputs["Switch"])
links.new(front_z, choose_z.inputs["False"])
links.new(back_z, choose_z.inputs["True"])
position = nodes.new("GeometryNodeInputPosition")
position.location = (location[0], location[1] + 150.0)
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (location[0] + 200.0, location[1] + 150.0)
links.new(position.outputs["Position"], separate.inputs["Vector"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (location[0] + 400.0, location[1])
links.new(separate.outputs["X"], combine.inputs["X"])
links.new(separate.outputs["Y"], combine.inputs["Y"])
links.new(choose_z.outputs["Output"], combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (location[0] + 600.0, location[1])
links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
side = nodes.new("GeometryNodeSeparateGeometry")
side.domain = "FACE"
side.location = (location[0] + 850.0, location[1] + 200.0)
links.new(set_position.outputs["Geometry"], side.inputs["Geometry"])
links.new(extrude.outputs["Side"], side.inputs["Selection"])
flip_side = nodes.new("GeometryNodeFlipFaces")
flip_side.location = (location[0] + 850.0, location[1] + 400.0)
links.new(side.outputs["Selection"], flip_side.inputs["Mesh"])
front = _offset_geometry_z(
group, geometry, front_z, (location[0], location[1] - 450.0)
)
back = _offset_geometry_z(
group, geometry, back_z, (location[0], location[1] - 700.0)
)
flip_back = nodes.new("GeometryNodeFlipFaces")
flip_back.location = (location[0] + 500.0, location[1] - 700.0)
links.new(back, flip_back.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
join.location = (location[0] + 1100.0, location[1])
links.new(front, join.inputs["Geometry"])
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 1e-6
merge.location = (location[0] + 1300.0, location[1])
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _set_segmented_layer_z(
group,
geometry,
front_z,
back_z,
thickness,
location,
):
nodes = group.nodes
links = group.links
x, y = location
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
edge_vertices.location = (x, y + 850.0)
edge_length = nodes.new("ShaderNodeVectorMath")
edge_length.operation = "DISTANCE"
edge_length.location = (x + 220.0, y + 850.0)
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
edge_neighbors.location = (x, y + 650.0)
boundary = _math(nodes, "COMPARE", (x + 220.0, y + 650.0))
boundary.inputs[1].default_value = 1.0
boundary.inputs[2].default_value = 0.1
links.new(edge_neighbors.outputs["Face Count"], boundary.inputs[0])
statistics = nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type = "FLOAT"
statistics.domain = "EDGE"
statistics.location = (x + 450.0, y + 750.0)
links.new(geometry, statistics.inputs["Geometry"])
links.new(boundary.outputs[0], statistics.inputs["Selection"])
links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"])
safe_edge = _math(nodes, "MAXIMUM", (x + 680.0, y + 750.0))
safe_edge.inputs[1].default_value = 1e-6
links.new(statistics.outputs["Median"], safe_edge.inputs[0])
segment_ratio = _math(nodes, "DIVIDE", (x + 900.0, y + 750.0))
links.new(thickness, segment_ratio.inputs[0])
links.new(safe_edge.outputs[0], segment_ratio.inputs[1])
segment_count = nodes.new("FunctionNodeFloatToInt")
segment_count.rounding_mode = "CEILING"
segment_count.location = (x + 1120.0, y + 750.0)
links.new(segment_ratio.outputs[0], segment_count.inputs["Float"])
at_least_one = nodes.new("FunctionNodeIntegerMath")
at_least_one.operation = "MAXIMUM"
at_least_one.inputs[1].default_value = 1
at_least_one.location = (x + 1340.0, y + 750.0)
links.new(segment_count.outputs["Integer"], at_least_one.inputs[0])
segment_limit = nodes.new("FunctionNodeIntegerMath")
segment_limit.operation = "MINIMUM"
segment_limit.inputs[1].default_value = 128
segment_limit.location = (x + 1560.0, y + 750.0)
links.new(at_least_one.outputs[0], segment_limit.inputs[0])
step = _math(nodes, "DIVIDE", (x + 1560.0, y + 500.0))
links.new(thickness, step.inputs[0])
links.new(segment_limit.outputs[0], step.inputs[1])
front = _offset_geometry_z(group, geometry, front_z, (x, y + 100.0))
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_output.location = (x + 2380.0, y + 100.0)
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
repeat_output.repeat_items.new("GEOMETRY", "Sides")
repeat_input.location = (x + 1560.0, y + 100.0)
links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"])
links.new(geometry, repeat_input.inputs["Geometry"])
negative_step = _math(nodes, "MULTIPLY", (x + 1760.0, y - 100.0))
negative_step.inputs[1].default_value = -1.0
links.new(step.outputs[0], negative_step.inputs[0])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
extrude.inputs["Selection"].default_value = True
extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0)
extrude.location = (x + 1960.0, y + 100.0)
links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"])
links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"])
split_extrude = nodes.new("GeometryNodeSeparateGeometry")
split_extrude.domain = "FACE"
split_extrude.location = (x + 2160.0, y + 100.0)
links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"])
links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"])
join_sides = nodes.new("GeometryNodeJoinGeometry")
join_sides.location = (x + 2160.0, y - 150.0)
links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"])
links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"])
links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"])
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
positioned_side = _offset_geometry_z(
group,
repeat_output.outputs["Sides"],
front_z,
(x + 2450.0, y + 350.0),
)
flip_side = nodes.new("GeometryNodeFlipFaces")
flip_side.location = (x + 2850.0, y + 350.0)
links.new(positioned_side, flip_side.inputs["Mesh"])
back = _offset_geometry_z(group, geometry, back_z, (x + 2450.0, y - 400.0))
flip_back = nodes.new("GeometryNodeFlipFaces")
flip_back.location = (x + 2850.0, y - 400.0)
links.new(back, flip_back.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
join.location = (x + 3050.0, y + 50.0)
links.new(front, join.inputs["Geometry"])
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 1e-6
merge.location = (x + 3250.0, y + 50.0)
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _set_geometry_z(group, geometry, z_value, location):
nodes = group.nodes
links = group.links
position = nodes.new("GeometryNodeInputPosition")
position.location = location
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (location[0] + 200.0, location[1])
links.new(position.outputs["Position"], separate.inputs["Vector"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (location[0] + 400.0, location[1])
links.new(separate.outputs["X"], combine.inputs["X"])
links.new(separate.outputs["Y"], combine.inputs["Y"])
links.new(z_value, combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (location[0] + 600.0, location[1])
links.new(geometry, set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
return set_position.outputs["Geometry"]
def _remove_zero_thickness(
group,
zero_geometry,
shaped_geometry,
thickness,
location,
):
nodes = group.nodes
links = group.links
zero = _math(nodes, "LESS_THAN", (location[0], location[1] - 200.0))
zero.inputs[1].default_value = 1e-6
links.new(thickness, zero.inputs[0])
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
choose.location = location
links.new(zero.outputs[0], choose.inputs["Switch"])
links.new(shaped_geometry, choose.inputs["False"])
links.new(zero_geometry, choose.inputs["True"])
return choose.outputs["Output"]
def _position_field(group, depth_image, location):
nodes = group.nodes
links = group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
uv.location = location
position_texture = nodes.new("GeometryNodeImageTexture")
position_texture.interpolation = "Linear"
position_texture.extension = "EXTEND"
position_texture.location = (location[0] + 220.0, location[1])
links.new(depth_image, position_texture.inputs["Image"])
links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"])
return position_texture.outputs["Color"]
def _plane_depth(group, direction, reference_depth, location):
nodes = group.nodes
links = group.links
normal = nodes.new("ShaderNodeVectorMath")
normal.operation = "NORMALIZE"
normal.location = location
links.new(direction, normal.inputs[0])
normal_components = nodes.new("ShaderNodeSeparateXYZ")
normal_components.location = (location[0] + 220.0, location[1])
links.new(normal.outputs["Vector"], normal_components.inputs["Vector"])
position = nodes.new("GeometryNodeInputPosition")
position.location = (location[0], location[1] + 450.0)
position_components = nodes.new("ShaderNodeSeparateXYZ")
position_components.location = (location[0] + 220.0, location[1] + 450.0)
links.new(position.outputs["Position"], position_components.inputs["Vector"])
x_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 850.0))
links.new(position_components.outputs["X"], x_term.inputs[0])
links.new(normal_components.outputs["X"], x_term.inputs[1])
y_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 350.0))
links.new(position_components.outputs["Y"], y_term.inputs[0])
links.new(normal_components.outputs["Y"], y_term.inputs[1])
point_dot = _math(nodes, "ADD", (location[0] + 660.0, location[1] + 400.0))
links.new(x_term.outputs[0], point_dot.inputs[0])
links.new(y_term.outputs[0], point_dot.inputs[1])
divide = _math(nodes, "DIVIDE", (location[0] + 880.0, location[1] + 200.0))
links.new(point_dot.outputs[0], divide.inputs[0])
links.new(normal_components.outputs["Z"], divide.inputs[1])
plane = _math(nodes, "ADD", (location[0] + 1100.0, location[1] + 200.0))
links.new(reference_depth, plane.inputs[0])
links.new(divide.outputs[0], plane.inputs[1])
return plane.outputs[0]
def _shade_output(group, geometry, location):
shade = group.nodes.new("GeometryNodeSetShadeSmooth")
shade.domain = "FACE"
shade.inputs["Shade Smooth"].default_value = True
shade.location = location
output = group.nodes.new("NodeGroupOutput")
output.location = (location[0] + 250.0, location[1])
group.links.new(geometry, shade.inputs["Geometry"])
group.links.new(shade.outputs["Geometry"], output.inputs["Geometry"])
def _build_cutout(group, geometry, thickness):
nodes = group.nodes
links = group.links
half = _math(nodes, "MULTIPLY", (-200.0, 50.0))
half.inputs[1].default_value = 0.5
links.new(thickness, half.inputs[0])
back = _math(nodes, "MULTIPLY", (20.0, 0.0))
back.inputs[1].default_value = -1.0
links.new(half.outputs[0], back.inputs[0])
shaped = _set_segmented_layer_z(
group,
geometry,
half.outputs[0],
back.outputs[0],
thickness,
(250.0, 300.0),
)
return _remove_zero_thickness(group, geometry, shaped, thickness, (3750.0, 300.0))
def _build_balloon(group, geometry, thickness):
nodes = group.nodes
links = group.links
profile, _profile_factor = _profile_field(group, geometry, (-200.0, 0.0))
front_scale = _math(nodes, "MULTIPLY", (2600.0, 500.0))
links.new(profile, front_scale.inputs[0])
links.new(thickness, front_scale.inputs[1])
back = _math(nodes, "MULTIPLY", (2600.0, 300.0))
links.new(profile, back.inputs[0])
links.new(thickness, back.inputs[1])
negative_back = _math(nodes, "MULTIPLY", (2800.0, 300.0))
negative_back.inputs[1].default_value = -1.0
links.new(back.outputs[0], negative_back.inputs[0])
extrude = _extrude_layers(group, geometry, (2400.0, 750.0))
shaped = _set_layer_z(
group,
geometry,
extrude,
front_scale.outputs[0],
negative_back.outputs[0],
(2850.0, 650.0),
)
return _remove_zero_thickness(group, geometry, shaped, thickness, (4400.0, 650.0))
def _build_depth_balloon(
group,
geometry,
thickness,
depth_scale,
double_sided,
uniform_scale,
depth_direction,
depth_image,
depth_reference,
):
nodes = group.nodes
links = group.links
profile, profile_factor = _profile_field(group, geometry, (-200.0, 250.0))
camera_position = _position_field(group, depth_image, (200.0, -250.0))
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
separate_camera.location = (650.0, -250.0)
links.new(camera_position, separate_camera.inputs["Vector"])
metric_depth = _math(nodes, "MULTIPLY", (900.0, -250.0))
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
links.new(uniform_scale, metric_depth.inputs[1])
plane_depth = _plane_depth(
group,
depth_direction,
depth_reference,
(600.0, -850.0),
)
corrected_depth = _math(nodes, "SUBTRACT", (1750.0, -250.0))
links.new(plane_depth, corrected_depth.inputs[0])
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
nonnegative_depth = _math(nodes, "MAXIMUM", (1950.0, -250.0))
nonnegative_depth.inputs[1].default_value = 0.0
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
scaled_depth = _math(nodes, "MULTIPLY", (2150.0, -250.0))
links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0])
links.new(depth_scale, scaled_depth.inputs[1])
front_depth = scaled_depth.outputs[0]
base_front = _math(nodes, "MULTIPLY", (2400.0, 500.0))
links.new(profile, base_front.inputs[0])
links.new(thickness, base_front.inputs[1])
base_back = _math(nodes, "MULTIPLY", (2600.0, 300.0))
links.new(profile, base_back.inputs[0])
links.new(thickness, base_back.inputs[1])
negate_back = _math(nodes, "MULTIPLY", (2800.0, 300.0))
negate_back.inputs[1].default_value = -1.0
links.new(base_back.outputs[0], negate_back.inputs[0])
front_mix = nodes.new("ShaderNodeMix")
front_mix.data_type = "FLOAT"
front_mix.clamp_factor = True
front_mix.location = (2600.0, 50.0)
links.new(profile_factor, front_mix.inputs["Factor"])
links.new(base_front.outputs[0], front_mix.inputs["A"])
links.new(front_depth, front_mix.inputs["B"])
mirrored_front = _math(nodes, "MULTIPLY", (2850.0, -150.0))
mirrored_front.inputs[1].default_value = -1.0
links.new(front_mix.outputs["Result"], mirrored_front.inputs[0])
back_choice = nodes.new("GeometryNodeSwitch")
back_choice.input_type = "FLOAT"
back_choice.location = (3050.0, 100.0)
links.new(double_sided, back_choice.inputs["Switch"])
links.new(negate_back.outputs[0], back_choice.inputs["False"])
links.new(mirrored_front.outputs[0], back_choice.inputs["True"])
extrude = _extrude_layers(group, geometry, (2400.0, 750.0))
shaped = _set_layer_z(
group,
geometry,
extrude,
front_mix.outputs["Result"],
back_choice.outputs["Output"],
(3300.0, 650.0),
)
front_only = _set_geometry_z(
group, geometry, front_mix.outputs["Result"], (3500.0, 100.0)
)
return _remove_zero_thickness(group, front_only, shaped, thickness, (4850.0, 600.0))
def _build_depth_surface(
group,
geometry,
thickness,
depth_scale,
split,
uniform_scale,
reference_depth,
depth_image,
):
nodes = group.nodes
links = group.links
geometry = split_depth_surface(
group, geometry, depth_image, split, uniform_scale
)
original_camera = _position_field(group, depth_image, (-200.0, -200.0))
geometry, camera_position = store_surface_camera(
group, geometry, original_camera, split
)
geometry = project_depth_surface(
group, geometry, camera_position, uniform_scale, reference_depth, depth_scale
)
shaped = thicken_depth_surface(group, geometry, thickness)
geometry = _remove_zero_thickness(
group, geometry, shaped, thickness, (11800.0, 400.0)
)
return remove_surface_attributes(group, geometry)

View File

@@ -1,122 +0,0 @@
"""Continue a front-normal thin shell into the rounded depth volume."""
from cutout_inflation import build_inflation_position
from cutout_volume import node, shift_stage
def build_front_normals(group, geometry):
links = group.links
normal = node(group, "GeometryNodeInputNormal", (3400, -650))
control = node(group, "NodeGroupInput", (3400, -950))
blur = node(group, "GeometryNodeBlurAttribute", (3640, -650))
blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"])
links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"])
normalize = node(group, "ShaderNodeVectorMath", (3880, -650))
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
store = node(group, "GeometryNodeStoreNamedAttribute", (4110, 0))
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
store.inputs["Name"].default_value = "o_balloon_surface_front_normal"
links.new(geometry, store.inputs["Geometry"])
links.new(normalize.outputs[0], store.inputs["Value"])
return store.outputs[0]
def build_shell_position(group, position, uniform, offset):
previous = set(group.nodes)
links = group.links
def connect(value, target):
if hasattr(value, "node"):
links.new(value, target)
else:
target.default_value = value
def math(operation, a, b, location):
result = node(group, "ShaderNodeMath", location)
result.operation = operation
connect(a, result.inputs[0])
connect(b, result.inputs[1])
return result.outputs[0]
def vector(operation, a, b, location):
result = node(group, "ShaderNodeVectorMath", location)
result.operation = operation
connect(a, result.inputs[0])
connect(b, result.inputs[3 if operation == "SCALE" else 1])
return result.outputs[0]
def read(name, location, kind="FLOAT"):
result = node(group, "GeometryNodeInputNamedAttribute", location)
result.data_type = kind
result.inputs["Name"].default_value = name
return result.outputs["Attribute"]
def z(value, location):
result = node(group, "ShaderNodeSeparateXYZ", location)
links.new(value, result.inputs[0])
return result.outputs["Z"]
# Uniform and the thin end of Balloon share the actual front normal.
front = read("o_depth_surface_position", (0, 0), "FLOAT_VECTOR")
front_z = z(front, (240, 0))
raw_z = z(position, (240, 250))
distance = math("SUBTRACT", raw_z, front_z, (480, 250))
normal = read("o_balloon_surface_front_normal", (480, 550), "FLOAT_VECTOR")
thin_offset = vector("SCALE", normal, distance, (720, 550))
uniform_position = vector("ADD", front, thin_offset, (3120, 550))
# 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 = node(group, "NodeGroupInput", (0, -400))
clamp = node(group, "ShaderNodeClamp", (240, -400))
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0]
center = read("o_balloon_surface_center", (0, -750))
delta = read("o_balloon_surface_front_delta", (480, -1050))
radius = math("SUBTRACT", front_z, center, (480, -750))
radius = math("MULTIPLY", radius, t, (720, -750))
remaining = math("SUBTRACT", 1, t, (480, -400))
remainder = math("MULTIPLY", remaining, delta, (720, -1050))
radius = math("ADD", radius, remainder, (960, -750))
# Front and rear use the same edge field, evaluated on their own caps.
edge = read("o_balloon_surface_edge_weight", (0, -1450))
mask = read("o_normal_scale", (240, -1800))
remaining = math("SUBTRACT", 1, edge, (240, -1450))
remaining = math("MULTIPLY", remaining, mask, (480, -1450))
weight = math("SUBTRACT", 1, remaining, (720, -1450))
difference = math("SUBTRACT", weight, edge, (960, -1450))
base = math("MULTIPLY", difference, radius, (1200, -1450))
layer = math("MULTIPLY", weight, distance, (1200, -1800))
rear_radius = math("ADD", base, layer, (1440, -1450))
front_radius = math("MULTIPLY", edge, radius, (1440, -750))
normal = read("o_balloon_surface_normal", (1200, -300), "FLOAT_VECTOR")
direction = vector("SUBTRACT", normal, (0, 0, 1), (1440, -300))
front_shift = vector("SCALE", direction, front_radius, (1680, -750))
front_position = vector("ADD", front, front_shift, (1920, -750))
rear_shift = vector("SCALE", direction, rear_radius, (1680, -1450))
axis_offset = node(group, "ShaderNodeCombineXYZ", (1680, -1800))
links.new(distance, axis_offset.inputs["Z"])
full_offset = vector("ADD", axis_offset.outputs[0], rear_shift, (1920, -1450))
# Smoothly reach the existing full-volume shape at Thickness >= 1.
transition = node(group, "ShaderNodeMapRange", (1920, 0))
transition.interpolation_type = "SMOOTHERSTEP"
links.new(t, transition.inputs["Value"])
mix = node(group, "ShaderNodeMix", (2400, -1100))
mix.data_type = "VECTOR"
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"], (3120, -750))
choose = node(group, "GeometryNodeSwitch", (3400, 0))
choose.input_type = "VECTOR"
links.new(uniform, choose.inputs["Switch"])
links.new(balloon_position, choose.inputs["False"])
links.new(uniform_position, choose.inputs["True"])
for item in set(group.nodes) - previous:
item.location.x *= 0.9
item.location.y *= 0.65
shift_stage(group, previous, offset)
return build_inflation_position(group, choose.outputs[0], distance, uniform)

View File

@@ -1,88 +0,0 @@
"""Build the smooth center field for depth volume."""
from node_graph import edge_boundary_field
def node(group, kind, location):
result = group.nodes.new(kind)
result.location = location
return result
def shift_stage(group, previous, offset):
for item in group.nodes:
if item not in previous:
item.location.x += offset[0]
item.location.y += offset[1]
def build_volume_fields(group, geometry, offset=(0, 0)):
previous = set(group.nodes)
boundary = edge_boundary_field(group.nodes, group.links, (0, -400))
store = node(group, "GeometryNodeStoreNamedAttribute", (860, 0))
store.domain, store.data_type = "POINT", "FLOAT"
store.inputs["Name"].default_value = "o_balloon_surface_rim"
group.links.new(geometry, store.inputs["Geometry"])
group.links.new(boundary, store.inputs["Value"])
profile = node(group, "GeometryNodeInputNamedAttribute", (860, 700))
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
links = group.links
def math(operation, a, b, location):
result = node(group, "ShaderNodeMath", location)
result.operation = operation
for index, value in enumerate((a, b)):
if isinstance(value, (int, float)):
result.inputs[index].default_value = value
else:
links.new(value, result.inputs[index])
return result.outputs[0]
position = node(group, "GeometryNodeInputPosition", (860, 1100))
axes = node(group, "ShaderNodeSeparateXYZ", (1100, 1100))
links.new(position.outputs[0], axes.inputs[0])
seed = math("SUBTRACT", axes.outputs["Z"], profile.outputs["Attribute"], (1340, 900))
center = node(group, "GeometryNodeStoreNamedAttribute", (1340, 0))
center.domain, center.data_type = "POINT", "FLOAT"
center.inputs["Name"].default_value = "o_balloon_surface_center"
links.new(store.outputs["Geometry"], center.inputs["Geometry"])
links.new(seed, center.inputs["Value"])
current = node(group, "GeometryNodeInputNamedAttribute", (1660, -330))
current.data_type = "FLOAT"
current.inputs["Name"].default_value = "o_balloon_surface_center"
rim = node(group, "GeometryNodeInputNamedAttribute", (1660, -660))
rim.data_type = "FLOAT"
rim.inputs["Name"].default_value = "o_balloon_surface_rim"
interior = math("SUBTRACT", 1, rim.outputs["Attribute"], (1890, -660))
blur = node(group, "GeometryNodeBlurAttribute", (2120, -330))
blur.data_type = "FLOAT"
blur.inputs["Iterations"].default_value = 256
links.new(current.outputs["Attribute"], blur.inputs["Value"])
links.new(interior, blur.inputs["Weight"])
update = node(group, "GeometryNodeStoreNamedAttribute", (2370, 0))
update.domain, update.data_type = "POINT", "FLOAT"
update.inputs["Name"].default_value = "o_balloon_surface_center"
links.new(center.outputs["Geometry"], update.inputs["Geometry"])
links.new(blur.outputs[0], update.inputs["Value"])
baseline = node(group, "GeometryNodeInputNamedAttribute", (2940, 1000))
baseline.data_type = "FLOAT"
baseline.inputs["Name"].default_value = "o_balloon_surface_center"
profile_end = node(group, "GeometryNodeInputNamedAttribute", (2940, 700))
profile_end.data_type = "FLOAT"
profile_end.inputs["Name"].default_value = "o_balloon"
position_end = node(group, "GeometryNodeInputPosition", (2940, 1360))
axes_end = node(group, "ShaderNodeSeparateXYZ", (3180, 1360))
links.new(position_end.outputs[0], axes_end.inputs[0])
residual = math("SUBTRACT", axes_end.outputs["Z"], baseline.outputs["Attribute"], (3420, 1000))
full = math("ADD", residual, profile_end.outputs["Attribute"], (3660, 1000))
minimum = math("MULTIPLY", profile_end.outputs["Attribute"], 0.05, (3420, 700))
positive = math("MAXIMUM", full, minimum, (3900, 1000))
control = node(group, "NodeGroupInput", (3900, 700))
scaled = math("MULTIPLY", positive, control.outputs["Thickness"], (4140, 1000))
thickness = node(group, "GeometryNodeStoreNamedAttribute", (4140, 0))
thickness.domain, thickness.data_type = "POINT", "FLOAT"
thickness.inputs["Name"].default_value = "o_balloon_surface_thickness"
group.links.new(update.outputs["Geometry"], thickness.inputs["Geometry"])
group.links.new(scaled, thickness.inputs["Value"])
shift_stage(group, previous, offset)
return thickness.outputs["Geometry"]

View File

@@ -1,145 +0,0 @@
"""Arrange staggered calculation branches on both sides of the geometry path."""
from cutout_layout import route_geometry
# Main geometry advances left to right. Local fields grow to either side of it;
# a long calculation may start before the geometry input or span several stages.
POSITIONS = {
"Position": (-240, 110),
"Store Named Attribute": (0, 0),
"Store Named Attribute.001": (260, 0),
"Store Named Attribute.002": (520, 0),
"Store Named Attribute.003": (780, 0),
"Split Edges": (1060, 0),
"Switch": (1360, 0),
# Face sampling stays near its stores; selection forms a dense field cluster.
"Named Attribute": (-440, 380),
"Evaluate on Domain": (-220, 380),
"Image Texture": (20, 380),
"Named Attribute.003": (260, 260),
"Named Attribute.001": (-1090, 1060),
"Separate XYZ": (-870, 1060),
"Named Attribute.002": (-390, 590),
"Corners of Edge": (-1090, 910),
"Face of Corner": (-910, 910),
"Corners of Edge.001": (-1090, 770),
"Face of Corner.001": (-910, 770),
"Evaluate at Index": (-170, 750),
"Evaluate at Index.002": (-170, 610),
"Evaluate at Index.001": (-710, 1060),
"Evaluate at Index.003": (-710, 920),
"Vector Math": (10, 710),
"Math": (-530, 1040),
"Math.001": (-350, 1040),
"Math.002": (-170, 1040),
"Math.003": (190, 1040),
"Math.004": (190, 790),
"Math.005": (370, 980),
"Math.006": (190, 620),
"Math.007": (550, 910),
"Edge Neighbors": (370, 590),
"Math.008": (550, 590),
"Math.009": (730, 810),
"Evaluate on Domain.001": (730, 670),
"Evaluate on Domain.002": (910, 670),
"Math.010": (1090, 670),
"Math.011": (1360, 280),
# Camera-ray reconstruction runs below; scene projection runs above.
"Named Attribute.004": (1290, -440),
"Image Texture.001": (1510, -440),
"Separate XYZ.001": (1740, -440),
"Named Attribute.005": (1510, -710),
"Separate XYZ.002": (1740, -710),
"Math.014": (1960, -440),
"Vector Math.001": (2180, -440),
"Named Attribute.006": (1740, -940),
"Math.012": (1510, -940),
"Math.013": (1960, -940),
"Switch.001": (2600, -240),
"Store Named Attribute.004": (2920, 0),
"Named Attribute.007": (2340, 1080),
"Vector Math.002": (2560, 1080),
"Separate XYZ.003": (2740, 1080),
"Math.015": (3100, 730),
"Math.016": (3280, 730),
"Math.017": (2740, 910),
"Combine XYZ": (2920, 1040),
"Position.001": (2920, 790),
"Mix": (3100, 950),
"Separate XYZ.004": (3280, 950),
"Combine XYZ.001": (3460, 850),
"Set Position": (3870, 0),
# Front and rear caps share a single extrusion along the geometry axis.
"Position.002": (4560, 340),
"Store Named Attribute.005": (4560, 0),
"Vector Math.003": (4780, 340),
"Set Position.001": (4780, 0),
"Index": (5010, 340),
"Store Named Attribute.006": (5010, 0),
"Extrude Mesh": (5500, 0),
"Combine XYZ.003": (5260, 350),
"Separate Geometry": (5780, 0),
"Flip Faces.001": (6300, 0),
"Join Geometry": (6970, 0),
"Combine XYZ.004": (6070, -530),
"Set Position.002": (6300, -320),
"Flip Faces": (6740, -320),
# Identity decoding above and position restoration below have independent,
# horizontally spread readers rather than equally tall vertical stacks.
"Named Attribute.008": (6240, 880),
"Integer Math": (6470, 880),
"Named Attribute.009": (6240, 650),
"Integer Math.001": (6470, 650),
"Position.004": (6240, 410),
"Separate XYZ.006": (6470, 410),
"Combine XYZ.002": (6670, 650),
"Set Position.003": (7410, 0),
"Merge by Distance": (7640, 0),
"Position.003": (6910, -950),
"Separate XYZ.005": (7160, -950),
"Math.018": (7380, -750),
"Combine XYZ.005": (7600, -680),
"Named Attribute.010": (7600, -370),
"Vector Math.004": (7830, -340),
"Set Position.004": (8080, 0),
"Math.019": (8310, 260),
"Switch.002": (8310, 0),
"Remove Named Attribute": (9000, 0),
"Set Shade Smooth": (9220, 0),
"Group Output": (9440, 0),
}
INPUT_REGIONS = (
(("Store Named Attribute",), (-240, 0)),
(("Switch",), (1100, -300)),
(("Image Texture",), (30, 520)),
(("Math.002",), (-350, 1140)),
(("Math.003",), (10, 1140)),
(("Math.011",), (1100, 320)),
(("Image Texture.001",), (1290, -270)),
(("Math.012",), (1290, -940)),
(("Vector Math.002",), (2340, 1200)),
(("Math.015", "Math.016"), (2920, 680)),
(("Mix",), (2920, 900)),
(("Math.019",), (8080, 300)),
)
def layout_geometry_references(group, stage):
"""Separate the split bypass and long projection references from fields."""
links = group.links
entry = stage["Store Named Attribute"].inputs["Geometry"]
source = entry.links[0].from_socket
fork = route_geometry(group, source, [(-3250, -40), (-1450, -40), (-290, -40)])
links.new(fork, entry)
bypass = route_geometry(group, fork, [(-210, -280), (500, -280), (1100, -280)])
links.new(bypass, stage["Switch"].inputs["False"])
projection = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_front_normal")
shared = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_inflation_normal")
uniform = next(link.to_socket for link in shared.links if link.to_node.bl_idname == "GeometryNodeStoreNamedAttribute" and link.to_node.inputs["Name"].default_value == "o_balloon_surface_thickness")
fork = route_geometry(group, projection, [(4360, -40), (4650, -1700), (8500, -1700), (11400, -1700)])
uniform_route = route_geometry(group, shared, [(8250, -40), (8460, -1450), (11400, -1450), (11420, -370)])
links.new(uniform_route, uniform)
surface_route = route_geometry(group, fork, [(13500, -1700), (16800, -1700)])
links.new(surface_route, stage["Switch.002"].inputs["True"])

View File

@@ -1,44 +0,0 @@
"""Build the camera-projected branch of the complete-image depth plane."""
from node_graph import group_input as _group_input
from depth_surface import project_depth_surface, thicken_depth_surface, remove_surface_attributes
def build_camera_plane(group, image_plane_group):
nodes, links = group.nodes, group.links
controls = _group_input(nodes, (-300, 0), {"Geometry", "Subdivide"})
plane = nodes.new("GeometryNodeGroup")
plane.node_tree = image_plane_group
plane.inputs["Thickness"].default_value = 0
links.new(controls.outputs["Geometry"], plane.inputs["Geometry"])
links.new(controls.outputs["Subdivide"], plane.inputs["Subdivide"])
texture_input = _group_input(nodes, (-300, 700), {"Depth Image", "Uniform Scale"})
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
texture = nodes.new("GeometryNodeImageTexture")
texture.interpolation, texture.extension = "Linear", "EXTEND"
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"])
depth_input = _group_input(nodes, (500, 1000), {"Reference Depth", "Depth Scale"})
surface = project_depth_surface(
group, plane.outputs["Geometry"], texture.outputs["Color"],
texture_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"],
depth_input.outputs["Depth Scale"],
)
shell_input = _group_input(nodes, (2000, 500), {"Camera Thickness", "Normal Smooth"})
shell = thicken_depth_surface(
group, surface, shell_input.outputs["Camera Thickness"],
normal_smooth=shell_input.outputs["Normal Smooth"],
)
positive = nodes.new("ShaderNodeMath")
positive.operation = "GREATER_THAN"
links.new(shell_input.outputs["Camera Thickness"], positive.inputs[0])
positive.inputs[1].default_value = 0
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(positive.outputs[0], choose.inputs["Switch"])
links.new(surface, choose.inputs["False"])
links.new(shell, choose.inputs["True"])
clean = remove_surface_attributes(group, choose.outputs["Output"])
return clean

View File

@@ -1,82 +0,0 @@
"""Place projection fields above the rectangular surface and shell stages."""
from cutout_layout import route_geometry, layout_stage
def layout_camera_plane(group, previous):
stage = layout_stage(group, previous, {
"Group": (0, 0),
"Named Attribute": (-240, 760),
"Image Texture": (0, 760),
"Vector Math": (220, 760),
"Separate XYZ": (440, 760),
"Math": (660, 980),
"Math.001": (1320, 980),
"Math.002": (660, 620),
"Combine XYZ": (880, 760),
"Position": (880, 420),
"Mix": (1100, 760),
"Separate XYZ.001": (1320, 760),
"Combine XYZ.001": (1540, 880),
"Set Position": (1760, 0),
"Position.001": (1760, 400),
"Store Named Attribute": (2020, 0),
"Vector Math.001": (2020, 400),
"Set Position.001": (2280, 0),
"Index": (2280, 400),
"Store Named Attribute.001": (2540, 0),
"Combine XYZ.002": (3970, 820),
"Integer Math": (3750, 1220),
"Named Attribute.001": (3500, 1220),
"Integer Math.001": (3750, 980),
"Named Attribute.002": (3500, 980),
"Extrude Mesh": (2820, 0),
"Combine XYZ.003": (2540, 440),
"Separate Geometry": (3090, 0),
"Combine XYZ.004": (2820, -350),
"Set Position.002": (3090, -350),
"Flip Faces": (3370, -350),
"Flip Faces.001": (3370, 0),
"Join Geometry": (3650, 0),
"Set Position.003": (3970, 0),
"Merge by Distance": (4230, 0),
"Normal": (1540, -340),
"Blur Attribute": (1760, -340),
"Vector Math.003": (2020, -340),
"Store Named Attribute.002": (2020, 0),
"Vector Math.004": (4990, 700),
"Named Attribute.004": (4710, 1080),
"Position.002": (4230, 700),
"Separate XYZ.002": (4450, 700),
"Math.003": (4710, 700),
"Vector Math.002": (5210, 700),
"Named Attribute.003": (4990, 1080),
"Set Position.004": (5430, 0),
"Position.003": (3500, 620),
"Separate XYZ.003": (3750, 620),
"Math.004": (5430, 500),
"Switch": (5700, 0),
"Remove Named Attribute": (5960, 0),
"Set Shade Smooth": (6220, 0),
"Group Output": (6480, 0),
}, (
(("Group",), (-250, 0)),
(("Image Texture",), (-240, 1100)),
(("Vector Math",), (0, 1100)),
(("Math",), (440, 1120)),
(("Math.001", "Mix"), (880, 1160)),
(("Math.003",), (4450, 1040)),
(("Math.004",), (5210, 500)),
(("Blur Attribute",), (1540, -540)),
))
for name, node in stage.items():
if node.location.x >= 2020 and name not in {
"Store Named Attribute.002", "Vector Math.003"
}:
node.location.x += 260
base = route_geometry(group, stage["Store Named Attribute.001"].outputs["Geometry"],
((2770, 180), (3600, 180)))
group.links.new(base, stage["Join Geometry"].inputs["Geometry"])
bypass = route_geometry(group, stage["Set Position"].outputs["Geometry"],
((1970, -760), (5650, -760)))
group.links.new(bypass, stage["Switch"].inputs["False"])

View File

@@ -1,195 +0,0 @@
"""Build the image material Shader node groups."""
import bpy
from anyimage.common.material import (
DEPTH_MATERIAL_NODE_GROUP_NAME,
IMAGE_MATERIAL_NODE_GROUP_NAME,
)
BUMP_BASE_DISTANCE = 0.001
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
def build_material_layer_group(depth_plane=False):
group_name = (
DEPTH_MATERIAL_NODE_GROUP_NAME
if depth_plane
else IMAGE_MATERIAL_NODE_GROUP_NAME
)
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(group_name, "ShaderNodeTree")
interface = group.interface
input_sockets = [
("Color", "NodeSocketColor"),
("Alpha", "NodeSocketFloat"),
("Bump Scale", "NodeSocketFloat"),
]
normal_group = not depth_plane
if normal_group:
input_sockets.insert(2, ("Normal", "NodeSocketColor"))
input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat"))
input_sockets.insert(4, ("Object Space", "NodeSocketBool"))
if depth_plane:
input_sockets.insert(2, ("Depth", "NodeSocketFloat"))
for name, socket_type in input_sockets:
socket = interface.new_socket(
name=name,
in_out="INPUT",
socket_type=socket_type,
)
if name == "Alpha":
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 1.0
socket.subtype = "FACTOR"
elif name == "Bump Scale":
socket.default_value = 0.0
socket.min_value = -10000.0
socket.max_value = 10000.0
elif name == "Normal":
socket.default_value = (0.5, 0.5, 1.0, 1.0)
elif name == "Normal Scale":
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 10.0
elif name == "Object Space":
socket.default_value = False
output_sockets = [
("Color", "NodeSocketColor"),
("Roughness", "NodeSocketFloat"),
("Alpha", "NodeSocketFloat"),
("Normal", "NodeSocketVector"),
]
if depth_plane:
output_sockets.append(("Displacement", "NodeSocketVector"))
for name, socket_type in output_sockets:
interface.new_socket(
name=name,
in_out="OUTPUT",
socket_type=socket_type,
)
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_input.location = (-500.0, 100.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (300.0, 100.0)
roughness_curve = nodes.new("ShaderNodeFloatCurve")
roughness_curve.mapping.initialize()
curve_points = roughness_curve.mapping.curves[0].points
curve_points[0].location = (0.0, 0.5)
curve_points[-1].location = (1.0, 0.1)
roughness_curve.mapping.update()
roughness_curve.location = (-250.0, 350.0)
bump = nodes.new("ShaderNodeBump")
bump.location = (100.0, 0.0)
if normal_group:
tangent_normal = nodes.new("ShaderNodeNormalMap")
tangent_normal.space = "TANGENT"
tangent_normal.location = (-500.0, -100.0)
object_normal = nodes.new("ShaderNodeNormalMap")
object_normal.space = "OBJECT"
object_normal.location = (-500.0, -350.0)
choose_normal = nodes.new("ShaderNodeMix")
choose_normal.data_type = "VECTOR"
choose_normal.location = (-150.0, -150.0)
for normal_map in (tangent_normal, object_normal):
links.new(group_input.outputs["Normal"], normal_map.inputs["Color"])
links.new(
group_input.outputs["Normal Scale"],
normal_map.inputs["Strength"],
)
links.new(group_input.outputs["Object Space"], choose_normal.inputs[0])
links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4])
links.new(object_normal.outputs["Normal"], choose_normal.inputs[5])
links.new(choose_normal.outputs["Result"], bump.inputs["Normal"])
displacement = None
processed_alpha = group_input.outputs["Alpha"]
bump_depth = group_input.outputs["Color"]
if depth_plane:
displacement = nodes.new("ShaderNodeDisplacement")
displacement.inputs["Midlevel"].default_value = 0.0
displacement.inputs["Scale"].default_value = 1.0
group_input.location = (-1200.0, 100.0)
group_output.location = (1050.0, 100.0)
roughness_curve.location = (-300.0, 450.0)
bump.location = (750.0, 50.0)
displacement.location = (750.0, -300.0)
background_attribute = nodes.new("ShaderNodeAttribute")
background_attribute.attribute_type = "GEOMETRY"
background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
background_attribute.location = (-950.0, -500.0)
material_attribute = nodes.new("ShaderNodeAttribute")
material_attribute.attribute_type = "GEOMETRY"
material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
material_attribute.location = (-50.0, -650.0)
strength_attribute = nodes.new("ShaderNodeAttribute")
strength_attribute.attribute_type = "GEOMETRY"
strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE
strength_attribute.location = (200.0, -650.0)
foreground_mask = nodes.new("ShaderNodeMath")
foreground_mask.operation = "GREATER_THAN"
foreground_mask.inputs[1].default_value = 0.5
foreground_mask.location = (-950.0, -250.0)
clamp_foreground = nodes.new("ShaderNodeMath")
clamp_foreground.operation = "MAXIMUM"
clamp_foreground.location = (-650.0, -400.0)
masked_depth = nodes.new("ShaderNodeMix")
masked_depth.data_type = "FLOAT"
masked_depth.location = (-350.0, -350.0)
enabled_depth = nodes.new("ShaderNodeMath")
enabled_depth.operation = "MULTIPLY"
enabled_depth.location = (250.0, -350.0)
scaled_depth = nodes.new("ShaderNodeMath")
scaled_depth.operation = "MULTIPLY"
scaled_depth.location = (450.0, -350.0)
links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0])
links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0])
links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1])
links.new(foreground_mask.outputs[0], masked_depth.inputs[0])
links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2])
links.new(clamp_foreground.outputs[0], masked_depth.inputs[3])
links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0])
links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1])
links.new(enabled_depth.outputs[0], scaled_depth.inputs[0])
links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1])
processed_alpha = foreground_mask.outputs[0]
processed_depth = scaled_depth.outputs[0]
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"])
links.new(
roughness_curve.outputs["Value"],
group_output.inputs["Roughness"],
)
links.new(processed_alpha, group_output.inputs["Alpha"])
links.new(bump_depth, bump.inputs["Height"])
bump_distance = nodes.new("ShaderNodeMath")
bump_distance.operation = "MULTIPLY"
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0)
links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0])
links.new(bump_distance.outputs[0], bump.inputs["Distance"])
links.new(bump.outputs["Normal"], group_output.inputs["Normal"])
if displacement is not None:
links.new(processed_depth, displacement.inputs["Height"])
links.new(
displacement.outputs["Displacement"],
group_output.inputs["Displacement"],
)
group.color_tag = "SHADER"
group.description = (
"Prepare image depth material outputs."
if depth_plane
else f"Prepare {group_name} material outputs."
)
group.use_fake_user = True
return group

View File

@@ -1,138 +0,0 @@
"""Shared presentation rules for authored geometry-node layouts."""
def arrange_nodes(group, positions, input_regions):
"""Place authored branches and rebuild their local Group Inputs."""
controls = [
(link.to_socket, link.from_socket.name)
for link in group.links
if link.from_node.bl_idname == "NodeGroupInput"
]
for node in list(group.nodes):
if node.bl_idname == "NodeGroupInput":
group.nodes.remove(node)
assert set(positions) == {node.name for node in group.nodes}, group.name
for name, location in positions.items():
group.nodes[name].location = location
for targets, location in input_regions:
node = group.nodes.new("NodeGroupInput")
node.location = location
for socket, name in controls:
if socket.node.name in targets:
group.links.new(node.outputs[name], socket)
assert all(socket.is_linked for socket, _ in controls), group.name
for node in group.nodes:
node.select = False
node.width = (
200
if "Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup"
else 160
)
node.hide = node.bl_idname in {
"ShaderNodeMath",
"ShaderNodeVectorMath",
} and all(
socket.is_linked
for socket in node.inputs
if socket.enabled and not socket.hide
)
for socket in node.outputs:
socket.hide = not socket.is_linked
def validate_node_layout(group):
"""Check visible bounds and sockets, allowing direct backward references."""
nodes = [n for n in group.nodes if n.bl_idname not in {"NodeReroute", "NodeFrame"}]
assert all(not n.label for n in nodes), group.name
assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes), group.name
assert all(s.is_linked or s.hide for n in nodes for s in n.outputs), group.name
validate_peer_inputs(group)
for link in group.links:
if (
link.from_node.bl_idname != "NodeReroute"
or link.to_node.bl_idname == "NodeReroute"
):
continue
source = link.from_node
while source.bl_idname == "NodeReroute":
source = source.inputs[0].links[0].from_node
assert source.location.x <= link.to_node.location.x, (
group.name,
source.name,
link.to_node.name,
)
for index, node in enumerate(nodes):
width = node.dimensions.x or node.width
height = node.dimensions.y or (30 if node.hide else node.height)
for other in nodes[index + 1 :]:
other_width = other.dimensions.x or other.width
other_height = other.dimensions.y or (30 if other.hide else other.height)
overlap = (
node.location.x < other.location.x + other_width
and node.location.x + width > other.location.x
and node.location.y - height < other.location.y
and node.location.y > other.location.y - other_height
)
assert not overlap, (group.name, node.name, other.name)
if node.bl_idname == "GeometryNodeRepeatInput":
assert (
node.paired_output and node.paired_output.location.x > node.location.x
)
def validate_peer_inputs(group):
"""Align peer branch ends while leaving shared upstream sources in place."""
parents = {node: set() for node in group.nodes}
consumers = {node: set() for node in group.nodes}
for link in group.links:
parents[link.to_node].add(link.from_node)
consumers[link.from_node].add(link.to_node)
def ancestors(node):
found = set()
pending = list(parents[node])
while pending:
parent = pending.pop()
if parent in found:
continue
found.add(parent)
pending.extend(parents[parent])
return found
for node in group.nodes:
if node.bl_idname not in {
"GeometryNodeJoinGeometry",
"GeometryNodeMenuSwitch",
"GeometryNodeSwitch",
"ShaderNodeCombineXYZ",
}:
continue
combine = node.bl_idname == "ShaderNodeCombineXYZ"
sources = set()
for socket in node.inputs:
if not combine and socket.type != "GEOMETRY":
continue
for link in socket.links:
source = link.from_node
while source.bl_idname == "NodeReroute":
source = source.inputs[0].links[0].from_node
if combine or source.bl_idname != "NodeGroupInput":
sources.add(source)
upstream = set().union(*(ancestors(source) for source in sources))
peers = sources - upstream
if combine:
peers = {source for source in peers if consumers[source] == {node}}
edges = [source.location.x + source.width for source in peers]
assert not edges or max(edges) - min(edges) < 1.0, (
group.name,
node.name,
[source.name for source in peers],
)
if node.bl_idname == "GeometryNodeMenuSwitch":
rows = [
link.from_node.location.y
for socket in node.inputs
for link in socket.links
if link.from_node in peers
]
assert rows == sorted(rows, reverse=True), (group.name, node.name)

View File

@@ -1,118 +0,0 @@
"""Authored processing stages for the shared image geometry assets."""
from node_layout import arrange_nodes
LAYOUTS = {
"O Image Plane": (
{
"Bounding Box": (-2320, 440),
"Vector Math": (-2100, 650),
"Separate XYZ": (-1870, 650),
"Math": (-1870, 990),
"Math.001": (-1650, 850),
"Math.002": (-1430, 850),
"Math.003": (-1200, 850),
"Math.004": (-980, 850),
"Math.005": (-760, 850),
"Math.006": (-1650, 510),
"Math.007": (-1430, 510),
"Math.008": (-1200, 510),
"Math.009": (-980, 510),
"Math.010": (-760, 510),
"Grid": (520, 340),
"Position": (-950, -480),
"Separate XYZ.001": (-720, -480),
"Math.011": (-500, -340),
"Math.012": (-280, -340),
"Math.013": (-500, -610),
"Math.014": (-280, -610),
"Combine XYZ": (0, -340),
"Store Named Attribute": (770, 340),
"Vector Math.001": (740, 710),
"Vector Math.002": (960, 710),
"Transform Geometry": (1030, 340),
"Bounding Box.001": (-1170, -990),
"Vector Math.003": (-950, -990),
"Separate XYZ.002": (-720, -990),
"Bounding Box.002": (-330, 140),
"Vector Math.004": (-100, 140),
"Separate XYZ.003": (120, 270),
"Math.015": (-520, 1390),
"Math.016": (-290, 1390),
"Math.017": (-520, 1160),
"Math.018": (-290, 1160),
"Math.019": (-70, 1390),
"Math.020": (150, 1390),
"Math.021": (380, 1390),
"Math.022": (600, 1390),
"Math.023": (830, 1390),
"Math.024": (1050, 1390),
"Combine XYZ.001": (280, 0),
"Cube": (520, -170),
"Store Named Attribute.001": (770, -170),
"Transform Geometry.001": (1030, -170),
"Compare": (1320, 410),
"Switch": (1360, 0),
"Capture Attribute": (1320, -580),
"Join Geometry": (1720, 0),
"Set Material Index": (1940, 0),
"Delete Geometry": (2170, 0),
"Group Output": (2390, 0),
},
(
(("Bounding Box",), (-2550, 440)),
(("Math",), (-2100, 1020)),
(("Math.021",), (150, 1600)),
(("Combine XYZ.001",), (120, 60)),
(("Compare",), (1140, 540)),
(("Capture Attribute",), (1140, -580)),
(("Bounding Box.001",), (-1390, -990)),
(("Bounding Box.002",), (-550, 140)),
),
),
"O Image Depth Plane": (
{
"Group": (0, 0),
"Transform Geometry": (520, 0),
"Set Position": (2470, 0),
"Set Shade Smooth": (2730, 0),
"Group Output": (2990, 0),
"Math": (60, 340),
"Combine XYZ": (240, 340),
"Named Attribute": (240, -450),
"Image Texture": (460, -450),
"Separate XYZ": (640, -450),
"Math.001": (820, -450),
"Math.002": (1000, -450),
"Math.003": (1180, -450),
"Math.004": (1180, -660),
"Math.005": (1380, -450),
"Math.006": (1740, -1060),
"Switch": (1760, -450),
"Position": (820, -820),
"Separate XYZ.001": (1000, -820),
"Math.007": (1180, -820),
"Math.008": (1380, -820),
"Math.009": (1560, -820),
"Switch.001": (1760, -820),
"Math.010": (1970, -520),
"Combine XYZ.001": (2150, -520),
},
(
(("Group",), (-240, 0)),
(("Math",), (-120, 340)),
(("Image Texture",), (240, -310)),
(("Math.001",), (640, -310)),
(("Math.002", "Math.003"), (820, -590)),
(("Math.004",), (1000, -660)),
(("Math.006",), (1560, -1060)),
(("Math.007",), (1000, -980)),
),
),
}
def layout_geometry(group):
"""Apply the authored layout for one geometry asset."""
positions, inputs = LAYOUTS[group.name]
arrange_nodes(group, positions, inputs)

1
tools/nodes/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,769 @@
"""Arrange geometry paths, local fields, and shared references after construction."""
from collections import defaultdict
from dataclasses import dataclass, field
from graphlib import TopologicalSorter
import numpy as np
from scipy.optimize import LinearConstraint, minimize
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
GEOMETRY_GAP = 100
FIELD_GAP = 40
BRANCH_GAP = 160
SECTION_GAP = 200
FIELD_HEIGHT = 720
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"]
for node in [node for node in node_group.nodes if node not in previous]:
for socket in node.inputs:
links = socket_links(socket)
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))
continue
sources = [origin_socket(link.from_socket) for link in links]
for link in links:
node_group.links.remove(link)
seen = set()
for source in reversed(sources):
original = source
if source in seen:
# Blender coalesces identical direct links; retain repeated inputs.
duplicate = node_group.nodes.new("NodeReroute")
node_group.links.new(source, duplicate.inputs[0])
source = duplicate.outputs[0]
seen.add(original)
node_group.links.new(source, socket)
for node in previous:
node_group.nodes.remove(node)
def _peer_inputs(node, ancestors):
sources = list(dict.fromkeys(
link.from_node for link in peer_links(node)
if link.from_node.bl_idname != "NodeGroupInput"
))
upstream = set().union(*(ancestors[source] for source in sources))
return [source for source in sources if source not in upstream]
def _localize_peer_inputs(node_group, ancestors, children):
"""Give shared inputs local ports without moving their public source."""
for node in list(node_group.nodes):
links = peer_links(node)
if len(links) < 2:
continue
sources = [link.from_node for link in links]
upstream = set().union(*(ancestors[source] for source in sources))
for link in links:
source = link.from_node
if source.bl_idname == "NodeReroute":
source["arrangement_input"] = True
continue
if source.bl_idname != "NodeGroupInput" and len(children[source]) <= 1 and sources.count(source) == 1 and source not in upstream:
continue
port = node_group.nodes.new("NodeReroute")
port["arrangement_input"] = True
node_group.links.new(link.from_socket, port.inputs[0])
_replace_link(node_group, link, port.outputs[0])
def _columns(nodes, parents, peers, sizes, gap, spacing=None):
"""Allocate columns with equal right edges for independent peer inputs."""
roots = {node: node for node in nodes}
def root(node):
while roots[node] != node:
node = roots[node]
return node
for sources in peers:
members = [source for source in sources if source in roots]
for source in members[1:]:
roots[root(source)] = root(members[0])
dependencies = {root(node): [] for node in nodes}
for node in nodes:
for parent in parents[node]:
if parent in roots and root(parent) not in dependencies[root(node)]:
dependencies[root(node)].append(root(parent))
levels = {}
for component in TopologicalSorter(dependencies).static_order():
levels[component] = max((levels[p] + 1 for p in dependencies[component]), default=0)
columns = defaultdict(list)
for node in nodes:
columns[levels[root(node)]].append(node)
right, positions = 0, {}
for level in sorted(columns):
right += max(((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
right += gap
return positions, columns
def _field_regions(nodes, core, children, order):
"""Group calculations by their first geometry consumers and shared scope."""
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])
)
for node in order:
if node.get("arrangement_exit"):
targets[node] = targets[origin_socket(node.outputs[0]).node]
regions = defaultdict(list)
for node in order:
if node in fields:
regions[targets[node]].append(node)
return [(region, owners) for owners, region in regions.items()]
def _localize_module_outputs(node_group, core, children):
"""Expose reused results at the boundary of their calculation module."""
shared = set(shared_geometry_outputs(node_group.nodes))
for link in list(node_group.links):
source = link.from_node
if link.to_node not in core or source in core or source.bl_idname == "NodeGroupInput":
continue
if link.from_socket in shared or not any(node not in core for node in children[source]):
continue
port = node_group.nodes.new("NodeReroute")
port["arrangement_exit"] = True
node_group.links.new(link.from_socket, port.inputs[0])
_replace_link(node_group, link, port.outputs[0])
def _localize_inputs(node_group):
"""Give each operation local interface reads, separating geometry from values."""
controls = [node for node in node_group.nodes if node.bl_idname == "NodeGroupInput"]
uses = defaultdict(list)
for link in node_group.links:
if link.from_node in controls:
uses[link.to_node, link.from_socket.type == "GEOMETRY"].append((link, link.from_socket.identifier))
for inputs in uses.values():
local = node_group.nodes.new("NodeGroupInput")
for link, identifier in inputs:
source = next(socket for socket in local.outputs if socket.identifier == identifier)
_replace_link(node_group, link, source)
for node in controls:
node_group.nodes.remove(node)
def _presentation(node_group):
for node in node_group.nodes:
node.select = False
for socket in node.outputs:
socket.hide = not socket.is_linked
if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}:
node.hide = all(socket.is_linked for socket in node.inputs if visible_socket(socket))
def _place_geometry_paths(paths, geometry_x, sizes, order):
"""Reuse fixed horizontal lanes; field sizes never participate in lane choice."""
paths = [list(path) for path in paths]
for path in list(paths):
for node in path:
paired = getattr(node, "paired_output", None)
if paired is None or paired not in geometry_x:
continue
first = next(nodes for nodes in paths if node in nodes)
last = next(nodes for nodes in paths if paired in nodes)
if first is not last:
first.extend(last)
paths.remove(last)
pitch = max(sizes[node].height for path in paths for node in path) + SECTION_GAP
path_of = {node: index for index, path in enumerate(paths) for node in path}
preceding = {index: set() for index in range(len(paths))}
for node in order:
inputs = list(dict.fromkeys(path_of[link.from_node] for link in peer_links(node)
if link.from_node in path_of))
for first, second in zip(inputs, inputs[1:]):
pending, seen = [first], set()
while pending:
current = pending.pop()
if current not in seen:
seen.add(current)
pending.extend(preceding[current])
if second not in seen:
preceding[second].add(first)
lanes, positions, rows = defaultdict(list), {}, {}
pending = set(preceding)
while pending:
ready = [index for index in pending if not preceding[index] & pending]
index = min(ready, key=lambda index: (-len(paths[index]), geometry_x[paths[index][0]], index))
path = paths[index]
left = min(geometry_x[node] for node in path)
right = max(geometry_x[node] + sizes[node].width for node in path)
minimum = max((rows[previous] + 1 for previous in preceding[index]), default=-len(paths))
for lane in sorted(range(minimum, len(paths) * 2), key=lambda value: (abs(value), value)):
if all(right + FIELD_GAP <= a or left >= b + FIELD_GAP for a, b in lanes[lane]):
break
lanes[lane].append((left, right))
rows[index] = lane
pending.remove(index)
for node in path:
positions[node] = (geometry_x[node], -lane * pitch)
# Solve the lane heights together so successive merges cannot create a staircase.
equalities, centers, inequalities, separations = [], [], [], []
for node in order:
sources = [link.from_socket for link in peer_links(node)]
if node not in path_of or len(sources) < 2 or any(socket.node not in path_of for socket in sources):
continue
output = next(socket for socket in node.outputs if socket in sizes[node].sockets)
first, last = sources[0], sources[-1]
row = np.zeros(len(paths))
row[path_of[node]] += 2
row[path_of[first.node]] -= 1
row[path_of[last.node]] -= 1
equalities.append(row)
centers.append(2 * sizes[node].sockets[output] - sizes[first.node].sockets[first] - sizes[last.node].sockets[last])
spans = [(min(geometry_x[node] for node in path), max(geometry_x[node] + sizes[node].width for node in path))
for path in paths]
local_ports = {index for index, path in enumerate(paths)
if all(node.get("arrangement_input") for node in path)}
pairs = {(first, second) for second, sources in preceding.items() for first in sources}
for first in range(len(paths)):
for second in range(first + 1, len(paths)):
if first in local_ports or second in local_ports:
continue
if spans[first][0] < spans[second][1] + FIELD_GAP and spans[first][1] + FIELD_GAP > spans[second][0]:
pairs.add((first, second) if rows[first] < rows[second] else (second, first))
for first, second in sorted(pairs):
row = np.zeros(len(paths))
row[first], row[second] = 1, -1
inequalities.append(row)
gap = FIELD_GAP if first in local_ports or second in local_ports else SECTION_GAP
separations.append(max(sizes[node].height for node in paths[first]) + gap)
weights = np.array([max(1, len(path)) ** 2 for path in paths], dtype=float)
weights /= weights.max()
constraints = []
if equalities:
constraints.append(LinearConstraint(np.array(equalities), centers, centers))
if inequalities:
constraints.append(LinearConstraint(np.array(inequalities), separations, np.inf))
compact = []
for node in order:
if node not in path_of:
continue
for link in peer_links(node):
if link.from_node in path_of and path_of[link.from_node] in local_ports:
row = np.zeros(len(paths))
row[path_of[link.from_node]], row[path_of[node]] = 1, -1
compact.append(row)
metric = np.diag(weights)
if compact:
matrix = np.array(compact)
metric += matrix.T @ matrix * 4
solution = minimize(lambda values: values @ metric @ values,
np.array([-rows[index] * pitch for index in range(len(paths))], dtype=float),
jac=lambda values: 2 * metric @ values, constraints=constraints,
method="SLSQP", options={"ftol": 1e-5, "maxiter": 300})
if not solution.success:
raise ValueError(f"Geometry lane constraints could not be placed: {solution.message}")
for index, path in enumerate(paths):
for node in path:
positions[node] = (positions[node][0], float(solution.x[index]))
for node in order:
if node not in positions:
continue
links = [link for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links
if link.from_node in positions]
if not links:
continue
x = positions[node][0]
required = []
for link in links:
source = link.from_socket
source_y = positions[source.node][1] - sizes[source.node].sockets[source]
target_y = positions[node][1] - sizes[node].sockets[link.to_socket]
required.append(positions[source.node][0] + sizes[source.node].width
+ max(GEOMETRY_GAP, abs(target_y - source_y) * 0.8))
extra = max(0, max(required) - x)
if extra:
for member, (left, y) in positions.items():
if left >= x:
positions[member] = (left + extra, y)
return positions
def _geometry_corridors(paths, positions, sizes):
"""Reserve continuous breathing room along every horizontal geometry path."""
corridors = []
for path in paths:
if all(node.get("arrangement_input") for node in path):
continue
boxes = [_node_box(node, positions, sizes) for node in path]
corridors.append((min(box[0] for box in boxes) - GEOMETRY_GAP / 2,
max(box[1] for box in boxes) + GEOMETRY_GAP / 2,
max(box[2] for box in boxes), min(box[3] for box in boxes)))
for port in positions:
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
continue
source = origin_socket(port.outputs[0]).node
if source not in positions:
continue
left = positions[source][0] + sizes[source].width
right, level = positions[port][0] + 8, positions[port][1] - 8
if right > left:
corridors.append((left, right, level + ROUTE_GAP, level - ROUTE_GAP))
return corridors
def _node_box(node, positions, sizes):
x, y = positions[node]
return x, x + sizes[node].width, y, y - sizes[node].height
def _overlap(first, second, gap):
a, b, top, bottom = first
c, d, upper, lower = second
return a < d + gap and b > c - gap and top > lower - gap and bottom < upper + gap
@dataclass
class BranchSpace:
"""Keep module bodies and their exit channels in one placement reservation."""
bodies: list = field(default_factory=list)
exits: list = field(default_factory=list)
routes: list = field(default_factory=list)
def connections(self, outlets, placed, sizes):
exits, routes = [], []
for links in outlets:
for link in links:
start, end = link_endpoints(link, placed, sizes)
corners = []
run = max(ROUTE_RUN, abs(end[1] - start[1]) * 0.8)
if end[0] - start[0] > max(ROUTE_RUN * 4, run + ROUTE_RUN * 2):
corners = [(start[0] + ROUTE_RUN, start[1]),
(max(start[0] + ROUTE_RUN, end[0] - run), start[1])]
points = [start, *dict.fromkeys(corners), end]
boxes = [box for a, b in zip(points, points[1:]) for box in curve_footprint(a, b)]
exits.append((link, boxes))
routes.append((link, list(dict.fromkeys(corners))))
return exits, routes
def fits(self, body, exits):
if any(_overlap(body, box, ROUTE_GAP) for _, boxes in self.exits for box in boxes):
return False
for link, boxes in exits:
if any(_overlap(box, rectangle, ROUTE_GAP) for nodes, rectangle in self.bodies
if link.to_node not in nodes for box in boxes):
return False
return True
def crossings(self, exits):
return sum(any(_overlap(box, previous, 8) for box in boxes for previous in channel)
for link, boxes in exits for other, channel in self.exits
if link.to_node != other.to_node and link.from_socket != other.from_socket)
def _place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space, horizontal=None):
"""Place an upstream module together with its forward-facing exit channels."""
right = min(positions[node][0] for node in destinations) - FIELD_GAP
baseline = sum(positions[node][1] for node in owners) / len(owners)
output = next(node for node in core if node.bl_idname == "NodeGroupOutput" and node.is_active_output)
ceiling = max(positions[node][1] for node in owners)
floor = min(positions[node][1] - sizes[node].height for node in owners)
axis_top = positions[output][1]
axis_bottom = axis_top - sizes[output].height
core_left = min(positions[node][0] for node in core)
side = 1 if floor > axis_top else -1 if ceiling < axis_bottom else 0
desired = right - width
front_allowed = not horizontal and 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 = [], [], []
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:
returns = [link for link in links if link.to_node in core]
if returns:
outlets.append(returns)
for link in links:
if link.to_node in positions and link.to_node not in local:
target_x, target_y = positions[link.to_node]
constraints.append((target_x - x - sizes[node].width,
y - sizes[node].sockets[socket],
target_y - sizes[link.to_node].sockets[link.to_socket]))
trunk_end = max(positions[node][0] for node in {*owners, *destinations})
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,
limits[0], limits[1] + height,
*(top + BRANCH_GAP + height for _, _, top, _ in occupied),
*(bottom - BRANCH_GAP for _, _, _, bottom in occupied)}
if front_allowed:
tops.add(baseline + height / 2)
for top in sorted(tops, key=lambda y: abs(y - height / 2 - baseline)):
if top > limits[0] or top - height < limits[1]:
continue
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)
for end, source_y, target_y in constraints)])
if horizontal and not minimum <= placed_left <= horizontal[1] - width:
continue
in_front = placed_left + width + BRANCH_GAP <= core_left
if not (in_front and front_allowed) and ((side == 1 and top - height < ceiling + BRANCH_GAP - 1e-5)
or (side == -1 and top > floor - BRANCH_GAP + 1e-5)):
continue
point = (placed_left, top)
if point in seen:
continue
seen.add(point)
distance = abs(placed_left - desired)
base_score = distance + abs(top - height / 2 - baseline) * 1.5
if best is not None and base_score > 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)
if best is None or candidate < best:
best = candidate
best_exits, best_routes = exits, routes
if best is None:
raise ValueError("The field region does not fit its reserved scope")
_, _, _, left, top = best
for node, (x, y) in local.items():
positions[node] = (x + left, y + top)
body = (left, left + width, top, top - height)
occupied.append(body)
space.bodies.append((set(local), body))
space.exits.extend(best_exits)
space.routes.extend(best_routes)
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
def _route_branch_exits(node_group, routes):
"""Materialize the channels reserved during placement, sharing trunk corners."""
deliveries, protected = defaultdict(list), set()
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 = {}
for point in sorted({point for _, points in branches for point in points}):
node = node_group.nodes.new("NodeReroute")
node["arrangement_branch"] = True
node.location = (point[0] - 8, point[1] + 8)
node_group.links.new(source, node.inputs[0])
source = corners[point] = node.outputs[0]
for link, points in branches:
if points:
_replace_link(node_group, link, corners[points[-1]])
return protected
def _route_peer_bypasses(node_group, sizes, positions, ancestors):
"""Use an entry and an exit corner for a branch that bypasses processing."""
for port in list(node_group.nodes):
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
continue
source = origin_socket(port.outputs[0])
junction = port.outputs[0].links[0].to_node
processed = [origin_socket(link.from_socket).node for link in peer_links(junction)
if source.node in ancestors[origin_socket(link.from_socket).node]]
if not processed:
continue
left = positions[source.node][0] + sizes[source.node].width
right, level = positions[port][0] + 8, positions[port][1] - 8
if right - left < ROUTE_GAP * 2:
continue
branch_nodes = {node for end in processed for node in ancestors[end] | {end}
if source.node in ancestors[node] and any(socket.type == "GEOMETRY" for socket in node.outputs)
and node.bl_idname != "NodeReroute"}
first = min(positions[node][0] for node in branch_nodes)
run = max(ROUTE_RUN, abs(level - positions[source.node][1] + sizes[source.node].sockets[source]) * 0.8)
x = min(right - ROUTE_GAP, max(left + min(run, (right - left) / 2), first - ROUTE_GAP))
entry = node_group.nodes.new("NodeReroute")
entry["arrangement_bypass"] = True
node_group.links.new(source, entry.inputs[0])
_replace_link(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(entry, 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 == entry:
delta = top + y - sizes[node].sockets[socket] + sizes[entry].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)
def arrange_nodes(node_group):
"""Fix geometry lanes, pack shallow field regions, and route shared references."""
_remove_routes(node_group)
parents, children, order, ancestors = graph(node_group)
_localize_peer_inputs(node_group, ancestors, children)
parents, children, order, ancestors = graph(node_group)
geometry_parents, paths = geometry_paths(node_group)
core = set(geometry_parents)
_localize_module_outputs(node_group, core, children)
_localize_inputs(node_group)
_presentation(node_group)
parents, children, order, ancestors = graph(node_group)
sizes = {node: estimate_node_dimensions(node) for node in order}
for node, size in sizes.items():
node.width = size.width
peers = [_peer_inputs(node, ancestors) for node in order]
regions = _field_regions(node_group.nodes, core, children, order)
for region, _ in regions:
outlets = [link.from_node for node in order if node in core for socket in node.inputs
for link in socket_links(socket) if link.from_node in region]
peers.append(list(dict.fromkeys(outlets)))
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(entry, interior, layout, sizes)
for node in core:
if entry in geometry_parents[node]:
spacing[node] = max(spacing.get(node, 0), span)
zones.append((entry, end, interior, fields, local, width, height))
column_parents = {node: list(sources) for node, sources in geometry_parents.items()}
for node in core:
if node.get("arrangement_input"):
source = origin_socket(node.outputs[0]).node
if source in core:
column_parents[node] = [source]
geometry_x, _ = _columns([node for node in order if node in core], column_parents, peers, sizes, GEOMETRY_GAP, spacing)
positions = _place_geometry_paths(paths, geometry_x, sizes, order)
occupied = _geometry_corridors(paths, positions, sizes)
trunks = {}
space = BranchSpace()
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
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)}
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))
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]),
delivery_order(item), order.index(item[0][0])))
pending.remove(layout)
region, targets, local, width, height = layout
consumers = list(dict.fromkeys(link.to_node for node in region for socket in node.outputs
for link in socket.links if link.to_node not in region and link.to_node in positions))
destinations = consumers or [node for node in order if node in targets] or [paths[0][-1]]
owners = [node for node in order if node in targets] or [paths[0][-1]]
_place_field_region(local, width, height, destinations, owners, positions, sizes, occupied, limits, core, trunks, space)
protected = _route_field_links(node_group, field_layouts, positions)
protected.update(_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)
while unused := [node for node in node_group.nodes
if node.bl_idname == "NodeReroute" and not node.outputs[0].is_linked]:
for node in unused:
node_group.nodes.remove(node)
_presentation(node_group)

View File

@@ -0,0 +1,39 @@
"""Socket endpoints and conservative curve footprints for placement and preview."""
from .flow import socket_links
def socket_position(socket, positions, sizes):
node = socket.node
x, y = positions[node]
if node.bl_idname == "NodeReroute":
return x + 8, y - 8
return x + (sizes[node].width if socket.is_output else 0), y - sizes[node].sockets[socket]
def link_endpoints(link, positions, sizes):
start = socket_position(link.from_socket, positions, sizes)
x, y = socket_position(link.to_socket, positions, sizes)
if link.to_socket.is_multi_input:
siblings = socket_links(link.to_socket)
y -= (siblings.index(link) - (len(siblings) - 1) / 2) * 8
return start, (x, y)
def curve_footprint(start, end):
"""Reserve eight convex hulls covering a horizontal-tangent Bezier curve."""
x1, y1 = start
x2, y2 = end
bend = max(40, abs(x2 - x1) * 0.42)
curves = [(start, (x1 + bend, y1), (x2 - bend, y2), end)]
for _ in range(3):
divided = []
for points in curves:
rows = [points]
for _ in range(3):
rows.append([((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
for a, b in zip(rows[-1], rows[-1][1:])])
divided.extend((tuple(row[0] for row in rows), tuple(row[-1] for row in reversed(rows))))
curves = divided
return [(min(x for x, _ in curve), max(x for x, _ in curve),
max(y for _, y in curve), min(y for _, y in curve)) for curve in curves]

View File

@@ -0,0 +1,70 @@
{
"blender_version": "4.5",
"header": 32,
"row": 24,
"padding": 12,
"width": 180,
"wide_width": 220,
"collapsed_height": 36,
"controls": {
"FunctionNodeBooleanMath": 1,
"FunctionNodeCompare": 2,
"FunctionNodeFloatToInt": 1,
"FunctionNodeIntegerMath": 1,
"FunctionNodeSeparateColor": 1,
"GeometryNodeAccumulateField": 2,
"GeometryNodeAttributeStatistic": 2,
"GeometryNodeBlurAttribute": 1,
"GeometryNodeBoundBox": 0,
"GeometryNodeCaptureAttribute": 3,
"GeometryNodeCornersOfEdge": 0,
"GeometryNodeDeleteGeometry": 2,
"GeometryNodeExtrudeMesh": 1,
"GeometryNodeFaceOfCorner": 0,
"GeometryNodeFieldAtIndex": 2,
"GeometryNodeFieldOnDomain": 2,
"GeometryNodeForeachGeometryElementInput": 0,
"GeometryNodeForeachGeometryElementOutput": 4,
"GeometryNodeFlipFaces": 0,
"GeometryNodeGroup": 1,
"GeometryNodeImageTexture": 2,
"GeometryNodeInputIndex": 0,
"GeometryNodeInputMeshEdgeNeighbors": 0,
"GeometryNodeInputMeshEdgeVertices": 0,
"GeometryNodeInputMeshFaceArea": 0,
"GeometryNodeInputMeshIsland": 0,
"GeometryNodeInputMeshVertexNeighbors": 0,
"GeometryNodeInputNamedAttribute": 1,
"GeometryNodeInputNormal": 0,
"GeometryNodeInputPosition": 0,
"GeometryNodeJoinGeometry": 0,
"GeometryNodeMenuSwitch": 3,
"GeometryNodeMergeByDistance": 1,
"GeometryNodeMeshCube": 0,
"GeometryNodeMeshGrid": 0,
"GeometryNodeRemoveAttribute": 1,
"GeometryNodeRepeatInput": 0,
"GeometryNodeRepeatOutput": 3,
"GeometryNodeSampleIndex": 3,
"GeometryNodeSampleNearest": 1,
"GeometryNodeSeparateGeometry": 1,
"GeometryNodeSetMaterialIndex": 0,
"GeometryNodeSetPosition": 0,
"GeometryNodeSetShadeSmooth": 1,
"GeometryNodeSimulationInput": 0,
"GeometryNodeSimulationOutput": 3,
"GeometryNodeSplitEdges": 0,
"GeometryNodeStoreNamedAttribute": 2,
"GeometryNodeSwitch": 1,
"GeometryNodeTransform": 1,
"NodeGroupInput": 0,
"NodeGroupOutput": 0,
"ShaderNodeClamp": 1,
"ShaderNodeCombineXYZ": 0,
"ShaderNodeMapRange": 3,
"ShaderNodeMath": 2,
"ShaderNodeMix": 3,
"ShaderNodeSeparateXYZ": 0,
"ShaderNodeVectorMath": 1
}
}

View File

@@ -0,0 +1,63 @@
"""Estimate node bounds and socket offsets in unscaled editor units."""
import json
from dataclasses import dataclass
from pathlib import Path
RULES = json.loads(Path(__file__).with_suffix(".json").read_text(encoding="utf-8"))
@dataclass(frozen=True)
class NodeDimensions:
width: float
height: float
sockets: dict
def visible_socket(socket):
"""Include only sockets drawn for the node's current operation."""
return socket.enabled and not socket.is_unavailable and not socket.hide
def estimate_node_dimensions(node):
"""Reserve socket rows, expanded vector values, and node-specific controls."""
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)]
outputs = [s for s in node.outputs if visible_socket(s)]
width = RULES["wide_width"] if (
"Attribute" in node.bl_idname or node.bl_idname == "GeometryNodeGroup"
) else RULES["width"]
if node.hide:
height = max(RULES["collapsed_height"], 16 + 12 * max(len(inputs), len(outputs)))
sockets = {
socket: (index + 1) * height / (len(side) + 1)
for side in (inputs, outputs)
for index, socket in enumerate(side)
}
return NodeDimensions(width, height, sockets)
# Conservative reserves include spacing between UI sections.
controls = RULES["controls"][node.bl_idname]
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "capture_items", "enum_items"):
controls += len(getattr(node, collection, ()))
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree)
cursor = RULES["header"] + RULES["padding"]
sockets = {}
for socket in outputs:
sockets[socket] = cursor + RULES["row"] / 2
cursor += RULES["row"]
cursor += controls * RULES["row"] + RULES["padding"]
for socket in inputs:
rows = 1
if not socket.is_linked and not socket.hide_value:
if socket.type in {"VECTOR", "ROTATION"}:
rows = 4
elif socket.type == "MATRIX":
rows = 5
extra = max(0, len(socket.links) - 1) * 8 if socket.is_multi_input else 0
sockets[socket] = cursor + RULES["row"] / 2 + extra / 2
cursor += rows * RULES["row"] + extra
return NodeDimensions(width, cursor + RULES["padding"], sockets)

View File

@@ -0,0 +1,196 @@
"""Lay out expression dependencies with ordered operands and reserved wire passages."""
from collections import defaultdict
from dataclasses import dataclass
from graphlib import TopologicalSorter
import numpy as np
from scipy.optimize import linprog
from .flow import peer_links
@dataclass
class FieldLayout:
positions: dict
width: float
height: float
routes: list
def field_layout(nodes, parents, peers, sizes, *, gap, target_height):
"""Order dependency layers, reserve long-link passages, and solve socket heights."""
roots = {node: node for node in nodes}
def root(node):
while roots[node] != node:
node = roots[node]
return node
for sources in peers:
members = [node for node in sources if node in roots]
if sum(sizes[node].height for node in members) + gap * (len(members) - 1) > target_height:
continue
for node in members[1:]:
roots[root(node)] = root(members[0])
groups = defaultdict(list)
for node in nodes:
groups[root(node)].append(node)
for sources in peers:
members = [node for node in sources if node in roots]
if members and all(root(node) == root(members[0]) for node in members):
groups[root(members[0])].sort(key=lambda node: sources.index(node) if node in sources else len(sources))
blocks = list(groups.values())
block_of = {node: index for index, block in enumerate(blocks) for node in block}
children = {index: set() for index in range(len(blocks))}
for node in nodes:
for parent in parents[node]:
if parent in block_of and block_of[parent] != block_of[node]:
children[block_of[parent]].add(block_of[node])
ranks = {}
for block in TopologicalSorter(children).static_order():
ranks[block] = max((ranks[child] + 1 for child in children[block]), default=0)
depth = max(ranks.values())
ranks = {block: depth - rank for block, rank in ranks.items()}
heights = [sum(sizes[node].height for node in block) + gap * (len(block) - 1) for block in blocks]
offsets = {}
for index, block in enumerate(blocks):
cursor = -heights[index] / 2
for node in block:
offsets[node] = cursor
cursor += sizes[node].height + gap
layers = defaultdict(list)
for block, rank in ranks.items():
layers[rank].append(block)
operand_columns = {rank for node in nodes for link in peer_links(node)
if link.from_node in block_of
for rank in range(ranks[block_of[link.from_node]], ranks[block_of[node]] + 1)}
# Keep an operand block and its junction together when spreading tall layers.
columns, column = defaultdict(list), 0
for rank in sorted(layers):
used = 0
for block in layers[rank]:
if rank not in operand_columns and used and used + gap + heights[block] > target_height:
column += 1
used = 0
columns[column].append(block)
ranks[block] = column
used += heights[block] + (gap if used else 0)
column += 1
layers = columns
depth = column - 1
links = [link for node in nodes for socket in node.inputs for link in socket.links if link.from_node in block_of]
edges, passages = [], []
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")
start_offset = offsets[link.from_node] + sizes[link.from_node].sockets[link.from_socket]
end_offset = offsets[link.to_node] + sizes[link.to_node].sockets[link.to_socket]
previous, previous_offset, path = first, start_offset, []
for rank in range(ranks[first] + 1, ranks[last]):
point = len(heights)
heights.append(16)
ranks[point] = rank
layers[rank].append(point)
edges.append((previous, point, previous_offset, 0))
previous, previous_offset = point, 0
path.append(point)
edges.append((previous, last, previous_offset, end_offset))
if path:
passages.append((link, path))
incoming, outgoing = defaultdict(list), defaultdict(list)
for first, last, a, b in edges:
outgoing[first].append((last, a, b))
incoming[last].append((first, b, a))
centers = {}
def stack(layer):
height = sum(heights[item] for item in layer) + sum(separation(a, b) for a, b in zip(layer, layer[1:]))
cursor = -height / 2
for index, item in enumerate(layer):
centers[item] = cursor + heights[item] / 2
cursor += heights[item] + (separation(item, layer[index + 1]) if index + 1 < len(layer) else 0)
def separation(first, last):
return gap if first < len(blocks) and last < len(blocks) else gap / 2
for layer in layers.values():
stack(layer)
for _ in range(4):
for ranks_in_order, adjacent in ((range(depth + 1), incoming), (range(depth, -1, -1), outgoing)):
for rank in ranks_in_order:
layer = layers[rank]
def desired(item):
neighbors = adjacent[item]
return sum(centers[other] + their_offset - own_offset
for other, own_offset, their_offset in neighbors) / len(neighbors) if neighbors else centers[item]
layer.sort(key=lambda item: (desired(item), item))
stack(layer)
count = len(heights)
variable_count = count + 1 + len(edges)
inequalities, bounds, equalities, values = [], [], [], []
def constraint(terms, maximum):
row = np.zeros(variable_count)
for index, coefficient in terms:
row[index] += coefficient
inequalities.append(row)
bounds.append(maximum)
for index, height in enumerate(heights):
constraint([(index, -1)], -height / 2)
constraint([(index, 1), (count, -1)], -height / 2)
for layer in layers.values():
for first, last in zip(layer, layer[1:]):
constraint([(first, 1), (last, -1)], -(heights[first] + heights[last]) / 2 - separation(first, last))
for index, (first, last, a, b) in enumerate(edges):
slack = count + 1 + index
constraint([(first, 1), (last, -1), (slack, -1)], b - a)
constraint([(last, 1), (first, -1), (slack, -1)], a - b)
for node in nodes:
inputs = [link.from_socket for link in peer_links(node)]
if len(inputs) < 2 or any(socket.node not in block_of for socket in inputs):
continue
output = next(socket for socket in node.outputs if socket in sizes[node].sockets)
first, last = inputs[0], inputs[-1]
row = np.zeros(variable_count)
row[block_of[node]] += 2
row[block_of[first.node]] -= 1
row[block_of[last.node]] -= 1
equalities.append(row)
values.append(offsets[first.node] + sizes[first.node].sockets[first]
+ offsets[last.node] + sizes[last.node].sockets[last]
- 2 * (offsets[node] + sizes[node].sockets[output]))
objective = np.zeros(variable_count)
objective[count] = len(edges) + 1
objective[count + 1:] = 1
result = linprog(objective, A_ub=np.array(inequalities), b_ub=bounds,
A_eq=np.array(equalities) if equalities else None, b_eq=values or None,
bounds=[(None, None)] * count + [(0, None)] * (1 + len(edges)), method="highs")
if not result.success:
raise ValueError(f"Expression layers could not be placed: {result.message}")
centers = result.x[:count]
positions, point_positions, right = {}, {}, 0
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)
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
for item in layer:
if item < len(blocks):
for node in blocks[item]:
positions[node] = (right - sizes[node].width, -centers[item] - offsets[node])
else:
point_positions[item] = (right, -centers[item])
routes = []
for link, path in passages:
points = [point_positions[item] for item in path]
points = [point for index, point in enumerate(points) if index == 0 or index == len(points)-1
or not abs(points[index-1][1] - point[1]) < 1e-6 or not abs(points[index+1][1] - point[1]) < 1e-6]
routes.append((link, points))
return FieldLayout(positions, right, result.x[count], routes)

View File

@@ -0,0 +1,151 @@
"""Infer geometry flow and socket relationships for layout and preview."""
import json
from collections import defaultdict
from graphlib import TopologicalSorter
from pathlib import Path
from .dimensions import visible_socket
SEMANTICS = json.loads(Path(__file__).with_name("semantics.json").read_text(encoding="utf-8"))
SHARED_GEOMETRY_USES = 3
def socket_links(socket):
return sorted(socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)
def origin_socket(socket):
while socket.node.bl_idname == "NodeReroute":
socket = socket.node.inputs[0].links[0].from_socket
return socket
def graph(node_group):
def key(node):
if node.bl_idname in {"NodeGroupInput", "NodeReroute"}:
return (1, tuple(sorted((link.to_node.name, link.to_socket.identifier,
socket_links(link.to_socket).index(link))
for socket in node.outputs for link in socket.links)))
return (0, node.name)
nodes = sorted(node_group.nodes, key=key)
parents = {node: [] for node in nodes}
children = {node: set() for node in nodes}
for link in node_group.links:
if link.from_node not in parents[link.to_node]:
parents[link.to_node].append(link.from_node)
children[link.from_node].add(link.to_node)
for sources in parents.values():
sources.sort(key=key)
order = list(TopologicalSorter(parents).static_order())
ancestors = {}
for node in order:
ancestors[node] = set(parents[node]) | set().union(*(ancestors[p] for p in parents[node]))
return parents, children, order, ancestors
def geometry_paths(node_group):
"""Trace every geometry input and split paths at forks and merges."""
output = next((node for node in node_group.nodes if node.bl_idname == "NodeGroupOutput" and node.is_active_output), None)
parents = {}
uses = defaultdict(set)
for link in node_group.links:
if link.from_socket.type == "GEOMETRY" and link.to_node.bl_idname != "NodeReroute":
uses[origin_socket(link.from_socket).node].add(link.to_node)
paired = {member for node in node_group.nodes if getattr(node, "paired_output", None) is not None
for member in (node, node.paired_output)}
result_nodes, pending = set(), [*paired, *([output] if output else [])]
while pending:
node = pending.pop()
if node in result_nodes:
continue
result_nodes.add(node)
pending.extend(origin_socket(link.from_socket).node
for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links)
shared = {node for node, targets in uses.items()
if len(targets) >= SHARED_GEOMETRY_USES and node not in paired
and (not any(socket.type == "GEOMETRY" and socket.is_linked for socket in node.inputs)
or len(targets & result_nodes) >= SHARED_GEOMETRY_USES)}
def source_node(link):
return link.from_node if link.from_node.get("arrangement_input") else origin_socket(link.from_socket).node
def collect(node):
if node in parents:
return
parents[node] = list(dict.fromkeys(
source_node(link) for socket in node.inputs if socket.type == "GEOMETRY"
for link in socket_links(socket)
if source_node(link).bl_idname != "NodeGroupInput" and source_node(link) not in shared
)) if not node.get("arrangement_input") else []
for parent in parents[node]:
collect(parent)
roots = []
if output is not None:
collect(output)
roots.append(output)
for node in node_group.nodes:
if node in paired and node not in parents:
collect(node)
roots.append(node)
children = {node: set() for node in parents}
for node, sources in parents.items():
for source in sources:
children[source].add(node)
paths, assigned, pending = [], set(), roots
while pending:
node = pending.pop(0)
path = []
while node not in assigned:
path.append(node)
assigned.add(node)
sources = parents[node]
if len(sources) != 1 or len(children[sources[0]]) != 1:
pending.extend(source for source in sources if source not in assigned)
break
node = sources[0]
if path:
paths.append(list(reversed(path)))
return parents, paths
def shared_geometry_outputs(nodes):
"""Find geometry outputs reused by several distinct operations."""
shared = []
for node in nodes:
for socket in node.outputs:
if socket.type != "GEOMETRY":
continue
targets, pending = set(), [link.to_node for link in socket.links]
while pending:
target = pending.pop()
if target.bl_idname == "NodeReroute":
pending.extend(link.to_node for link in target.outputs[0].links)
else:
targets.add(target)
if len(targets) >= SHARED_GEOMETRY_USES:
shared.append(socket)
return shared
def peer_links(node):
"""Read ordered data operands from the semantic table, excluding controls."""
rule = SEMANTICS.get(node.bl_idname)
if rule is None or ("operations" in rule and node.operation not in rule["operations"]):
return []
return [link for socket in list(node.inputs)[rule["peer_inputs_from"]:]
if visible_socket(socket) and socket.type != "CUSTOM"
for link in socket_links(socket)]
def node_roles(node_group):
"""Classify by contribution to the result, not by available socket types."""
core, _ = geometry_paths(node_group)
return {node: "geometry" if node in core else "field" for node in node_group.nodes}

View File

@@ -0,0 +1,10 @@
{
"GeometryNodeJoinGeometry": {"peer_inputs_from": 0},
"GeometryNodeMenuSwitch": {"peer_inputs_from": 1},
"GeometryNodeSwitch": {"peer_inputs_from": 1},
"ShaderNodeCombineXYZ": {"peer_inputs_from": 0},
"FunctionNodeBooleanMath": {
"peer_inputs_from": 0,
"operations": ["AND", "OR", "NAND", "NOR", "XNOR", "XOR", "IMPLY", "NIMPLY"]
}
}

View File

@@ -0,0 +1,28 @@
"""Identify paired execution zones and their exclusive input calculations."""
from graphlib import TopologicalSorter
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}
children = {node: set() for node in node_group.nodes}
for link in node_group.links:
parents[link.to_node].add(link.from_node)
children[link.from_node].add(link.to_node)
order = list(TopologicalSorter(parents).static_order())
ancestors = {}
for node in order:
ancestors[node] = parents[node] | set().union(*(ancestors[parent] for parent in parents[node]))
zones = []
for entry in order:
end = getattr(entry, "paired_output", None)
if end is None:
continue
members = {node for node in ancestors[end] if entry in ancestors[node]} | {entry, end}
for node in reversed(order):
if (node not in members and node in ancestors[end] and children[node]
and children[node] <= members and entry not in children[node]):
members.add(node)
zones.append((entry, end, members))
return zones

48
tools/nodes/build.py Normal file
View File

@@ -0,0 +1,48 @@
"""Build, arrange, and save the bundled node library."""
from pathlib import Path
import bpy
from .arrangement.arrange import arrange_nodes
from .groups.image_plane import build_image_plane_group
from .groups.image_depth_plane import build_image_depth_plane_group
from .groups.image_relief_plane import build_image_relief_plane_group
from .groups.image_cutout import build_image_cutout_group
from .groups.image_depth_cutout import build_image_depth_cutout_group
from .groups.image_depth_balloon import build_image_depth_balloon_group
from .groups.image_layer import build_image_layer_group
from .groups.image_depth_layer import build_image_depth_layer_group
from .groups.shadeless import build_shadeless_group
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
def build_node_groups():
"""Build each asset once in dependency order."""
image_plane = build_image_plane_group()
return [
image_plane,
build_image_depth_plane_group(image_plane),
build_image_relief_plane_group(image_plane),
build_image_cutout_group(),
build_image_depth_cutout_group(),
build_image_depth_balloon_group(),
build_image_layer_group(),
build_image_depth_layer_group(),
build_shadeless_group(),
]
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.filepaths.save_version = 0
for node_group in build_node_groups():
if node_group.bl_idname == "GeometryNodeTree":
arrange_nodes(node_group)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)
if __name__ == "__main__":
main()

71
tools/nodes/check.py Normal file
View File

@@ -0,0 +1,71 @@
"""Reload saved assets and evaluate geometry in the target Blender process."""
from math import isfinite
from pathlib import Path
import bpy
ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
GEOMETRY_NAMES = {
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Balloon",
}
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
def check_asset_library(asset_path):
"""Check library completeness and finite, nonempty evaluated geometry."""
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES
target.node_groups = list(source.node_groups)
depth_image = bpy.data.images.new("Asset Check Depth", width=8, height=8, float_buffer=True)
depth_image.colorspace_settings.name = "Non-Color"
depth_image.pixels.foreach_set([
value for y in range(8) for x in range(8)
for value in ((x + 0.5) / 8 - 0.5, (y + 0.5) / 8 - 0.5, 2.0, 1.0)
])
for node_group in target.node_groups:
expected_type = "GeometryNodeTree" if node_group.name in GEOMETRY_NAMES else "ShaderNodeTree"
assert node_group.bl_idname == expected_type and node_group.library is not None, node_group.name
if expected_type == "ShaderNodeTree":
continue
mesh = bpy.data.meshes.new("Asset Check Plane")
mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], [], [(0, 1, 2, 3)])
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
position = mesh.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (position.x + 0.5, position.y + 0.5)
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
for value in profile.data:
value.value = 0.2
obj = bpy.data.objects.new("Asset Check", mesh)
bpy.context.collection.objects.link(obj)
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}
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]
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) and len(result.polygons), node_group.name
assert all(isfinite(value) for vertex in result.vertices for value in vertex.co), node_group.name
finally:
evaluated.to_mesh_clear()
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.meshes.remove(mesh)
bpy.data.images.remove(depth_image)
print(f"Checked {len(target.node_groups)} saved node groups in Blender {bpy.app.version_string}")
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
check_asset_library(ASSET_PATH)
if __name__ == "__main__":
main()

View File

@@ -4,14 +4,14 @@ import shutil
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from zipfile import ZipFile
PROJECT_ROOT = Path(__file__).resolve().parents[1]
BLENDER_SCRIPTS = (
"asset_library.py",
"asset_validation.py",
"image_plane_validation.py",
"cutout_validation.py",
PROJECT_ROOT = Path(__file__).resolve().parents[2]
BLENDER_MODULES = (
"tools.nodes.build",
"tools.nodes.check",
)
@@ -73,14 +73,24 @@ def main():
options = parser.parse_args()
executable = blender_path(options.blender)
for script_name in BLENDER_SCRIPTS:
with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory:
for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"):
with ZipFile(wheel) as archive:
archive.extractall(Path(directory) / wheel.name.split("-")[2])
paths = [str(PROJECT_ROOT), str(PROJECT_ROOT / "src"),
str(next((PROJECT_ROOT / "src/anyimage/wheels").glob("blendjob-*.whl")))]
bootstrap = (
f"import sys, runpy; sys.path[:0] = {paths!r}; "
f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); "
)
for module_name in BLENDER_MODULES:
run(
(
executable,
"--factory-startup",
"--background",
"--python",
PROJECT_ROOT / "tools" / "node_assets" / script_name,
"--python-exit-code", "1",
"--python-expr", bootstrap + f"runpy.run_module({module_name!r}, run_name='__main__')",
)
)
if not options.skip_tests:

View File

@@ -0,0 +1 @@

Some files were not shown because too many files have changed in this diff Show More