Refactor geometry node groups for image depth processing

- Introduced new utility functions in common nodes for evaluating fields and capturing attributes.
- Simplified volume field calculations in image depth cutout and panorama groups by utilizing new utility functions.
- Enhanced readability and maintainability of the image depth cutout and panorama node groups by removing redundant code and improving function usage.
- Adjusted default values for thickness and depth scale in image relief plane group for better user experience.
- Improved handling of geometry attributes and connections in image depth plane group to streamline processing.
This commit is contained in:
2026-09-10 16:46:19 +08:00
parent 489003c2cc
commit 8ae3c1d5b7
20 changed files with 553 additions and 473 deletions

View File

@@ -47,7 +47,7 @@ uv run pytest
uv run pack
# 构建并验证 Blender 节点资产
uv run build-nodes
uv run node-group build
# 准备、校验并同步 ONNX 模型到 R2
uv run sync-models
@@ -90,5 +90,9 @@ uv run sync-models
- 节点组统一保存于 `src/anyimage/assets/O_AnyImage.blend`。
- 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。
- `tools/nodes` 构建脚本和测试允许通过代码创建节点组。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run build-nodes`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。
- 字段默认直接连接,按消费位置的几何、域、类型及输入属性版本验证求值等价性;多个消费者可复用同一输出。
- 域转换显式表达;跨变形、拓扑或分支变化需要保留源值时,使用 Capture Attribute 并确保捕获值随几何传递。
- 命名属性用于需要按名称访问的数据协议,明确名称、类型、域与消费者;内部临时命名属性按专用前缀在最后消费者之后清理。
- 节点布局通过区域内输入、字段排列和必要的 Reroute 保持可读;存储须有数据生命周期依据,构建阶段通过明确的节点或 socket 引用接线。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run node-group build`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。
- 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。

View File

@@ -2,21 +2,33 @@
## 构建节点资产
生产节点组由 `uv run build-nodes` 构建到 `src/anyimage/assets/O_AnyImage.blend`
生产节点组由 `uv run node-group build` 构建到 `src/anyimage/assets/O_AnyImage.blend`
节点组文件在 `tools/nodes/groups`
节点组规则:
- Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线
- 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV
- Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开
- 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性
- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨变形、拓扑或分支变化需要固定源值时,使用 Capture Attribute,明确捕获时机并验证几何传播路径
- 命名属性用于需要按名称访问的数据协议,明确名称、类型、域、写入选择和消费者;内部临时属性使用专用 `o_*` 前缀,在最后消费者之后按前缀清理,保留 UV、用户属性和外部协议属性
- Group Input 按区域重复放置,通过字段排列和必要的 Reroute 保持连线可读,隐藏未使用输出;存储须有数据生命周期依据
- 构建阶段通过明确的节点或 socket 引用接线
- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域
- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见
- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE`
- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认
- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变
```mermaid
flowchart TD
A[字段生产与消费] --> B{需要按名称访问}
B -->|是| C[命名属性]
B -->|否| D{目标上下文重新求值是否等价}
D -->|是| E[字段直连并显式转换域]
D -->|否| F[在源几何与源域捕获]
F --> G[随几何传递匿名属性]
```
## 资产清单
@@ -31,6 +43,7 @@
| `O Image Cutout` | `image_cutout.py` |
| `O Image Depth Cutout` | `image_depth_cutout.py` |
| `O Image Depth Balloon` | `image_depth_balloon.py` |
| `O Image Depth Panorama` | `image_depth_panorama.py` |
## 构建资产脚本
@@ -40,8 +53,8 @@
保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。
```bash
uv run build-nodes --blender "/path/to/blender"
uv run build-nodes --skip-tests
uv run node-group build --blender "/path/to/blender"
uv run node-group build --skip-tests
```
脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置:

View File

@@ -0,0 +1,3 @@
schema: spec-driven
skip_specs: true
created: 2026-09-10

View File

@@ -0,0 +1,79 @@
## Context
动机见 `proposal.md`。`tools/nodes/build.py` 构建七个几何节点组和三个材质节点组;材质组作为属性消费者纳入检查。现有测试包含真实 Blender 求值,也有通过临时属性或默认节点编号定位中间阶段的用例。
`docs/internals/node-assets.md` 当前按跨区域距离推荐命名属性。Depth Cutout 的 `_depth_volume` 同时依赖 `Store Named Attribute.005`、`Math.018` 等默认编号,并通过几何输入反查阶段。删除存储节点必须同步调整这些连接。
## Goals / Non-Goals
以等价几何、求值域、数据类型、属性版本和求值时机为验收边界,简化字段传递。修改器接口、几何结果及对外属性协议保持一致。
## Decisions
### 按实际消费者判断字段传递
以构建入口为清单,逐项记录 Store 的名称、类型、域、Selection、输入几何、读者及读取位置。重复写入同名属性按不同版本追踪,检查输入网格和材质中的外部读写;消费次数仅作为排查线索。
```mermaid
flowchart TD
A[字段生产与消费] --> B{需要按名称对外访问}
B -->|是| C[命名属性及明确的生命周期]
B -->|否| D{目标上下文重新求值是否等价}
D -->|是| E[直接连接字段]
E --> F[显式表达必要的域与类型转换]
D -->|否| G[在源几何和源域捕获字段]
G --> H[随几何传递匿名属性并验证传播]
```
直连允许多个消费者。跨区域或连线较长交给布局处理。只要几何、域或输入属性版本变化会改变字段结果,即使只有一个消费者,也保留求值快照。Capture 必须接入产生该快照的几何链,并沿分支传播至消费者;确有按名称访问需求时才使用 Store Named Attribute。
### 已定位的调整清单
下表是源码核查结果;涉及上下文传播的候选必须通过 Blender 求值确认后落地。
| 范围 | 当前字段或链路 | 调整判断 |
| --- | --- | --- |
| 共用 Depth Surface;Depth Plane、Depth Cutout、Panorama | `o_depth_face_center`、`o_depth_face_camera` | 相邻面采样和分裂前计算优先直接连接;显式保持 FACE 求值,尤其保证图像在面中心采样后再用于角域,避免变成逐点采样的平均值 |
| 共用 Depth Surface | `o_depth_camera` | 当前结果写入 POINT 后只供紧接的投影字段使用,优先改为返回字段;保持 CORNER 到 POINT 的转换顺序 |
| 共用 Depth Surface | `o_depth_corner_camera`、`o_depth_cut_vertex` | 跨 Split Edges 和删面保存分裂前数据;保留快照,优先用匿名属性传递 |
| 共用厚度构建 | `o_depth_surface_position`、`o_depth_source_vertex`、`o_depth_surface_normal` | 跨展平、挤出、合并、恢复位置使用;保留源位置、身份和法线快照,验证匿名属性在所有分支上的传播 |
| Depth Cutout | 中心 seed 写入 `o_balloon_surface_center`,读取做 Blur,再覆盖同名属性 | seed 直接接 Blur;最终中心值跨壳体变形的快照独立保存,避免改变 Blur 权重和域 |
| Depth Cutout | `rim`、`center`、`thickness`、`edge_weight`、`front_delta`、`normal`、`structure`、`front_normal`、`inflation_normal` | 逐版本核对计算与壳体消费者;局部算术直连,源表面上的 Blur、Normal、边界与统计结果在变形前固定;Uniform 与 Balloon 分支都提供消费者所需数据 |
| Panorama | `o_panorama_direction`、`o_panorama_uv`、`o_panorama_distance`、`o_panorama_invalid` | 同时检查删面、分裂、极点和周期采样;等价上下文的 UV 与采样直连,需保留原球面数据的路径捕获;invalid 的 POINT 到 FACE 聚合需显式保留 |
| Image Plane | Grid、Cube 写入 `UVMap` | 输出 UV 是材质接口,保留命名属性及 CORNER 域 |
| Image Cutout、Depth Balloon、Depth Cutout | `o_balloon`、`o_normal_scale` | 核对 Shape 输入和材质消费者,保留命名协议、正背面掩码和域 |
| Relief Plane 及三个材质组 | UV 与材质属性读取 | 检查实际生产者及外部消费边界,保证现有着色与位移行为 |
不采用按读者数量自动消除 Store 的图优化器:域转换、同名覆盖和隐式字段上下文需要业务判断,直接修改构建函数更容易验证。
### 通过函数返回值连接阶段
共享构建函数按需要返回几何 socket 与字段 socket,调用方显式传递。Depth Cutout 直接获得投影表面、厚度构建和恢复位置等阶段引用,替换本次影响到的默认编号定位和节点输入反查。简单场景用普通返回值或元组;确需多个有名称的输出时用小型字典。
复用 `tools/nodes/common` 的现有辅助函数;仅在多个实际调用方共享同一操作时提取公共函数。完成调用方迁移后删除失去业务用途的存取辅助函数与临时属性清理节点。
### 规范修改内容
在 `AGENTS.md` 的 Blender 节点组规范加入简短原则,在 `docs/internals/node-assets.md` 替换现有三条字段入口和暂存规则:
- 字段默认直接连接,多个消费者可复用同一输出;以实际消费位置的几何、域、类型和输入属性版本验证等价性。
- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨几何变化需要固定源值时使用 Capture Attribute,并明确捕获时机和传播路径。
- 命名属性用于需要按名称访问的数据协议;明确名称、数据类型、域、写入选择和消费者。内部临时命名属性使用专用 `o_*` 前缀,在最后消费者之后按该前缀清理,保留 UV 和外部协议属性。
- 布局通过区域内 Group Input、字段分支排列和必要的 Reroute 保持可读;新增存储须有数据生命周期依据。
- 构建阶段通过明确节点或 socket 引用接线。
### 验证与资产交付
先记录当前真实几何行为和各组存取节点数量,作为结果与简化规模的基线。数量只用于报告;测试以等价求值和必要数据边界为主。
复用并补充面域与角域不一致的非均匀深度、Split 开关、零厚度及正厚度、独立重叠表面、Balloon/Uniform 与法线平滑、Panorama 接缝与无效像素场景。对不再存在的临时属性测试,改为通过测试输出捕获待验证字段或检查最终网格,不为测试保留生产命名属性。
运行相关节点与材质测试,执行 `uv run node-group build`,完成保存后独立 Blender 加载验证,再运行 `uv run pytest` 并确认退出成功。检查重建资产的接口、布局、UV、材质属性及临时属性清理;源码、测试、规范和 `.blend` 资产作为一个完整变更交付。
## Risks / Trade-offs
- 字段直连推迟求值或改变域转换顺序 → 使用面内非均匀输入验证;保留明确的域转换和必要快照。
- 匿名属性跨分支、挤出或合并丢失 → 核对每条几何来源,并检查最终网格的顶点身份、封口和法线。
- 同名属性覆盖导致读取错误版本 → 拆出不同阶段的字段引用,明确消费者依赖的版本。
- 删除存储改变默认节点编号 → 同步替换受影响的隐式接线,检查最终真实连接。
- 直连使布局跨度增加或重复计算 → 使用现有排列器验证可读性;只有实际求值证据支持时引入捕获。

View File

@@ -0,0 +1,29 @@
## Why
几何节点中存在仅为传递中间字段而写入、读取命名属性的链路,增加节点数量和隐式依赖。现行节点资产规范以跨区域连线作为命名存储的依据,需要统一改为按字段求值上下文与数据生命周期选择传递方式。
## What Changes
- 全面检查构建入口中的几何节点组及共享构建函数,沿几何链、字段链和外部消费者记录命名属性的存取域、求值时机与用途。
- 同一几何状态与等价求值域中的中间字段优先直连;显式保留必要的域转换,删除冗余存取及失去用途的清理节点和辅助函数。
- 需要固定变形前、拓扑变化前或分支源几何上的值时优先使用 Capture Attribute;需要按名称供材质、输入网格或其他消费者访问的属性继续使用命名属性。
- 调整受节点数量变化影响的构建接线,使用明确返回的节点或 socket 引用表达业务连接。
- 更新 `AGENTS.md` 与 `docs/internals/node-assets.md` 的节点属性规范,按新规范统一整理节点连线。
- 更新相关测试,重建并验证 `.blend` 资产,确认几何、UV、材质与修改器接口行为一致。
## Capabilities
### New Capabilities
无。本变更属于内部节点重构与开发规范调整,使用 `skip_specs: true`。
### Modified Capabilities
无。保持现有公开接口和几何行为。
## Impact
- `tools/nodes/common`、`tools/nodes/groups`,重点是 Depth Surface、Depth Cutout 和 Panorama。
- `tools/nodes/arrangement` 的现有布局能力与相关测试需验证能正确处理直连及匿名属性;仅在发现实际问题时修改。
- `tests/tools/nodes`、相关 Operator 与材质测试、`src/anyimage/assets/O_AnyImage.blend`。
- `AGENTS.md`、`docs/internals/node-assets.md`。

View File

@@ -0,0 +1,25 @@
## 1. 核查与基线
- [x] 1.1 按 `build_node_groups()` 完成七个几何组、共享函数与材质消费者的属性存取清单,记录每次写入的域、类型、Selection、版本、消费位置及保留或替换依据,在本变更中交付核查结果。
- [x] 1.2 运行现有节点测试,记录各组 Store/Named/Capture 节点数量及代表场景的几何、UV 和材质属性基线,确认测试正常退出并区分已有失败。
## 2. 整理节点字段传递
- [x] 2.1 将 Depth Cutout 受影响的阶段连接改为显式函数返回值与 socket 引用,移除自动编号定位和输入反查;用现有 Depth Cutout 求值测试验证连接等价。
- [x] 2.2 简化共用 Depth Surface 的面中心、面相机值和投影相机值传递,显式保持域转换;用非均匀面深度、Split 开关和零厚度测试验证结果。
- [x] 2.3 为分裂、删面与厚度构建中必须保留的源位置、角值、切分标记、顶点身份和法线建立必要捕获,迁移调用方;通过断层封口、独立重叠表面和法线测试验证快照传播。
- [x] 2.4 将 Depth Cutout 的中心 seed 直接接 Blur,并按属性版本简化局部体积计算、保存必要表面快照;通过 Balloon/Uniform、厚度连续性、边缘圆化、前表面膨胀和法线平滑测试验证行为。
- [x] 2.5 逐项简化 Panorama 的方向、UV、距离和无效标记传递,保持 POINT/FACE 聚合和球面采样时机;通过周期接缝、极点、无效深度边缘、Valid Only 与 Split 测试验证行为。
- [x] 2.6 按清单完成 Image Plane、Relief Plane、Image Cutout、Depth Balloon 及材质边界检查,保留必要 UV 与命名协议;通过对应节点和材质测试确认域、属性值及着色输入一致。
- [x] 2.7 删除失去业务用途的存取函数、读节点和清理节点,调整依赖中间属性的测试;检索确认旧调用消失,并验证所有保留的临时命名属性在最后消费者后清理。
## 3. 更新规范与布局验证
- [x] 3.1 按设计更新 `AGENTS.md` 与 `docs/internals/node-assets.md`,明确直连、域转换、捕获、命名协议与显式接线的选择依据;检查两处规范一致且已替换跨区域即存储的旧规则。
- [x] 3.2 验证现有排列器对直连和匿名属性的处理,仅修复实际出现的布局问题;通过 arrangement 与 saved layout 测试,并检查各组字段连线可读、未使用输出隐藏、接口顺序与默认值一致。
## 4. 重建与整体交付
- [x] 4.1 运行 `uv run node-group build`,更新 `src/anyimage/assets/O_AnyImage.blend`,确认构建、保存后独立 Blender 加载和求值验证全部成功。
- [x] 4.2 运行 `uv run pytest` 并确认正常退出,对照基线检查相关几何、UV、材质属性和修改器接口结果,修复本次变更引入的失败。
- [x] 4.3 检查最终差异、旧引用和临时属性清理,交付逐组调整结论、存取节点数量变化及测试结果,确认源码、测试、规范和 `.blend` 资产同步完成。

View File

@@ -0,0 +1,62 @@
# 节点属性核查与验证
## 存取清单
核查入口为 `tools/nodes/build.py` 的全部七个几何组和三个材质组。以下覆盖原有 44 次 Store 写入;其 Selection 均为常量 True。重复名称按几何阶段区分版本,表中标明重复写入次数。
| 属性 | 原域 / 类型 | 生产、消费与处理 |
| --- | --- | --- |
| `o_depth_face_center`(3 组) | FACE / FLOAT_VECTOR | Depth Plane、Depth Cutout、Panorama 分裂前的面中心,供相邻面距离采样;直接由 FACE 域的 Sample Index 求值 |
| `o_depth_face_camera`(3 组) | FACE / FLOAT_VECTOR | 面中心 UV 采样结果供断层判断及角值捕获;改为 FACE Evaluate on Domain 后直连 |
| `o_depth_corner_camera`(3 组) | CORNER / FLOAT_VECTOR | 分裂、删面前的各面相机值,供切分后投影;与切分标记一起捕获 |
| `o_depth_cut_vertex`(3 组) | CORNER / BOOLEAN | 分裂前 EDGE→POINT 标记,供分裂后的射线调整;在 CORNER 域捕获,保持原转换顺序 |
| `o_depth_camera`(3 组) | POINT / FLOAT_VECTOR | 射线调整结果紧接投影使用;改为 POINT Evaluate on Domain 后直连 |
| `o_depth_surface_position`(2 组) | POINT / FLOAT_VECTOR | Depth Plane、Depth Cutout 展平前的位置,供合并后恢复;与顶点身份一起捕获 |
| `o_depth_source_vertex`(2 组) | POINT / INT | 挤出前身份,供前后盖和侧壁合并;与位置在同拓扑下捕获 |
| `o_depth_surface_normal` | POINT / FLOAT_VECTOR | Depth Plane 的源面平滑法线,供恢复壳体厚度;合并到源几何捕获 |
| `o_panorama_direction` | POINT / FLOAT_VECTOR | 球面方向供 UV、面中心和径向投影;消费前位置保持不变,直接用 Position |
| `o_panorama_uv` | POINT / FLOAT_VECTOR | 球面采样坐标供深度与有效性采样;POINT 域直接求值 |
| `o_panorama_distance` | POINT / FLOAT_VECTOR | 采样距离供分裂后投影;删面与 Split Edges 保持球面坐标,POINT 域直接求值 |
| `o_panorama_invalid` | POINT / FLOAT_VECTOR | 有效性分类供面删除及无效距离选择;改为 POINT/FLOAT 字段,面消费者保留聚合语义 |
| `o_balloon_surface_rim` | POINT / FLOAT | 源面边界供中心和结构 Blur 权重;显式 POINT/FLOAT 字段直连 |
| `o_balloon_surface_center`(2 次) | POINT / FLOAT | 第一版 seed 只供 Blur,直接连接;第二版平滑中心跨表面变形,捕获最终值 |
| `o_balloon_surface_front_normal` | POINT / FLOAT_VECTOR | 源表面法线供 Uniform 厚度及前向平滑;POINT 求值后捕获 |
| `o_balloon_surface_structure` | POINT / FLOAT | 原表面结构幅度供壳体膨胀;在壳体构建前捕获 |
| `o_balloon_surface_inflation_normal` | POINT / FLOAT_VECTOR | 原表面平滑方向供最终膨胀;与结构幅度一起捕获 |
| `o_balloon_surface_thickness`(3 次) | POINT / FLOAT | Balloon 基础厚度、Uniform 控件厚度、Balloon 圆化后厚度三个版本;基础计算直接连接,模式选择后的最终厚度在变形前捕获 |
| `o_balloon_surface_edge_weight` | POINT / FLOAT | 圆化权重供壳体前后表面;在圆化前捕获 |
| `o_balloon_surface_front_delta` | POINT / FLOAT | 圆化位移供当前 Set Position 和后续壳体;在圆化前捕获 |
| `o_balloon_surface_normal` | POINT / FLOAT_VECTOR | 在独立中心表面求值并按顶点采样的法线,供最终壳体;保留采样源并捕获 |
| `UVMap`(3 次) | CORNER / FLOAT2 | Image Plane 的 Grid/Cube 两分支及 Panorama,供材质与输出使用;保留命名属性 |
| `o_normal_scale`(5 次) | FLOAT;Cutout/Balloon 为 POINT,Depth Cutout 为 FACE | 两类 Cutout 输出掩码、Depth Cutout 原面值 1 与背面/侧壁值 0;供 `cutout_tool/object.py` 材质读取,保留全部写入 |
`o_balloon` 由 `cutout_tool/object.py` 写入输入网格,作为形状协议继续按名称读取。Relief Plane 使用 UVMap 读取;Image Layer、Shadeless 通过接口消费材质数据;Depth Layer 的位移属性读取保持原协议。以上外部消费者均已检查。
## 数量变化
表中为 Store / Named Attribute 读取 / Capture 节点数量,包含组内共用构建函数展开后的节点。
| 节点组 | 调整前 | 调整后 |
| --- | --- | --- |
| Depth Panorama | 10 / 14 / 1 | 1 / 0 / 2 |
| Image Plane | 2 / 0 / 1 | 2 / 0 / 1 |
| Depth Plane | 8 / 12 / 0 | 0 / 2 / 2 |
| Relief Plane | 0 / 1 / 0 | 0 / 1 / 0 |
| Image Cutout | 1 / 2 / 0 | 1 / 2 / 0 |
| Depth Cutout | 22 / 36 / 0 | 3 / 7 / 4 |
| Depth Balloon | 1 / 3 / 0 | 1 / 3 / 0 |
| 三个材质组 | 0 / 0 / 0 | 0 / 0 / 0 |
| 合计 | 44 / 68 / 2 | 8 / 15 / 9 |
移除的内部命名空间不再参与清理;输入网格中自行携带的同前缀属性得以保留。快照经匿名属性传播,最终输出不新增内部命名属性。
## 求值验证
- 基线节点测试首次受环境同步文件锁影响:248 通过,2 个材质测试因 Cycles 未能注册失败。恢复完整 bpy 安装后,相关 5 个材质测试全部通过。
- 保存并对比 28 个场景、164 组数组:覆盖 Balloon/Uniform、Split 关闭/开启、零/正厚度、Normal Smooth 0/50、Front Inflation、Panorama 非均匀深度与无效像素。
- 28 个场景的实际顶点位置完全一致。按空间位置对应比较,面的方向、角 UV、材质索引和法线掩码完全一致;Merge by Distance 的部分输出编号顺序发生变化。
- 重构后的 Depth Cutout 与分裂测试:56 通过;其余投影与 Panorama 首轮测试通过,旧临时属性测试已迁移为字段输出验证。
- `uv run node-group build` 成功退出:使用 Blender 4.5.10 LTS 重建 `.blend`,在独立进程中重新加载并验证全部 10 个节点组。
- 同一构建入口调用项目环境的 `python -m pytest`:1182 项全部通过,耗时 217.79 秒,正常退出;包含排列前后行为、保存布局稳定性、材质与 Operator 回归验证。
- 七个几何组的节点边界检查均为零重叠,现有排列器无需调整;已导出本地交互预览 `build/node-attributes.html`。
- `git diff --check` 与 OpenSpec 严格校验通过;节点源代码中已无本次移除的临时命名属性与旧存取入口引用。

Binary file not shown.

View File

@@ -82,7 +82,6 @@ def create_depth_plane_object(
uniform_scale = depth_uniform_scale(source_object, depth_metadata)
for name, value in (
("Subdivide", max(0, min(10, int(subdivisions)))),
("Depth Scale", 1.0),
("Reference Depth", depth_metadata["reference_depth"] * uniform_scale),
("Uniform Scale", uniform_scale),
("Depth Image", depth_image),

View File

@@ -197,6 +197,9 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
modifier = result.modifiers[0]
group = modifier.node_group
for name in ("Thickness", "Depth Scale"):
identifier = object_data.modifier_input_identifier(group, name)
assert modifier[identifier] == 0.5
def set_input(name, value):
object_data.set_modifier_input(
@@ -262,3 +265,5 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
set_input("Reference Depth", 0.8)
assert np.allclose(_evaluated_positions(result)[:, 2], 0.0, atol=1e-5)

View File

@@ -6,6 +6,35 @@ import pytest
from tools.nodes.groups.image_depth_cutout import build_volume_fields
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from tools.nodes.common.nodes import capture_fields
def store_test_fields(group, geometry, fields):
"""Expose evaluated fields only in the test output mesh."""
for name, field in fields.items():
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.domain = "POINT"
store.data_type = "FLOAT_VECTOR" if field.type == "VECTOR" else "FLOAT"
store.inputs["Name"].default_value = name
group.links.new(geometry, store.inputs["Geometry"])
group.links.new(field, store.inputs["Value"])
geometry = store.outputs["Geometry"]
return geometry
def set_test_structure(monkeypatch, value):
"""Control structural amplitude without depending on storage nodes."""
from tools.nodes.groups import image_depth_cutout
build = image_depth_cutout.build_front_fields
def fields(group, rim, normal):
result = build(group, rim, normal)
structure = group.nodes.new("ShaderNodeValue")
structure.outputs[0].default_value = value
result["structure"] = structure.outputs[0]
return result
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
@pytest.mark.parametrize("iterations", [0, 50])
@@ -70,14 +99,17 @@ def test_rounding_shares_front_and_back_falloff(amount):
obj = bpy.data.objects.new("Round profile", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree")
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Uniform Thickness", "NodeSocketFloat", 0), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
if default is not None:
socket.default_value = default
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
result = build_volume_fields(group, source.outputs["Geometry"])
result = build_volume_rounding(group, result)
fields = build_volume_fields(group)
geometry, center = capture_fields(group, source.outputs["Geometry"], "POINT", center=("FLOAT", fields["center"]))
mode = group.nodes.new("FunctionNodeInputBool")
result, rounded = build_volume_rounding(group, geometry, {**fields, **center}, mode.outputs[0])
result = store_test_fields(group, result, {**center, **rounded})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Round", "NODES").node_group = group
bpy.context.view_layer.update()
@@ -85,7 +117,7 @@ def test_rounding_shares_front_and_back_falloff(amount):
result = evaluated.to_mesh()
try:
def values(name):
return np.array([v.value for v in result.attributes["o_balloon_surface_" + name].data])
return np.array([v.value for v in result.attributes[name].data])
center = values("center")
u = balloon / balloon.max()
weight = amount * (1 - u**3 * (u * (6*u - 15) + 10))
@@ -94,7 +126,7 @@ def test_rounding_shares_front_and_back_falloff(amount):
np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6)
np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6)
np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6)
normals = np.array([v.vector[:] for v in result.attributes["o_balloon_surface_normal"].data])
normals = np.array([v.vector[:] for v in result.attributes["normal"].data])
np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6)
assert np.max(abs(normals[:, :2])) > 0.1
finally:
@@ -125,14 +157,15 @@ def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
socket.default_value = 1
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"])
fields = build_volume_fields(group)
result = store_test_fields(group, source.outputs["Geometry"], {"thickness": fields["thickness"]})
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Volume", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
thickness = np.array([item.value for item in result.attributes["o_balloon_surface_thickness"].data])
thickness = np.array([item.value for item in result.attributes["thickness"].data])
rear = center + profile + noise - thickness
error = rear - (center - profile)
assert max(abs(error)) < 1e-5
@@ -161,18 +194,14 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
@pytest.mark.parametrize("structure", [0, 1, 4])
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
def test_uniform_front_inflation_scales_with_distance(thickness, structure):
def test_uniform_front_inflation_scales_with_distance(thickness, structure, monkeypatch):
from tests.support.depth_surface import surface, evaluated, assert_closed
set_test_structure(monkeypatch, structure)
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group
store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure")
for link in list(store.inputs["Value"].links):
group.links.remove(link)
store.inputs["Value"].default_value = structure
set_value("Thickness", thickness)
set_value("Front Inflation", 0.0)
baseline, faces = evaluated(obj)
@@ -218,18 +247,15 @@ def test_normal_smooth_controls_front_and_rear_directions():
assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5
def test_balloon_suppression_is_stronger_without_a_startup_threshold():
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1)
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group
store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure")
for link in list(store.inputs["Value"].links):
group.links.remove(link)
store.inputs["Value"].default_value = 1
set_value("Mode", 0)
set_value("Edge Round", 0)
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")

View File

@@ -144,6 +144,22 @@ def test_relief_reads_z_and_keeps_the_base(relief, thickness):
assert obj.modifiers[0][inputs["Thickness"].identifier] == pytest.approx(thickness)
@pytest.mark.parametrize("reference", [0.5, 1.25, 2.0])
def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief, reference):
obj, set_value, _, _, _ = relief
base = evaluated(obj)
texel_x = np.clip((base[0][:, 0] + 2) / 4 * 16, 0.5, 15.5)
depth = (2 + texel_x / 16) * 0.5
expected = np.maximum((reference - depth) * 1.5, 0)
set_value("Reference Depth", reference)
set_value("Depth Scale", 1.5)
result = evaluated(obj)
np.testing.assert_allclose(result[0][:, 2], expected, atol=1e-6)
np.testing.assert_array_equal(result[0][:, :2], base[0][:, :2])
assert result[1] == base[1]
np.testing.assert_array_equal(result[2], base[2])
@pytest.mark.parametrize("normal_smooth", [0, 2, 50])
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth):
from scipy.spatial import cKDTree
@@ -197,7 +213,7 @@ def test_surface_interface_and_node_structure(surface):
assert inputs["Split Threshold"].subtype == "FACTOR"
assert inputs["Split Threshold"].parent == inputs["Smooth"].parent
assert inputs["Split Threshold"].parent.name != "Options"
assert inputs["Validity Threshold"].default_value == pytest.approx(0.1)
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
@@ -223,9 +239,9 @@ 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["Thickness"].default_value == 0.5
assert inputs["Subdivide"].default_value == 6
assert inputs["Depth Scale"].default_value == 1
assert inputs["Depth Scale"].default_value == 0.5
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

View File

@@ -200,7 +200,7 @@ def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
@pytest.mark.parametrize("thickness", [0.0, 0.2])
@pytest.mark.parametrize("split", [0.0, 0.5])
def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness, split):
def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, split):
obj, set_value = surface(wrapper=True)
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
@@ -212,7 +212,7 @@ def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness,
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert not any(a.name.startswith("o_depth_") for a in mesh.attributes)
assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"}
assert "UVMap" in mesh.attributes
assert all(
v.value == pytest.approx(0.75)

View File

@@ -15,6 +15,8 @@ def test_saved_asset_inventory():
assert node_group.use_fake_user and node_group.users > 0
if name in GEOMETRY_NAMES:
assert node_group.is_modifier
stores = [node for node in node_group.nodes if node.bl_idname == "GeometryNodeStoreNamedAttribute"]
assert all(node.inputs["Name"].default_value in {"UVMap", "o_normal_scale"} for node in stores), name
if bpy.app.version >= (5, 0, 0):
assert node_group.library is not None
if name in {"O Image Depth Layer", "O Shadeless"}:

View File

@@ -1,40 +1,6 @@
"""Face separation and connected thickness for Depth Surface assets."""
TEMPORARY_ATTRIBUTES = {
"face_center": ("FLOAT_VECTOR", "FACE"),
"face_camera": ("FLOAT_VECTOR", "FACE"),
"corner_camera": ("FLOAT_VECTOR", "CORNER"),
"cut_vertex": ("BOOLEAN", "CORNER"),
"camera": ("FLOAT_VECTOR", "POINT"),
"surface_position": ("FLOAT_VECTOR", "POINT"),
"source_vertex": ("INT", "POINT"),
"surface_normal": ("FLOAT_VECTOR", "POINT"),
}
def _store(group, geometry, name, value):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.data_type, node.domain = TEMPORARY_ATTRIBUTES[name]
node.inputs["Name"].default_value = "o_depth_" + name
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def _read(group, name):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = TEMPORARY_ATTRIBUTES[name][0]
node.inputs["Name"].default_value = "o_depth_" + name
return node.outputs["Attribute"]
def remove_surface_attributes(group, geometry):
"""Clear the reserved temporary namespace with one wildcard operation."""
node = group.nodes.new("GeometryNodeRemoveAttribute")
node.pattern_mode = "WILDCARD"
node.inputs["Name"].default_value = "o_depth_*"
group.links.new(geometry, node.inputs["Geometry"])
return node.outputs["Geometry"]
from .nodes import capture_fields, evaluate_field, store_float_attribute
def _math(group, operation, a, b=None):
@@ -192,11 +158,9 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
nodes, links = group.nodes, group.links
if face_center is None:
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
faces = _store(group, geometry, "face_center", face_center)
faces = _store(group, faces, "face_camera", face_camera)
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(_read(group, "face_camera"), axes.inputs[0])
center_field = _read(group, "face_center")
links.new(face_camera, axes.inputs[0])
centers, depths = [], []
for side in (0, 1):
corner = nodes.new("GeometryNodeCornersOfEdge")
@@ -204,7 +168,7 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
face = nodes.new("GeometryNodeFaceOfCorner")
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
for data_type, field, results in (
("FLOAT_VECTOR", center_field, centers),
("FLOAT_VECTOR", face_center, centers),
("FLOAT", axes.outputs["Z"], depths),
):
sample = nodes.new("GeometryNodeFieldAtIndex")
@@ -247,12 +211,10 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
cut_vertices.data_type = "FLOAT"
cut_vertices.domain = "POINT"
links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"])
corners = _store(group, faces, "corner_camera", _read(group, "face_camera"))
corners = _store(
group,
corners,
"cut_vertex",
_math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0),
corners, captured = capture_fields(
group, geometry, "CORNER",
Camera=("VECTOR", face_camera),
Cut=("BOOLEAN", _math(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0)),
)
split = nodes.new("GeometryNodeSplitEdges")
links.new(corners, split.inputs["Mesh"])
@@ -263,17 +225,16 @@ def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
links.new(enabled, choose.inputs["Switch"])
links.new(geometry, choose.inputs["False"])
links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"])
return choose.outputs["Output"]
return choose.outputs["Output"], captured["Camera"], captured["Cut"]
def store_surface_camera(group, geometry, original, strength):
def build_surface_camera(group, original, separated, cut_vertex, strength):
"""Change cut depth along the original ray, preserving every other vertex."""
separated = _read(group, "corner_camera")
enabled = _math(
group,
"MULTIPLY",
_math(group, "GREATER_THAN", strength, 0.0),
_read(group, "cut_vertex"),
cut_vertex,
)
original_axes = group.nodes.new("ShaderNodeSeparateXYZ")
separated_axes = group.nodes.new("ShaderNodeSeparateXYZ")
@@ -291,17 +252,27 @@ def store_surface_camera(group, geometry, original, strength):
group.links.new(enabled, choose.inputs["Switch"])
group.links.new(original, choose.inputs["False"])
group.links.new(projected.outputs[0], choose.inputs["True"])
geometry = _store(group, geometry, "camera", choose.outputs["Output"])
return geometry, _read(group, "camera")
return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT")
def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
back_mask=None, weld_zero_thickness=False):
"""Connect caps with single quads by source vertex and cap identity."""
nodes, links = group.nodes, group.links
front_geometry = geometry
position = nodes.new("GeometryNodeInputPosition")
geometry = _store(group, geometry, "surface_position", position.outputs[0])
surface_store = geometry.node
index = nodes.new("GeometryNodeInputIndex")
fields = {"Position": ("VECTOR", position.outputs[0]), "Vertex": ("INT", index.outputs[0])}
if normal_smooth is not None:
normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
links.new(normal_smooth, blur.inputs["Iterations"])
links.new(normal.outputs[0], blur.inputs["Value"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
fields["Normal"] = ("VECTOR", normalize.outputs[0])
geometry, captured = capture_fields(group, geometry, "POINT", **fields)
flattened = nodes.new("ShaderNodeVectorMath")
flattened.operation = "MULTIPLY"
flattened.inputs[1].default_value = (1.0, 1.0, 0.0)
@@ -309,11 +280,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
sheet = nodes.new("GeometryNodeSetPosition")
links.new(geometry, sheet.inputs["Geometry"])
links.new(flattened.outputs[0], sheet.inputs["Position"])
index = nodes.new("GeometryNodeInputIndex")
geometry = _store(
group, sheet.outputs["Geometry"], "source_vertex", index.outputs[0]
)
saved = geometry
saved = sheet.outputs["Geometry"]
# Weld in an integer identity grid, never in scene coordinates. Nearby cuts
# retain distinct IDs; copies of one rim vertex share the same ID and layer.
@@ -322,7 +289,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
part = nodes.new("FunctionNodeIntegerMath")
part.operation = operation
part.inputs[1].default_value = 4096
links.new(_read(group, "source_vertex"), part.inputs[0])
links.new(captured["Vertex"], part.inputs[0])
links.new(part.outputs[0], identity.inputs[axis])
extrude = nodes.new("GeometryNodeExtrudeMesh")
@@ -346,10 +313,15 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
flip_sides = nodes.new("GeometryNodeFlipFaces")
links.new(sides.outputs["Selection"], flip_sides.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
rear = flip_back.outputs["Mesh"]
rim = flip_sides.outputs["Mesh"]
if back_mask is not None:
rear = store_float_attribute(group, rear, back_mask, 0.0, domain="FACE")
rim = store_float_attribute(group, rim, back_mask, 0.0, domain="FACE")
for value in (
saved,
flip_back.outputs["Mesh"],
flip_sides.outputs["Mesh"],
rear,
rim,
):
links.new(value, join.inputs["Geometry"])
encoded = nodes.new("GeometryNodeSetPosition")
@@ -363,13 +335,11 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
layer_position = nodes.new("GeometryNodeInputPosition")
layer_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(layer_position.outputs[0], layer_axes.inputs[0])
links.new(
_math(group, "MULTIPLY", layer_axes.outputs["Z"], thickness),
displacement.inputs["Z"],
)
distance = _math(group, "MULTIPLY", layer_axes.outputs["Z"], thickness)
links.new(distance, displacement.inputs["Z"])
restored = nodes.new("ShaderNodeVectorMath")
restored.operation = "ADD"
links.new(_read(group, "surface_position"), restored.inputs[0])
links.new(captured["Position"], restored.inputs[0])
links.new(displacement.outputs[0], restored.inputs[1])
result = nodes.new("GeometryNodeSetPosition")
links.new(merge.outputs["Geometry"], result.inputs["Geometry"])
@@ -377,25 +347,24 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
local_position = nodes.new("GeometryNodeInputPosition")
local_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(local_position.outputs[0], local_axes.inputs[0])
links.new(local_axes.outputs["Z"], identity.inputs["Z"])
layer = local_axes.outputs["Z"]
if weld_zero_thickness:
positive = _math(group, "GREATER_THAN", thickness, 1e-12)
layer = _math(group, "MULTIPLY", positive, layer)
links.new(layer, identity.inputs["Z"])
if normal_smooth is not None:
normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
links.new(normal_smooth, blur.inputs["Iterations"])
links.new(normal.outputs[0], blur.inputs["Value"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
front_geometry = _store(group, front_geometry, "surface_normal", normalize.outputs[0])
links.new(front_geometry, surface_store.inputs["Geometry"])
normal_offset = nodes.new("ShaderNodeVectorMath")
normal_offset.operation = "SCALE"
links.new(_read(group, "surface_normal"), normal_offset.inputs[0])
links.new(displacement.inputs["Z"].links[0].from_socket, normal_offset.inputs[3])
links.new(captured["Normal"], normal_offset.inputs[0])
links.new(distance, normal_offset.inputs[3])
links.new(normal_offset.outputs[0], restored.inputs[1])
nodes.remove(displacement)
return result.outputs["Geometry"]
return {
"geometry": result.outputs["Geometry"],
"front": captured["Position"],
"position": restored.outputs[0],
"position_input": result.inputs["Position"],
}
def _projection_math(nodes, operation):

View File

@@ -104,3 +104,23 @@ def store_float_attribute(group, geometry, name, value, domain="POINT"):
else:
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def evaluate_field(group, value, data_type, domain):
"""Evaluate a field on an explicit domain before downstream adaptation."""
node = group.nodes.new("GeometryNodeFieldOnDomain")
node.data_type, node.domain = data_type, domain
group.links.new(value, node.inputs["Value"])
return node.outputs["Value"]
def capture_fields(group, geometry, domain, **fields):
"""Keep source fields attached to geometry through later changes."""
node = group.nodes.new("GeometryNodeCaptureAttribute")
node.domain = domain
node.capture_items.clear()
group.links.new(geometry, node.inputs["Geometry"])
for name, (data_type, value) in fields.items():
node.capture_items.new(data_type, name)
group.links.new(value, node.inputs[name])
return node.outputs["Geometry"], {name: node.outputs[name] for name in fields}

View File

@@ -2,112 +2,44 @@
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, capture_fields, evaluate_field
from ..common.smoothing import expand_smooth
from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output
from ..common.depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, sample_face_camera, thicken_depth_surface
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface
from ..common.smoothing import edge_boundary_field
NODE_GROUP_NAME = "O Image Depth Cutout"
def build_volume_fields(group, geometry):
boundary = edge_boundary_field(group.nodes, group.links)
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
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 = group.nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
def build_volume_fields(group):
"""Evaluate the center and thickness on the original front surface."""
links = group.links
def math(operation, a, b):
result = group.nodes.new("ShaderNodeMath")
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]
rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT")
profile = read_float_attribute(group, "o_balloon")
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
seed = math("SUBTRACT", axes.outputs["Z"], profile.outputs["Attribute"])
center = group.nodes.new("GeometryNodeStoreNamedAttribute")
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 = group.nodes.new("GeometryNodeInputNamedAttribute")
current.data_type = "FLOAT"
current.inputs["Name"].default_value = "o_balloon_surface_center"
rim = group.nodes.new("GeometryNodeInputNamedAttribute")
rim.data_type = "FLOAT"
rim.inputs["Name"].default_value = "o_balloon_surface_rim"
interior = math("SUBTRACT", 1, rim.outputs["Attribute"])
seed = _math(group, "SUBTRACT", axes.outputs["Z"], profile)
interior = _math(group, "SUBTRACT", 1, rim)
blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT"
blur.inputs["Iterations"].default_value = 256
links.new(current.outputs["Attribute"], blur.inputs["Value"])
links.new(seed, blur.inputs["Value"])
links.new(interior, blur.inputs["Weight"])
update = group.nodes.new("GeometryNodeStoreNamedAttribute")
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 = group.nodes.new("GeometryNodeInputNamedAttribute")
baseline.data_type = "FLOAT"
baseline.inputs["Name"].default_value = "o_balloon_surface_center"
profile_end = group.nodes.new("GeometryNodeInputNamedAttribute")
profile_end.data_type = "FLOAT"
profile_end.inputs["Name"].default_value = "o_balloon"
position_end = group.nodes.new("GeometryNodeInputPosition")
axes_end = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position_end.outputs[0], axes_end.inputs[0])
residual = math("SUBTRACT", axes_end.outputs["Z"], baseline.outputs["Attribute"])
full = math("ADD", residual, profile_end.outputs["Attribute"])
minimum = math("MULTIPLY", profile_end.outputs["Attribute"], 0.05)
positive = math("MAXIMUM", full, minimum)
control = group.nodes.new("NodeGroupInput")
scaled = math("MULTIPLY", positive, control.outputs["Thickness"])
thickness = group.nodes.new("GeometryNodeStoreNamedAttribute")
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"])
return thickness.outputs["Geometry"]
center = blur.outputs[0]
residual = _math(group, "SUBTRACT", axes.outputs["Z"], center)
full = _math(group, "ADD", residual, profile)
minimum = _math(group, "MULTIPLY", profile, 0.05)
positive = _math(group, "MAXIMUM", full, minimum)
control = group_input(group.nodes, {"Thickness"})
thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"])
return {"rim": rim, "center": center, "thickness": thickness}
def build_volume_rounding(group, volume):
def build_volume_rounding(group, geometry, fields, uniform):
links = group.links
def math(operation, a, b):
result = group.nodes.new("ShaderNodeMath")
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, kind="FLOAT"):
result = group.nodes.new("GeometryNodeInputNamedAttribute")
result.data_type = kind
result.inputs["Name"].default_value = "o_balloon_surface_" + name
return result.outputs["Attribute"]
def store(geometry, name, value, kind="FLOAT"):
result = group.nodes.new("GeometryNodeStoreNamedAttribute")
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 = group.nodes.new("NodeGroupInput")
profile = group.nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
@@ -115,34 +47,31 @@ def build_volume_rounding(group, volume):
balloon = profile.outputs["Attribute"]
maximum = group.nodes.new("GeometryNodeAttributeStatistic")
maximum.data_type, maximum.domain = "FLOAT", "POINT"
links.new(volume, maximum.inputs["Geometry"])
links.new(geometry, maximum.inputs["Geometry"])
links.new(balloon, maximum.inputs["Attribute"])
limit = math("MAXIMUM", maximum.outputs["Max"], 1e-8)
limit = _math(group, "MAXIMUM", maximum.outputs["Max"], 1e-8)
falloff = group.nodes.new("ShaderNodeMapRange")
falloff.interpolation_type = "SMOOTHERSTEP"
falloff.inputs["To Min"].default_value = 1
falloff.inputs["To Max"].default_value = 0
links.new(balloon, falloff.inputs["Value"])
links.new(limit, falloff.inputs["From Max"])
weight = math("MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"])
center = read("center")
weight = _math(group, "MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"])
center = fields["center"]
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
target = math("ADD", center, balloon)
delta = math("SUBTRACT", target, axes.outputs["Z"])
delta = math("MULTIPLY", delta, weight)
enabled = math("MINIMUM", control.outputs["Thickness"], 1)
enabled = math("MAXIMUM", enabled, 0)
delta = math("MULTIPLY", delta, enabled)
carrier = store(volume, "edge_weight", weight)
carrier = store(carrier, "front_delta", delta)
rear = math("MULTIPLY", balloon, weight)
rear = math("MULTIPLY", rear, control.outputs["Thickness"])
thickness = read("thickness")
total = math("ADD", thickness, delta)
total = math("ADD", total, rear)
carrier = store(carrier, "thickness", total)
target = _math(group, "ADD", center, balloon)
delta = _math(group, "SUBTRACT", target, axes.outputs["Z"])
delta = _math(group, "MULTIPLY", delta, weight)
enabled = _math(group, "MINIMUM", control.outputs["Thickness"], 1)
enabled = _math(group, "MAXIMUM", enabled, 0)
delta = _math(group, "MULTIPLY", delta, enabled)
rear = _math(group, "MULTIPLY", balloon, weight)
rear = _math(group, "MULTIPLY", rear, control.outputs["Thickness"])
thickness = fields["thickness"]
total = _math(group, "ADD", thickness, delta)
total = _math(group, "ADD", total, rear)
# Evaluate normals on a separate center surface with identical point indices.
center_position = group.nodes.new("ShaderNodeCombineXYZ")
@@ -150,7 +79,7 @@ def build_volume_rounding(group, volume):
links.new(axes.outputs[axis], center_position.inputs[axis])
links.new(center, center_position.inputs["Z"])
surface = group.nodes.new("GeometryNodeSetPosition")
links.new(volume, surface.inputs["Geometry"])
links.new(geometry, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"])
normal = group.nodes.new("GeometryNodeInputNormal")
blur = group.nodes.new("GeometryNodeBlurAttribute")
@@ -167,44 +96,30 @@ def build_volume_rounding(group, volume):
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], "FLOAT_VECTOR")
captured = read("front_delta")
thickness = group.nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT"
links.new(uniform, thickness.inputs["Switch"])
links.new(total, thickness.inputs["False"])
links.new(control.outputs["Uniform Thickness"], thickness.inputs["True"])
carrier, captured = capture_fields(
group, geometry, "POINT",
edge_weight=("FLOAT", weight), front_delta=("FLOAT", delta),
thickness=("FLOAT", thickness.outputs[0]), normal=("VECTOR", sample.outputs[0]),
)
displacement = group.nodes.new("ShaderNodeCombineXYZ")
links.new(captured, displacement.inputs["Z"])
links.new(captured["front_delta"], displacement.inputs["Z"])
front = group.nodes.new("GeometryNodeSetPosition")
links.new(carrier, front.inputs["Geometry"])
links.new(displacement.outputs[0], front.inputs["Offset"])
return front.outputs[0]
choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(uniform, choose.inputs["Switch"])
links.new(front.outputs[0], choose.inputs["False"])
links.new(carrier, choose.inputs["True"])
return choose.outputs[0], captured
def _inflation_math(group, operation, a, b):
result = group.nodes.new("ShaderNodeMath")
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 _inflation_read(group, name, kind="FLOAT"):
result = group.nodes.new("GeometryNodeInputNamedAttribute")
result.data_type = kind
result.inputs["Name"].default_value = "o_balloon_surface_" + name
return result.outputs["Attribute"]
def _inflation_store(group, geometry, name, value, kind="FLOAT"):
result = group.nodes.new("GeometryNodeStoreNamedAttribute")
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):
def build_front_fields(group, rim, front_normal):
"""Fit structural amplitude over neighbouring points before creating the shell."""
links = group.links
@@ -221,30 +136,28 @@ def build_front_fields(group, geometry):
profile_node.data_type = "FLOAT"
profile_node.inputs["Name"].default_value = "o_balloon"
profile = profile_node.outputs["Attribute"]
rim = _inflation_read(group, "rim")
interior = _inflation_math(group, "SUBTRACT", 1, rim)
interior = _math(group, "SUBTRACT", 1, rim)
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
depth = axes.outputs["Z"]
depth_slow = blur(depth, interior)
profile_slow = blur(profile, interior)
depth_bump = _inflation_math(group, "SUBTRACT", depth, depth_slow)
profile_bump = _inflation_math(group, "SUBTRACT", profile, profile_slow)
numerator = _inflation_math(group, "MULTIPLY", depth_bump, profile_bump)
denominator = _inflation_math(group, "MULTIPLY", profile_bump, profile_bump)
depth_bump = _math(group, "SUBTRACT", depth, depth_slow)
profile_bump = _math(group, "SUBTRACT", profile, profile_slow)
numerator = _math(group, "MULTIPLY", depth_bump, profile_bump)
denominator = _math(group, "MULTIPLY", profile_bump, profile_bump)
numerator = blur(numerator)
denominator = blur(denominator)
denominator = _inflation_math(group, "MAXIMUM", denominator, 1e-10)
structure = _inflation_math(group, "DIVIDE", numerator, denominator)
structure = _inflation_math(group, "MAXIMUM", structure, 0)
carrier = _inflation_store(group, geometry, "structure", structure)
denominator = _math(group, "MAXIMUM", denominator, 1e-10)
structure = _math(group, "DIVIDE", numerator, denominator)
structure = _math(group, "MAXIMUM", structure, 0)
normal = _inflation_read(group, "front_normal", "FLOAT_VECTOR")
normal = front_normal
controls = group.nodes.new("NodeGroupInput")
# Keep the accepted broad front-direction filter at the default of 50;
# zero disables both this filter and the initial front/back normal filters.
spread = _inflation_math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50)
spread = _math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50)
smooth = group.nodes.new("GeometryNodeBlurAttribute")
smooth.data_type = "FLOAT_VECTOR"
links.new(normal, smooth.inputs["Value"])
@@ -252,52 +165,50 @@ def build_front_fields(group, geometry):
normalize = group.nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(smooth.outputs[0], normalize.inputs[0])
carrier = _inflation_store(group, carrier, "inflation_normal", normalize.outputs[0], "FLOAT_VECTOR")
return carrier
return {"structure": structure, "inflation_normal": normalize.outputs[0]}
def build_inflation_position(group, position, distance, uniform):
def build_inflation_position(group, position, distance, uniform, fields):
"""Grow both modes continuously while leaving the rear cap in place."""
links = group.links
structure = _inflation_read(group, "structure")
structure = fields["structure"]
control = group.nodes.new("NodeGroupInput")
a = _inflation_math(group, "MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"])
a = _math(group, "MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"])
# A shifted cubic blend preserves continuous growth and height order.
shifted = _inflation_math(group, "ADD", a, 0.75)
quadratic = _inflation_math(group, "MULTIPLY", a, shifted)
quadratic = _inflation_math(group, "ADD", quadratic, 0.1875)
q = _inflation_math(group, "MULTIPLY", a, quadratic)
k = _inflation_math(group, "ADD", structure, 0.25)
square = _inflation_math(group, "MULTIPLY", k, k)
cube = _inflation_math(group, "MULTIPLY", square, k)
total = _inflation_math(group, "ADD", cube, q)
root = _inflation_math(group, "POWER", total, 1 / 3)
root_square = _inflation_math(group, "MULTIPLY", root, root)
product = _inflation_math(group, "MULTIPLY", root, k)
denominator = _inflation_math(group, "ADD", root_square, product)
denominator = _inflation_math(group, "ADD", denominator, square)
response = _inflation_math(group, "DIVIDE", q, denominator)
shifted = _math(group, "ADD", a, 0.75)
quadratic = _math(group, "MULTIPLY", a, shifted)
quadratic = _math(group, "ADD", quadratic, 0.1875)
q = _math(group, "MULTIPLY", a, quadratic)
k = _math(group, "ADD", structure, 0.25)
square = _math(group, "MULTIPLY", k, k)
cube = _math(group, "MULTIPLY", square, k)
total = _math(group, "ADD", cube, q)
root = _math(group, "POWER", total, 1 / 3)
root_square = _math(group, "MULTIPLY", root, root)
product = _math(group, "MULTIPLY", root, k)
denominator = _math(group, "ADD", root_square, product)
denominator = _math(group, "ADD", denominator, square)
response = _math(group, "DIVIDE", q, denominator)
profile = group.nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = "o_balloon"
balloon = _inflation_math(group, "MULTIPLY", profile.outputs["Attribute"], response)
balloon = _math(group, "MULTIPLY", profile.outputs["Attribute"], response)
uniform_control = group.nodes.new("NodeGroupInput")
uniform_amount = _inflation_math(group, "MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"])
uniform_amount = _math(group, "MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"])
selected = group.nodes.new("GeometryNodeSwitch")
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 = _inflation_read(group, "thickness")
layer = _inflation_math(group, "DIVIDE", distance, height)
layer = _inflation_math(group, "ADD", 1, layer)
layer = _inflation_math(group, "MAXIMUM", layer, 0)
layer = _inflation_math(group, "MINIMUM", layer, 1)
displacement = _inflation_math(group, "MULTIPLY", displacement, layer)
normal = _inflation_read(group, "inflation_normal", "FLOAT_VECTOR")
height = fields["thickness"]
layer = _math(group, "DIVIDE", distance, height)
layer = _math(group, "ADD", 1, layer)
layer = _math(group, "MAXIMUM", layer, 0)
layer = _math(group, "MINIMUM", layer, 1)
displacement = _math(group, "MULTIPLY", displacement, layer)
normal = fields["inflation_normal"]
offset = group.nodes.new("ShaderNodeVectorMath")
offset.operation = "SCALE"
links.new(normal, offset.inputs[0])
@@ -309,7 +220,7 @@ def build_inflation_position(group, position, distance, uniform):
return result.outputs[0]
def build_front_normals(group, geometry):
def build_front_normal(group):
links = group.links
normal = group.nodes.new("GeometryNodeInputNormal")
control = group.nodes.new("NodeGroupInput")
@@ -320,15 +231,10 @@ def build_front_normals(group, geometry):
normalize = group.nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
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]
return evaluate_field(group, normalize.outputs[0], "FLOAT_VECTOR", "POINT")
def build_shell_position(group, position, uniform):
def build_shell_position(group, position, front, uniform, fields):
links = group.links
def connect(value, target):
@@ -337,13 +243,6 @@ def build_shell_position(group, position, uniform):
else:
target.default_value = value
def math(operation, a, b):
result = group.nodes.new("ShaderNodeMath")
result.operation = operation
connect(a, result.inputs[0])
connect(b, result.inputs[1])
return result.outputs[0]
def vector(operation, a, b):
result = group.nodes.new("ShaderNodeVectorMath")
result.operation = operation
@@ -351,23 +250,16 @@ def build_shell_position(group, position, uniform):
connect(b, result.inputs[3 if operation == "SCALE" else 1])
return result.outputs[0]
def read(name, kind="FLOAT"):
result = group.nodes.new("GeometryNodeInputNamedAttribute")
result.data_type = kind
result.inputs["Name"].default_value = name
return result.outputs["Attribute"]
def z(value):
result = group.nodes.new("ShaderNodeSeparateXYZ")
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", "FLOAT_VECTOR")
front_z = z(front)
raw_z = z(position)
distance = math("SUBTRACT", raw_z, front_z)
normal = read("o_balloon_surface_front_normal", "FLOAT_VECTOR")
distance = _math(group, "SUBTRACT", raw_z, front_z)
normal = fields["front_normal"]
thin_offset = vector("SCALE", normal, distance)
uniform_position = vector("ADD", front, thin_offset)
@@ -377,26 +269,26 @@ def build_shell_position(group, position, uniform):
clamp = group.nodes.new("ShaderNodeClamp")
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0]
center = read("o_balloon_surface_center")
delta = read("o_balloon_surface_front_delta")
radius = math("SUBTRACT", front_z, center)
radius = math("MULTIPLY", radius, t)
remaining = math("SUBTRACT", 1, t)
remainder = math("MULTIPLY", remaining, delta)
radius = math("ADD", radius, remainder)
center = fields["center"]
delta = fields["front_delta"]
radius = _math(group, "SUBTRACT", front_z, center)
radius = _math(group, "MULTIPLY", radius, t)
remaining = _math(group, "SUBTRACT", 1, t)
remainder = _math(group, "MULTIPLY", remaining, delta)
radius = _math(group, "ADD", radius, remainder)
# Front and rear use the same edge field, evaluated on their own caps.
edge = read("o_balloon_surface_edge_weight")
mask = read("o_normal_scale")
remaining = math("SUBTRACT", 1, edge)
remaining = math("MULTIPLY", remaining, mask)
weight = math("SUBTRACT", 1, remaining)
difference = math("SUBTRACT", weight, edge)
base = math("MULTIPLY", difference, radius)
layer = math("MULTIPLY", weight, distance)
rear_radius = math("ADD", base, layer)
front_radius = math("MULTIPLY", edge, radius)
normal = read("o_balloon_surface_normal", "FLOAT_VECTOR")
edge = fields["edge_weight"]
mask = read_float_attribute(group, "o_normal_scale")
remaining = _math(group, "SUBTRACT", 1, edge)
remaining = _math(group, "MULTIPLY", remaining, mask)
weight = _math(group, "SUBTRACT", 1, remaining)
difference = _math(group, "SUBTRACT", weight, edge)
base = _math(group, "MULTIPLY", difference, radius)
layer = _math(group, "MULTIPLY", weight, distance)
rear_radius = _math(group, "ADD", base, layer)
front_radius = _math(group, "MULTIPLY", edge, radius)
normal = fields["normal"]
direction = vector("SUBTRACT", normal, (0, 0, 1))
front_shift = vector("SCALE", direction, front_radius)
front_position = vector("ADD", front, front_shift)
@@ -420,87 +312,48 @@ def build_shell_position(group, position, uniform):
links.new(uniform, choose.inputs["Switch"])
links.new(balloon_position, choose.inputs["False"])
links.new(uniform_position, choose.inputs["True"])
return build_inflation_position(group, choose.outputs[0], distance, uniform)
return build_inflation_position(group, choose.outputs[0], distance, uniform, fields)
def _build_depth_surface(
group,
geometry,
thickness,
depth_scale,
split,
uniform_scale,
reference_depth,
depth_image,
):
nodes = group.nodes
def _build_depth_surface(group, geometry, controls):
"""Project the cleaned mesh, capture surface fields, and connect the shell."""
links = group.links
geometry = split_depth_surface(
group, geometry, sample_face_camera(group, depth_image), split, uniform_scale, triangles=True
split = controls.outputs["Split Threshold"]
image = controls.outputs["Depth Image"]
scale = controls.outputs["Uniform Scale"]
geometry, corner_camera, cut_vertex = split_depth_surface(
group, geometry, sample_face_camera(group, image), split, scale, triangles=True,
)
original_camera = _position_field(group, depth_image)
geometry, camera_position = store_surface_camera(
group, geometry, original_camera, split
camera = build_surface_camera(group, _position_field(group, image), corner_camera, cut_vertex, split)
projection = project_depth_surface(
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"],
)
geometry = project_depth_surface(
group, geometry, camera_position, uniform_scale, reference_depth, depth_scale
# The mode field is shared by the geometry and thickness choices.
mode = menu_switch(group, "Mode", ("Balloon", "Uniform"), "BOOLEAN")
mode.inputs["Balloon"].default_value = False
mode.inputs["Uniform"].default_value = True
uniform = mode.outputs[0]
volume = build_volume_fields(group)
normal = build_front_normal(group)
front = build_front_fields(group, volume["rim"], normal)
shared, fields = capture_fields(
group, projection, "POINT",
center=("FLOAT", volume["center"]), front_normal=("VECTOR", normal),
structure=("FLOAT", front["structure"]), inflation_normal=("VECTOR", front["inflation_normal"]),
)
shaped = thicken_depth_surface(group, geometry, thickness)
geometry = _remove_zero_thickness(
group, geometry, shaped, thickness
rounded, rounding = build_volume_rounding(group, shared, {**volume, **fields}, uniform)
fields.update(rounding)
shell = thicken_depth_surface(
group, rounded, fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
)
return remove_surface_attributes(group, geometry)
def _depth_volume(group):
"""Insert the volume field ahead of identity-based shell construction."""
links = group.links
nodes = group.nodes
projection = nodes["Set Position"].outputs["Geometry"]
shell_input = nodes["Store Named Attribute.005"].inputs["Geometry"]
projection = build_front_normals(group, projection)
volume = build_volume_fields(group, projection)
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, {"Uniform Thickness"})
uniform = store_float_attribute(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"])
choose = menu_switch(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY")
links.new(volume, choose.inputs["Balloon"])
links.new(uniform, choose.inputs["Uniform"])
links.new(choose.outputs[0], shell_input)
thickness = read_float_attribute(group, "o_balloon_surface_thickness")
links.new(thickness, nodes["Math.018"].inputs[1])
control = group_input(group.nodes, {"Thickness", "Uniform Thickness"})
amount = menu_switch(group, "Mode", ("Balloon", "Uniform"), "FLOAT")
links.new(control.outputs["Thickness"], amount.inputs["Balloon"])
links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"])
links.new(amount.outputs[0], nodes["Math.019"].inputs[0])
identity_thickness = read_float_attribute(group, "o_balloon_surface_thickness")
positive = math_node(group.nodes, "GREATER_THAN")
positive.inputs[1].default_value = 1e-12
links.new(identity_thickness, positive.inputs[0])
layer = math_node(group.nodes, "MULTIPLY")
links.new(positive.outputs[0], layer.inputs[0])
links.new(nodes["Separate XYZ.006"].outputs["Z"], layer.inputs[1])
links.new(layer.outputs[0], nodes["Combine XYZ.002"].inputs["Z"])
for name in ("Flip Faces", "Flip Faces.001"):
source = nodes[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_float_attribute(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, domain="FACE")
for target in targets:
links.new(masked, target)
rounded = build_volume_rounding(group, volume)
links.new(rounded, choose.inputs["Balloon"])
restored = nodes["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)
for target in targets:
links.new(oriented, target)
oriented = build_shell_position(group, shell["position"], shell["front"], uniform, fields)
links.new(oriented, shell["position_input"])
amount = group.nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT"
links.new(uniform, amount.inputs["Switch"])
links.new(controls.outputs["Thickness"], amount.inputs["False"])
links.new(controls.outputs["Uniform Thickness"], amount.inputs["True"])
return _remove_zero_thickness(group, projection, shell["geometry"], amount.outputs[0])
def build_image_depth_cutout_group():
@@ -535,24 +388,15 @@ def build_image_depth_cutout_group():
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
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'])
_depth_volume(group)
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, {'Geometry', 'Cleanup Threshold'})
area = math_node(group.nodes, 'MULTIPLY')
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])
cleaned = store_float_attribute(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, domain='FACE')
for target in planar_inputs:
group.links.new(cleaned, target)
controls = group_input(group.nodes, set())
geometry = _build_depth_surface(group, cleaned, controls)
geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight')
remove = group.nodes.new('GeometryNodeRemoveAttribute')
remove.pattern_mode = 'WILDCARD'
remove.inputs['Name'].default_value = 'o_balloon_surface_*'
group.links.new(geometry, remove.inputs['Geometry'])
geometry = remove.outputs[0]
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
@@ -560,8 +404,6 @@ def build_image_depth_cutout_group():
group.links.new(geometry, axis.inputs['-Z'])
group.links.new(orient.outputs[0], axis.inputs['+X'])
_shade_output(group, axis.outputs[0])
projection = next((node.outputs['Geometry'] for node in group.nodes if node.bl_idname == 'GeometryNodeStoreNamedAttribute' and node.inputs['Name'].default_value == 'o_balloon_surface_front_normal'))
group.links.new(projection, group.nodes['Switch.002'].inputs['True'])
prepare_node_group(group)
for item in group.interface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':

View File

@@ -5,9 +5,9 @@ import math
import bpy
from ..common.depth_surface import (
_math, remove_surface_attributes, split_depth_surface, store_surface_camera,
_math, split_depth_surface, build_surface_camera,
)
from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group
from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field
from ..common.material import inherit_material_slots
from ..common.smoothing import expand_smooth
@@ -54,20 +54,6 @@ def build_image_depth_panorama_group():
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes, links = group.nodes, group.links
def read(name):
node = nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
return node.outputs["Attribute"]
def store(geometry, name, value, domain):
node = nodes.new("GeometryNodeStoreNamedAttribute")
node.data_type, node.domain = ("FLOAT2" if name == "UVMap" else "FLOAT_VECTOR"), domain
node.inputs["Name"].default_value = name
links.new(geometry, node.inputs["Geometry"])
links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def uv(direction):
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
@@ -118,12 +104,11 @@ def build_image_depth_panorama_group():
inputs = group_input(nodes, {"Subdivide"})
sphere = _quad_sphere(group, _math(group, "ADD", _math(group, "POWER", 2, inputs.outputs["Subdivide"]), 1))
position = nodes.new("GeometryNodeInputPosition").outputs[0]
geometry = store(sphere, "o_panorama_direction", position, "POINT")
longitude, latitude, z = uv(read("o_panorama_direction"))
geometry = store(geometry, "o_panorama_uv", combine(longitude, latitude), "POINT")
longitude, latitude, z = uv(position)
coordinates = evaluate_field(group, combine(longitude, latitude), "FLOAT_VECTOR", "POINT")
face = nodes.new("GeometryNodeFieldOnDomain")
face.data_type, face.domain = "FLOAT_VECTOR", "FACE"
links.new(read("o_panorama_direction"), face.inputs["Value"])
links.new(position, face.inputs["Value"])
face_u, face_v, _ = uv(face.outputs["Value"])
correction = _math(group, "ROUND", _math(group, "SUBTRACT", face_u, longitude))
choose = nodes.new("GeometryNodeSwitch")
@@ -131,17 +116,20 @@ def build_image_depth_panorama_group():
links.new(_math(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"])
links.new(_math(group, "ADD", longitude, correction), choose.inputs["False"])
links.new(face_u, choose.inputs["True"])
geometry = store(geometry, "UVMap", combine(choose.outputs[0], latitude), "CORNER")
pixel, _ = depth(read("o_panorama_uv"))
geometry = store(geometry, "o_panorama_distance", pixel, "POINT")
uv_map = nodes.new("GeometryNodeStoreNamedAttribute")
uv_map.data_type, uv_map.domain = "FLOAT2", "CORNER"
uv_map.inputs["Name"].default_value = "UVMap"
links.new(sphere, uv_map.inputs["Geometry"])
links.new(combine(choose.outputs[0], latitude), uv_map.inputs["Value"])
pixel, _ = depth(coordinates)
pixel = evaluate_field(group, pixel, "FLOAT_VECTOR", "POINT")
nearest = nodes.new("FunctionNodeSeparateColor")
links.new(texture(read("o_panorama_uv"), "Closest"), nearest.inputs["Color"])
links.new(texture(coordinates, "Closest"), nearest.inputs["Color"])
controls = group_input(nodes, {"Validity Threshold"})
invalid = _math(group, "MAXIMUM",
_math(group, "LESS_THAN", nearest.outputs["Alpha"], controls.outputs["Validity Threshold"]),
_math(group, "LESS_THAN", nearest.outputs["Red"], 1e-5))
geometry = store(geometry, "o_panorama_invalid", invalid, "POINT")
face_invalid = read("o_panorama_invalid")
face_invalid = evaluate_field(group, invalid, "FLOAT", "POINT")
any_invalid = _math(group, "GREATER_THAN", face_invalid, 0)
mixed = _math(group, "MULTIPLY", any_invalid,
_math(group, "LESS_THAN", face_invalid, 1))
@@ -152,36 +140,31 @@ def build_image_depth_panorama_group():
links.new(any_invalid, selection.inputs["True"])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(uv_map.outputs["Geometry"], delete.inputs["Geometry"])
links.new(selection.outputs[0], delete.inputs["Selection"])
controls = group_input(nodes, {"Split Threshold", "Reference Distance"})
strength = controls.outputs["Split Threshold"]
geometry = split_depth_surface(group, delete.outputs[0], depth(combine(face_u, face_v))[0], strength,
geometry, corner_camera, cut_vertex = split_depth_surface(group, delete.outputs[0], depth(combine(face_u, face_v))[0], strength,
_math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]),
face_center=face.outputs["Value"])
geometry, distance = store_surface_camera(group, geometry, read("o_panorama_distance"), strength)
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(distance, axes.inputs[0])
distance = nodes.new("GeometryNodeSwitch")
distance.input_type = "FLOAT"
links.new(read("o_panorama_invalid"), distance.inputs["Switch"])
links.new(face_invalid, distance.inputs["Switch"])
links.new(axes.outputs["X"], distance.inputs["False"])
links.new(group_input(nodes, {"Invalid Distance"}).outputs["Invalid Distance"], distance.inputs["True"])
controls = group_input(nodes, {"Depth Scale"})
radius = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Depth Scale"], _math(group, "SUBTRACT", distance.outputs[0], 1)))
radial = nodes.new("ShaderNodeVectorMath")
radial.operation = "SCALE"
links.new(read("o_panorama_direction"), radial.inputs[0])
links.new(position, radial.inputs[0])
links.new(radius, radial.inputs[3])
deform = nodes.new("GeometryNodeSetPosition")
links.new(geometry, deform.inputs["Geometry"])
links.new(radial.outputs[0], deform.inputs["Position"])
geometry = remove_surface_attributes(group, deform.outputs[0])
remove = nodes.new("GeometryNodeRemoveAttribute")
remove.pattern_mode = "WILDCARD"
remove.inputs["Name"].default_value = "o_panorama_*"
links.new(geometry, remove.inputs["Geometry"])
geometry = expand_smooth(group, remove.outputs[0], iterations_name="Smooth", weight_name="Smooth Weight")
geometry = expand_smooth(group, deform.outputs[0], iterations_name="Smooth", weight_name="Smooth Weight")
flip = nodes.new("GeometryNodeFlipFaces")
links.new(geometry, flip.inputs["Mesh"])
smooth = nodes.new("GeometryNodeSetShadeSmooth")

View File

@@ -6,8 +6,8 @@ from ..common.nodes import group_input as _group_input, interface_socket as _int
from ..common.nodes import prepare_node_group, float_input
from ..common.smoothing import expand_smooth
from ..common.depth_surface import (
project_depth_surface, thicken_depth_surface, remove_surface_attributes,
split_depth_surface, sample_face_camera, store_surface_camera,
project_depth_surface, thicken_depth_surface,
split_depth_surface, sample_face_camera, build_surface_camera,
)
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
@@ -63,7 +63,7 @@ def build_image_depth_plane_group(image_plane_group):
smooth_weight.min_value, smooth_weight.max_value = 0.0, 1.0
smooth_weight.subtype = "FACTOR"
validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options)
validity_threshold.default_value = 0.1
validity_threshold.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
validity_threshold.subtype = "FACTOR"
data = group.interface.new_panel(name="Data", default_closed=True)
@@ -122,12 +122,13 @@ def build_image_depth_plane_group(image_plane_group):
links.new(delete.outputs["Geometry"], cull.inputs["True"])
links.new(plane.outputs["Geometry"], cull.inputs["False"])
split_input = _group_input(nodes, {"Split Threshold"})
geometry = split_depth_surface(
geometry, corner_camera, cut_vertex = split_depth_surface(
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
split_input.outputs["Split Threshold"], scale_input.outputs["Uniform Scale"],
)
geometry, camera = store_surface_camera(
group, geometry, texture.outputs["Color"], split_input.outputs["Split Threshold"],
camera = build_surface_camera(
group, texture.outputs["Color"], corner_camera, cut_vertex,
split_input.outputs["Split Threshold"],
)
depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"})
surface = project_depth_surface(
@@ -148,10 +149,9 @@ def build_image_depth_plane_group(image_plane_group):
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"])
links.new(shell["geometry"], choose.inputs["True"])
geometry = expand_smooth(
group, clean, iterations_name="Smooth", weight_name="Smooth Weight",
group, choose.outputs["Output"], iterations_name="Smooth", weight_name="Smooth Weight",
)
smooth = nodes.new("GeometryNodeSetShadeSmooth")
smooth.domain = "FACE"

View File

@@ -31,12 +31,12 @@ def build_image_relief_plane_group(image_plane_group):
subdivide.min_value = 0
subdivide.max_value = 10
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.default_value = 0.5
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
thickness.description = "Fixed relief base thickness"
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
depth_scale.default_value = 1.0
depth_scale.default_value = 0.5
depth_scale.min_value = 0.0
options = group.interface.new_panel(name="Options", default_closed=True)
base_plane_depth = _interface_socket(
@@ -129,10 +129,13 @@ def build_image_relief_plane_group(image_plane_group):
has_thickness = _math(nodes, "GREATER_THAN")
has_thickness.inputs[1].default_value = 1e-6
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
flat_offset = _math(nodes, "MAXIMUM")
flat_offset.inputs[1].default_value = 0.0
links.new(full_offset.outputs[0], flat_offset.inputs[0])
safe_offset = nodes.new("GeometryNodeSwitch")
safe_offset.input_type = "FLOAT"
links.new(has_thickness.outputs[0], safe_offset.inputs["Switch"])
links.new(full_offset.outputs[0], safe_offset.inputs["False"])
links.new(flat_offset.outputs[0], safe_offset.inputs["False"])
links.new(limited_offset.outputs[0], safe_offset.inputs["True"])
position = nodes.new("GeometryNodeInputPosition")