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:
22
AGENTS.md
22
AGENTS.md
@@ -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()` 新建
|
||||
|
||||
@@ -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 与文档导航
|
||||
|
||||
|
||||
@@ -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` 等备份不应提交或打包。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
44
openspec/changes/add-image-layer-alpha-adjustment/design.md
Normal file
44
openspec/changes/add-image-layer-alpha-adjustment/design.md
Normal 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,创建时按用户偏好启用,保留每个实例的强度控制。
|
||||
- 工作区已有材质与节点资产修改 → 在当前实现上追加变更,保留已有修改并检查最终差异。
|
||||
- 旧资产缺少输入 → 验证资产接口与运行时代码一致,正式发布配套资产。
|
||||
@@ -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` 需要包含新接口;本轮仅生成提案。
|
||||
@@ -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 使用当前偏好初始化
|
||||
18
openspec/changes/add-image-layer-alpha-adjustment/tasks.md
Normal file
18
openspec/changes/add-image-layer-alpha-adjustment/tasks.md
Normal 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 严格校验通过。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
@@ -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 获得新产物。仅回退本变更引入的修改即可恢复旧行为。
|
||||
@@ -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 语义。实现只运行测试,不同步内部文档或构建节点资产、扩展包;无新增依赖。
|
||||
@@ -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
|
||||
@@ -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。最终差异及写入调用检查通过;本变更未修改几何节点脚本、资产或节点测试。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
68
openspec/changes/split-depth-and-relief-planes/design.md
Normal file
68
openspec/changes/split-depth-and-relief-planes/design.md
Normal 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 检查已获用户授权。
|
||||
28
openspec/changes/split-depth-and-relief-planes/proposal.md
Normal file
28
openspec/changes/split-depth-and-relief-planes/proposal.md
Normal 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 视觉验收单独安排。
|
||||
@@ -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** 节点连接、运算、默认值和域设置与参考图一致,有效与无效区域交界网格的保留结果符合该图规则
|
||||
@@ -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** 几何位置与未平滑结果相同
|
||||
39
openspec/changes/split-depth-and-relief-planes/tasks.md
Normal file
39
openspec/changes/split-depth-and-relief-planes/tasks.md
Normal 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 阻塞,待该工具修复后复验。
|
||||
@@ -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.
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from .operators import (
|
||||
ConvertToDepthPlane,
|
||||
ConvertToReliefPlane,
|
||||
ConvertToPlane,
|
||||
GenerateDepthPlane,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"ConvertToDepthPlane",
|
||||
"ConvertToReliefPlane",
|
||||
"ConvertToPlane",
|
||||
"GenerateDepthPlane",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
5
tests/conftest.py
Normal 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"))
|
||||
@@ -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)
|
||||
|
||||
22
tests/node_asset_test_support.py
Normal file
22
tests/node_asset_test_support.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
139
tests/test_image_layer_alpha.py
Normal file
139
tests/test_image_layer_alpha.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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__":
|
||||
|
||||
683
tests/test_node_arrangement.py
Normal file
683
tests/test_node_arrangement.py
Normal 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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
1
tools/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Model preparation, export, and upload tools."""
|
||||
@@ -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):
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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))))}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
1
tools/nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tools/nodes/arrangement/__init__.py
Normal file
1
tools/nodes/arrangement/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
769
tools/nodes/arrangement/arrange.py
Normal file
769
tools/nodes/arrangement/arrange.py
Normal 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)
|
||||
39
tools/nodes/arrangement/connections.py
Normal file
39
tools/nodes/arrangement/connections.py
Normal 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]
|
||||
70
tools/nodes/arrangement/dimensions.json
Normal file
70
tools/nodes/arrangement/dimensions.json
Normal 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
|
||||
}
|
||||
}
|
||||
63
tools/nodes/arrangement/dimensions.py
Normal file
63
tools/nodes/arrangement/dimensions.py
Normal 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)
|
||||
196
tools/nodes/arrangement/fields.py
Normal file
196
tools/nodes/arrangement/fields.py
Normal 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)
|
||||
151
tools/nodes/arrangement/flow.py
Normal file
151
tools/nodes/arrangement/flow.py
Normal 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}
|
||||
10
tools/nodes/arrangement/semantics.json
Normal file
10
tools/nodes/arrangement/semantics.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
28
tools/nodes/arrangement/zones.py
Normal file
28
tools/nodes/arrangement/zones.py
Normal 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
48
tools/nodes/build.py
Normal 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
71
tools/nodes/check.py
Normal 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()
|
||||
@@ -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:
|
||||
1
tools/nodes/common/__init__.py
Normal file
1
tools/nodes/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user