feat: Implement weight cutout boundary smoothing for image depth cutout
- Added a new proposal for boundary smoothing in the weight cutout process, differentiating between Split edges and Outline smoothing requirements. - Introduced a new capability `cutout-boundary-smoothing` to control smoothing strength and default values. - Updated specifications to define the behavior of boundary-specific smoothing strength and default settings. - Created tasks to verify the implementation of boundary behavior and asset updates. - Developed comprehensive tests for depth symmetry and normal scaling to ensure expected behavior and performance. - Introduced new attributes for normal mapping to facilitate persistent face attributes sharing between depth symmetry and image materials. - Built a new node group for O Image Depth Symmetry, integrating the new smoothing capabilities and ensuring compatibility with existing workflows.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-14
|
||||
77
openspec/changes/add-projected-depth-symmetry/design.md
Normal file
77
openspec/changes/add-projected-depth-symmetry/design.md
Normal file
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
`O Image Depth Balloon` 当前以原始 Cutout 网格作为对称平面上的轮廓,通过 `o_balloon` 和深度差构建两侧。新需求已经用用户提供的小车图完成独立实验:正面投射后截留正 Z 区域,两侧围绕固定 XY 平面镜像,并以直壁连接对应外边界。
|
||||
|
||||
实验文件位于 `C:/Users/icrdr/AppData/Local/Temp/anyimage-car-symmetry/`:`car-depth-balloon-fast-modes.blend`、`fast_balloon.py`、`performance-results.json`、`mode-validation.json`。这些是可复核的实验材料,正式实现和测试自行提供所需资源。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** 双模式、手势预设、完整对称网格、独立且可复用的节点构建、统一命名。
|
||||
|
||||
**Non-Goals:** 本变更不包含旧资产兼容层、AI 模型调整或自动推断最佳车身对称方向。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 名称与手势
|
||||
|
||||
| 对象 | 名称 |
|
||||
|---|---|
|
||||
| 功能与菜单 | `Depth Symmetry` |
|
||||
| 节点组 | `O Image Depth Symmetry` |
|
||||
| 构建模块与入口 | `image_depth_symmetry.py` / `build_image_depth_symmetry_group()` |
|
||||
| Shape 标识 | `DEPTH_SYMMETRY` |
|
||||
| Mode 顺序 | `Balloon`、`Projected` |
|
||||
|
||||
`Balloon` 描述原有鼓起方式;`Projected` 描述新模式的投射轮廓。功能名称表达两者共同的对称职责,避免用其中一个模式命名整个节点组。
|
||||
|
||||
| 手势 | Depth Cutout(现有) | Depth Symmetry |
|
||||
|---|---|---|
|
||||
| Lasso | Balloon | Balloon |
|
||||
| Polyline | Shell | Projected |
|
||||
|
||||
在 Cutout 对象创建阶段按 gesture 设置 Mode。预设只影响初值,用户仍可直接切换节点 Mode。裸节点默认 Balloon,保持原有入口的默认行为。
|
||||
|
||||
### 直接构建 Projected 正面
|
||||
|
||||
复用 `_position_field` 读取 Depth Image 的相机 XYZ,乘以 Uniform Scale,将 Y 转为现有 Cutout 坐标约定,以 `Reference Depth − metric depth` 构建 Z。Depth Scale 沿用已验证的平面坐标与相机坐标插值,并缩放深度差。
|
||||
|
||||
将 Depth Direction 对应的方向对齐到局部 Z 轴,输出成形坐标中的正面。Reference Depth 和 Depth Direction 共同改变位移与投射后的轮廓;最终镜像面固定在成形坐标 `Z=0`,不把镜像结果转回倾斜平面。既有 Depth Axis 作为统一的最终坐标变换保留。
|
||||
|
||||
复用公共边界平滑,随后在 POINT 域判断负 Z,并在 FACE 域判断是否包含越界顶点。删除包含越界顶点的整面及其失去面的边线、孤点。仅保留正 Z 面,不产生压扁在对称平面上的外围裙边。平面附近使用与几何尺度相适应的小容差处理零厚度退化。
|
||||
|
||||
采用整面删除,因为它只依赖现有网格与字段求值;相较精确裁切,边界具有网格分辨率级别的近似。新分支不使用 Mesh Boolean,也不嵌套 Depth Plane、Depth Cutout 节点组。
|
||||
|
||||
### 镜像与直壁
|
||||
|
||||
先完成正面和平滑,再镜像到负 Z,避免封闭后的全网格平滑破坏直壁。以边的面邻接数识别正面外边界,将每个边界点 `(x,y,z)` 与 `(x,y,-z)` 连接;生成的侧壁沿 Z 轴延伸,其法线垂直于 Z 轴。保留正背面平滑着色,侧壁使用平面着色。
|
||||
|
||||
合并对应顶点,正确处理对称平面上的零厚度边。Double Sided 打开时生成完整封闭网格;关闭时沿用已验证原型的正面预览行为。Balloon 分支原有 Double Sided、Thickness、平滑等语义保持不变。Thickness 继续控制 Balloon 轮廓;Projected 的形体由 Depth Scale、Reference Depth、Depth Direction 控制,不引入未经验证的厚度推断。
|
||||
|
||||
### 复用与模式分派
|
||||
|
||||
复用公共节点接口、深度采样、岛清理、边界平滑、普通平滑和属性函数。节点内部保留直接构建的两条几何分支,通过 Mode 选择;共享最终输出和坐标约定,不通过嵌套节点组包装旧实现。
|
||||
|
||||
Projected 需要局部辅助函数时放在该业务模块;只有确有其他消费者的函数才进入 common。UV 和现有命名属性继续随拓扑传递,新增临时属性需要明确生命周期;源码与保存资产中 Capture Attribute 数量均为零。
|
||||
|
||||
### 性能与验证
|
||||
|
||||
同一小车输入、相同参数与 3 次边界平滑,预热 4 次后连续修改 Reference Depth,记录 24 次依赖图求值的中位数。外部实验的双模式 Projected 分支结果如下:
|
||||
|
||||
| 输入顶点 | 布尔原型 | 双模式直接构建 | 提速 |
|
||||
|---|---:|---:|---:|
|
||||
| 27,860 | 174.9 ms | 30.9 ms | 约 5.7 倍 |
|
||||
| 165,771 | 960.7 ms | 135.6 ms | 约 7.1 倍 |
|
||||
|
||||
这些数值是当前机器的对比证据,不作为跨机器测试断言。正式验证保留同输入、同平滑、同参数的对照,并分别记录 Reference Depth 与 Depth Direction 调整耗时。几何测试覆盖封闭性、顶点流形性、面绕序、XY 镜像、直壁法线、越界删除、空结果及模式切换;Balloon 分支与变更前的基准几何比较。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 整面删除使切口随网格密度离散变化 → 使用已有采样密度控制,比较多个密度与连续参数变化,明确其为近似边界。
|
||||
- 深度噪声可能形成小岛或点接触 → 复用岛清理,测试连通性与顶点流形性,退化处删除无效拓扑。
|
||||
- 在补壁后平滑可能令侧壁弯曲 → Projected 的平滑在正面阶段完成,再镜像和补壁。
|
||||
- 只在中等网格测量可能掩盖扩展成本 → 同时验证普通与加密输入,记录求值而非渲染耗时。
|
||||
- 资产与代码名称失配 → 同步重命名 Shape 标识、模块、校准函数、构建/校验清单和界面文本,构建保存资产后检查旧引用。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
实现时整体替换旧名称并更新相关代码、测试和节点资产,不保留旧路径或别名。通过 `uv run node-group build` 构建并验证 `O_AnyImage.blend`,检查新组存在、旧组不再由构建产出。已保存在用户工程中的旧节点组不执行自动扫描或重写。
|
||||
29
openspec/changes/add-projected-depth-symmetry/proposal.md
Normal file
29
openspec/changes/add-projected-depth-symmetry/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
现有 Depth Balloon 始终向原 Cutout 剪影收拢。用户需要另一种对称形体:轮廓随深度投射变化,两侧围绕固定 XY 平面对称,并通过直壁补成完整网格。小车独立实验已验证效果;直接构建版本在保留相同边界平滑时,比布尔原型约快 5.7–7.1 倍。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将功能命名为 **Depth Symmetry**,节点组命名为 `O Image Depth Symmetry`,提供 `Balloon / Projected` 两种模式。
|
||||
- `Balloon` 保持原有行为;`Projected` 直接采样深度、调整投射方向、删除越界面、镜像并补直壁。
|
||||
- Lasso 默认使用 `Balloon`,Polyline 默认使用 `Projected`;创建后仍可通过 Mode 切换。与现有 Depth Cutout 的 `Lasso → Balloon / Polyline → Shell` 对应。
|
||||
- 正面保持在局部 `Z≥0`;开启 Double Sided 后,两侧关于局部 `Z=0` 对称,外边界沿 Z 轴连接,形成封闭网格。
|
||||
- 复用公共采样、清理、平滑和节点接口构建函数,新模式独立构建,不使用布尔或依赖 Depth Plane、Depth Cutout 节点组。
|
||||
- **BREAKING**:将节点资产名、Shape 标识 `DEPTH_BALLOON`、构建模块及相关代码名称统一为 Depth Symmetry,不保留旧名转发或兼容资产。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `image-depth-symmetry`: 双模式深度对称节点、直壁封闭、手势预设、命名和性能约束。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。当前 `openspec/specs` 尚无可修改的已归档规格。
|
||||
|
||||
## Impact
|
||||
|
||||
- `tools/nodes/groups/image_depth_balloon.py`、相关公共构建函数、节点组构建与校验入口。
|
||||
- `src/anyimage/operators/cutout_tool` 的 Shape 菜单、手势分派、深度校准与对象创建,以及相关界面文本。
|
||||
- `src/anyimage/assets/O_AnyImage.blend` 和对应节点、对象创建、资产清单测试。
|
||||
- 实现时运行相关测试与 `uv run node-group build`,更新并验证源码及资产;不新增依赖。
|
||||
@@ -0,0 +1,74 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Unified depth symmetry identity
|
||||
系统 SHALL 使用菜单名称 `Depth Symmetry`、节点组名称 `O Image Depth Symmetry` 和 Shape 标识 `DEPTH_SYMMETRY`,节点 Mode SHALL 按 `Balloon / Projected` 顺序提供选择。
|
||||
|
||||
#### Scenario: Load the node asset
|
||||
- **WHEN** 用户创建 Depth Symmetry 对象
|
||||
- **THEN** 系统从节点资产加载 `O Image Depth Symmetry`,Mode 包含 Balloon 和 Projected
|
||||
|
||||
### Requirement: Gesture selects the initial mode
|
||||
系统 SHALL 为 Lasso 创建结果选择 Balloon,为 Polyline 创建结果选择 Projected,并允许用户随后手动切换 Mode。
|
||||
|
||||
#### Scenario: Lasso preset
|
||||
- **WHEN** 用户通过 Lasso 选择区域并创建 Depth Symmetry
|
||||
- **THEN** Mode 初始值为 Balloon
|
||||
|
||||
#### Scenario: Polyline preset
|
||||
- **WHEN** 用户通过 Polyline 选择区域并创建 Depth Symmetry
|
||||
- **THEN** Mode 初始值为 Projected,Depth Cutout 自身的 Polyline → Shell 关系保持不变
|
||||
|
||||
#### Scenario: Switch the generated object's mode
|
||||
- **WHEN** 用户修改已创建对象的 Mode
|
||||
- **THEN** 同一份输入网格和深度数据按所选模式重新求值,不受创建手势锁定
|
||||
|
||||
### Requirement: Preserve balloon behavior
|
||||
Balloon 模式 SHALL 保持原 Depth Balloon 的几何与参数行为,在对称平面向原 Cutout 剪影收拢。
|
||||
|
||||
#### Scenario: Compare the original mode
|
||||
- **WHEN** 同一输入、深度图和原有参数分别进入旧基准与新节点的 Balloon 模式
|
||||
- **THEN** 输出拓扑和位置在浮点容差内一致,包括 Double Sided、Thickness、平滑及 Depth Axis 的行为
|
||||
|
||||
### Requirement: Projected positive surface
|
||||
Projected 模式 SHALL 独立读取深度,使用 Reference Depth 和 Depth Direction 调整正面的置换与投射方向。成形坐标中的正面 MUST 保持在 `Z≥0`,越界面 SHALL 被删除。
|
||||
|
||||
#### Scenario: Adjust both depth controls
|
||||
- **WHEN** 用户修改 Reference Depth 或 Depth Direction
|
||||
- **THEN** 正面形体与投射轮廓重新求值,镜像平面保持在成形坐标的 `Z=0`
|
||||
|
||||
#### Scenario: A face crosses the plane
|
||||
- **WHEN** 正面某个面含有负 Z 顶点
|
||||
- **THEN** 该面及失去面支撑的边线、孤点被删除,剩余结果不包含越界正面或外围压平裙边
|
||||
|
||||
#### Scenario: Everything is outside
|
||||
- **WHEN** 参数使全部输入面越界
|
||||
- **THEN** 输出为空几何,求值正常结束
|
||||
|
||||
### Requirement: Close the projected symmetric volume with straight walls
|
||||
Projected 开启 Double Sided 时 SHALL 关于成形坐标 `Z=0` 镜像,并沿 Z 轴直线连接对应外边界,生成封闭网格。正面平滑 SHALL 在镜像补壁之前完成。
|
||||
|
||||
#### Scenario: Complete a car volume
|
||||
- **WHEN** 合法正面存在外边界且 Double Sided 打开
|
||||
- **THEN** 输出网格没有开放边、异常面邻接或顶点非流形,面绕序一致;每个 `(x,y,z)` 存在镜像对应点 `(x,y,-z)`
|
||||
|
||||
#### Scenario: Check straight walls after smoothing
|
||||
- **WHEN** 用户开启 Projected 平滑后生成双面网格
|
||||
- **THEN** 外边界仍沿 Z 轴直线连接,侧壁法线与 Z 轴垂直,平滑不使侧壁弯曲
|
||||
|
||||
#### Scenario: Preview only the front
|
||||
- **WHEN** 用户关闭 Projected 的 Double Sided
|
||||
- **THEN** 仅输出非负 Z 正面,用于检查投射和裁去区域
|
||||
|
||||
### Requirement: Direct node construction and shared builders
|
||||
Projected 分支 MUST 直接使用基础节点与公共构建函数,不得使用 Mesh Boolean、Capture Attribute 或依赖 Depth Plane、Depth Cutout 节点组。插件运行时 SHALL 从资产加载节点组。
|
||||
|
||||
#### Scenario: Inspect the built asset
|
||||
- **WHEN** 构建与检查 `O Image Depth Symmetry`
|
||||
- **THEN** 新模式不存在布尔、Capture Attribute 或上述节点组依赖,Depth Symmetry 源码与资产接口一致
|
||||
|
||||
### Requirement: Verify performance on equivalent inputs
|
||||
实现 SHALL 在同一机器、同输入、同平滑和同参数序列下,对比直接构建版本与已验证的布尔原型,分别记录 Reference Depth 和 Depth Direction 更新耗时。
|
||||
|
||||
#### Scenario: Compare ordinary and dense meshes
|
||||
- **WHEN** 对普通与加密网格执行预热后的重复求值
|
||||
- **THEN** 报告两种更新的中位数、输入规模与测试条件,并确认正式双模式 Projected 分支的求值收益,而非以固定毫秒值作为跨机器测试门槛
|
||||
29
openspec/changes/add-projected-depth-symmetry/tasks.md
Normal file
29
openspec/changes/add-projected-depth-symmetry/tasks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 1. 统一功能命名
|
||||
|
||||
- [x] 1.1 将构建模块和入口重命名为 `image_depth_symmetry.py` / `build_image_depth_symmetry_group()`,更新构建、校验与测试引用。
|
||||
- [x] 1.2 将菜单、Shape 标识、节点资产名称和深度校准相关名称统一为 Depth Symmetry,清除旧名称的业务引用。
|
||||
|
||||
## 2. 双模式节点
|
||||
|
||||
- [x] 2.1 保留 Balloon 分支与参数语义,新增顺序为 `Balloon / Projected` 的 Mode;复用公共接口、清理、属性及输出坐标处理。
|
||||
- [x] 2.2 直接构建 Projected 深度正面,使 Depth Direction 与 Reference Depth 控制投射,保留固定 XY 对称平面。
|
||||
- [x] 2.3 复用边界平滑与正面平滑,删除含越界点的整面并清理无面支撑拓扑,处理零厚度与空结果。
|
||||
- [x] 2.4 构建正面镜像与直壁补全,处理接缝合并、绕序、平滑着色和 Double Sided 正面预览。
|
||||
- [x] 2.5 整理节点布局,确保直接构建、无布尔、无 Capture Attribute、无复杂几何节点组依赖。
|
||||
|
||||
## 3. 手势预设
|
||||
|
||||
- [x] 3.1 在 Cutout 对象创建阶段设置 `Lasso → Balloon`、`Polyline → Projected`,保留现有 Depth Cutout 的 Balloon / Shell 预设。
|
||||
- [x] 3.2 验证两类手势的默认值、创建后 Mode 切换、深度校准输入及节点资产加载名称。
|
||||
|
||||
## 4. 几何与性能验证
|
||||
|
||||
- [x] 4.1 补充可独立运行的几何测试,覆盖非负正面、双面镜像、封闭性、顶点流形性、面绕序、直壁法线、平滑、空结果和 Depth Axis。
|
||||
- [x] 4.2 将新 Balloon 分支与变更前基准比较,验证原参数行为及模式切换不污染状态。
|
||||
- [x] 4.3 使用同输入、同边界平滑的普通与加密网格,分别测量 Reference Depth 和 Depth Direction 更新,记录预热后中位数及条件。
|
||||
- [x] 4.4 用已确认的小车案例检查视角一致的效果对比,记录整面删除在切口处的分辨率取舍。
|
||||
|
||||
## 5. 资产交付
|
||||
|
||||
- [x] 5.1 更新资产清单和相关测试,运行 `uv run node-group build`,保存并验证 `src/anyimage/assets/O_AnyImage.blend`。
|
||||
- [x] 5.2 运行相关对象创建与节点测试,检查源码、资产中的旧名称及最终差异,确认工作正常退出;不构建文档或打包扩展。
|
||||
28
openspec/changes/add-projected-depth-symmetry/validation.md
Normal file
28
openspec/changes/add-projected-depth-symmetry/validation.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 实现验证
|
||||
|
||||
2026-09-14,本机 Windows;资产由 Blender 4.5.10 LTS 构建和重载校验,几何测试及性能测量使用 bpy 4.5.3。
|
||||
|
||||
## 几何
|
||||
|
||||
- 源码构建和保存资产均验证正面越界删除、完整镜像、边与顶点流形性、面绕序、直壁法线、平滑、空输出和 Depth Axis。
|
||||
- 测试几何尺度为 0.001、1、100,包含倾斜方向和正面平滑。
|
||||
- Lasso / Polyline 默认值、模式往返切换与深度校准输入通过对象创建测试。
|
||||
- 小车 Balloon 分支在默认、单面、零厚度、平滑、坐标轴及方向变化共 5 组参数下,与变更前基准的顶点和面完全一致,最大坐标差为 0。
|
||||
- 同相机、同参数的小车渲染与已确认布尔原型一致。基准顶点到新网格最近顶点距离中位数为 0,P95 为 6.66e-8,最大值为 0.0361;这是顶点距离比较,不是曲面误差界。整面删除的切口精度受输入网格密度影响。
|
||||
|
||||
## 性能
|
||||
|
||||
同一小车网格及深度图,Boundary Smooth=3、Smooth=0、Depth Scale=1、Uniform Scale=1、Double Sided=true。Reference Depth=5.5786533,Depth Direction=(-0.6635724, -0.2783087, 0.6944177)。每次只修改所列参数:参考深度或方向 X 分量增加 `0.002*sin(i*0.71)`;预热 4 次,记录随后 24 次依赖图更新耗时中位数。加密输入通过一次 Simple Subdivision 生成,保留 UV。
|
||||
|
||||
| 输入顶点 | 修改参数 | 布尔原型 | 正式 Projected | 提速 |
|
||||
|---:|---|---:|---:|---:|
|
||||
| 27,860 | Reference Depth | 170.4 ms | 32.8 ms | 5.2 倍 |
|
||||
| 27,860 | Depth Direction | 173.1 ms | 32.6 ms | 5.3 倍 |
|
||||
| 165,771 | Reference Depth | 948.3 ms | 147.0 ms | 6.5 倍 |
|
||||
| 165,771 | Depth Direction | 987.2 ms | 151.5 ms | 6.5 倍 |
|
||||
|
||||
本机复核材料在 `C:/Users/icrdr/AppData/Local/Temp/anyimage-car-symmetry/`:`validate_production.py`、`production-validation.json`、`production-baseline.png`、`production-result.png`、`car-depth-symmetry-production.blend`。性能数字为本机对照证据。
|
||||
|
||||
## 交付检查
|
||||
|
||||
`uv run node-group build` 正常退出,10 个保存节点组通过 Blender 重载校验,附带全量测试 1064 项全部通过(188.83 秒)。源码、测试和构建清单中旧名称引用为零;资产清单验证新组存在且旧组不存在。`openspec validate add-projected-depth-symmetry` 通过。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-14
|
||||
29
openspec/changes/weight-cutout-boundary-smoothing/design.md
Normal file
29
openspec/changes/weight-cutout-boundary-smoothing/design.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Context
|
||||
|
||||
Depth Cutout 当前在 Split 后对全部实际边界使用同一两圈 influence 和同一 `Smooth Weight`。原始 Outline 需要保形,Split 形成的切边则需要更强平滑;两者可通过分裂前原始几何的轮廓字段区分。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 只保留一个 `Boundary Smooth` 次数输入。
|
||||
- Split 边使用完整强度,Outline 使用固定 `0.1` 倍强度。
|
||||
- 两类影响带相交时保留更强的 Split 平滑。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不增加独立的 Outline 或 Split 平滑参数。
|
||||
- 不改变影响带宽度、整体 `Smooth Weight` 或普通 `Smooth` 的语义。
|
||||
|
||||
## Decisions
|
||||
|
||||
在 Split 前保留原始几何引用,并在当前投影几何上通过 `original_boundary_field()` 识别原始 Outline。分别构造 Outline 与 Split 边界字段及其两圈 influence:当前实际边界扣除 Outline 后作为 Split 边,Outline influence 乘 `0.1`,最终使用两者最大值。
|
||||
|
||||
两类边界继续进入同一个 Repeat Zone,共用 `Boundary Smooth` 次数和 `Smooth Weight`。相比串联两套平滑循环,这样不会增加公开参数,并保持节点数量与求值成本较低。交汇区域取最大值,使 Split 的完整强度优先。
|
||||
|
||||
`Boundary Smooth` 默认值改为 `4`,范围及零值旁路保持不变。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Outline 每轮权重为 `0.1`,多次迭代后的累计位移不等于最终位移的 10% → 以行为测试验证它始终弱于相同设置下的 Split,并由默认 4 次控制累计程度。
|
||||
- Split influence 向 Outline 邻域扩散时会在交点附近覆盖弱 Outline influence → 这是 Split 优先规则的预期结果,测试交汇区域输出有限且拓扑不变。
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
`O Image Depth Cutout` 的 Split 边与原始 Outline 对几何平滑的需求不同:Split 边需要完整强度消除切缝折痕,而 Outline 只需轻微整理,避免轮廓被过度软化。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `Boundary Smooth` 继续作为唯一的边界平滑次数控制,默认值改为 `4`。
|
||||
- Split 边使用完整 `Smooth Weight`,Outline 使用其 `0.1` 倍;两类影响相交时由 Split 强度优先。
|
||||
- 保持 `Boundary Smooth = 0` 完全关闭边界平滑。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `cutout-boundary-smoothing`: 定义 Depth Cutout 对 Split 边与 Outline 的差异化平滑强度和默认控制值。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
影响 `tools/nodes/common/boundary_smoothing.py`、`tools/nodes/groups/image_depth_cutout.py`、相关节点测试和 `src/anyimage/assets/O_AnyImage.blend`;不增加或移除公开输入。
|
||||
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Boundary-specific smoothing strength
|
||||
|
||||
`O Image Depth Cutout` SHALL 使用单个 `Boundary Smooth` 输入控制边界平滑次数。Split 边 SHALL 使用完整 `Smooth Weight`,原始 Outline SHALL 使用 `0.1` 倍 `Smooth Weight`;两类影响相交时 Split 强度 MUST 优先。
|
||||
|
||||
#### Scenario: Separate Split and Outline response
|
||||
|
||||
- **WHEN** 同一网格同时存在 Split 边与原始 Outline 且启用 Boundary Smooth
|
||||
- **THEN** Split 边按完整 Smooth Weight 平滑
|
||||
- **AND** 仅受 Outline 影响的点按其 0.1 倍平滑
|
||||
|
||||
#### Scenario: Split and Outline intersection
|
||||
|
||||
- **WHEN** Split 与 Outline 的两圈影响范围相交
|
||||
- **THEN** 相交区域使用两者中较强的 Split influence
|
||||
|
||||
### Requirement: Boundary Smooth default and bypass
|
||||
|
||||
`O Image Depth Cutout` 的 `Boundary Smooth` SHALL 默认为 `4`,保持范围 `0–16`;值为 `0` 时 MUST 完全旁路边界平滑。
|
||||
|
||||
#### Scenario: New node group defaults
|
||||
|
||||
- **WHEN** 构建新的 O Image Depth Cutout 节点组
|
||||
- **THEN** Boundary Smooth 默认值为 4
|
||||
|
||||
#### Scenario: Disabled boundary smoothing
|
||||
|
||||
- **WHEN** Boundary Smooth 为 0
|
||||
- **THEN** Split 边和 Outline 均不发生边界平滑位移
|
||||
@@ -0,0 +1,9 @@
|
||||
## 1. Boundary behavior
|
||||
|
||||
- [x] 1.1 区分 Depth Cutout 的 Split 与 Outline influence,并在单一平滑循环中应用完整与 0.1 倍权重。
|
||||
- [x] 1.2 将 O Image Depth Cutout 的 Boundary Smooth 默认值改为 4,保持零值旁路和既有范围。
|
||||
|
||||
## 2. Verification and asset
|
||||
|
||||
- [x] 2.1 更新相关行为与接口测试,覆盖 Split 优先、Outline 弱权重、默认值和关闭行为。
|
||||
- [x] 2.2 运行相关测试,执行 `uv run node-group build` 并确认保存资产验证通过。
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
"""Calibrate metric reference depth and the Depth Balloon base plane."""
|
||||
"""Calibrate metric reference depth and the Depth Symmetry base plane."""
|
||||
|
||||
PLANE_VARIANCE_FLOOR_RATIO = 0.025
|
||||
DEPTH_REFERENCE_PERCENTILE = 95.0
|
||||
@@ -122,7 +122,7 @@ def _stable_plane(coordinates, values):
|
||||
return plane
|
||||
|
||||
|
||||
def depth_balloon_projection_parameters(
|
||||
def depth_symmetry_projection_parameters(
|
||||
vertices,
|
||||
vertex_uv,
|
||||
camera_depth,
|
||||
@@ -135,15 +135,15 @@ def depth_balloon_projection_parameters(
|
||||
vertices = np.asarray(vertices, dtype=np.float32)
|
||||
uv = np.asarray(vertex_uv, dtype=np.float32)
|
||||
if vertices.ndim != 2 or vertices.shape[1] != 3:
|
||||
raise ValueError("Depth Balloon vertices must have three components")
|
||||
raise ValueError("Depth Symmetry vertices must have three components")
|
||||
if uv.shape != (len(vertices), 2):
|
||||
raise ValueError("Depth Balloon UV coordinates do not match its vertices")
|
||||
raise ValueError("Depth Symmetry UV coordinates do not match its vertices")
|
||||
camera_depth = np.asarray(camera_depth, dtype=np.float32)
|
||||
valid = np.asarray(valid, dtype=bool)
|
||||
if valid.shape != camera_depth.shape:
|
||||
raise ValueError("Depth Balloon depth and mask dimensions do not match")
|
||||
raise ValueError("Depth Symmetry depth and mask dimensions do not match")
|
||||
if not uniform_scale > 0.0:
|
||||
raise ValueError("Depth Balloon Uniform Scale must be positive")
|
||||
raise ValueError("Depth Symmetry Uniform Scale must be positive")
|
||||
height, width = camera_depth.shape
|
||||
sample_x = uv[:, 0] * max(width - 1, 0)
|
||||
sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0)
|
||||
@@ -155,7 +155,7 @@ def depth_balloon_projection_parameters(
|
||||
sample_y,
|
||||
)
|
||||
if not usable.any():
|
||||
raise ValueError("MoGe-2 found no valid Depth Balloon samples")
|
||||
raise ValueError("MoGe-2 found no valid Depth Symmetry samples")
|
||||
local_coordinates = vertices[:, :2]
|
||||
metric_depth = sampled[usable] * float(uniform_scale)
|
||||
plane = _stable_plane(local_coordinates[usable], metric_depth)
|
||||
|
||||
@@ -6,7 +6,7 @@ from .shape import CUTOUT_SHAPE_LABELS
|
||||
CUTOUT_SHAPE_BUTTONS = (
|
||||
("FLAT", "MESH_GRID"),
|
||||
("SOLID", "MESH_UVSPHERE"),
|
||||
("DEPTH_BALLOON", "META_BALL"),
|
||||
("DEPTH_SYMMETRY", "META_BALL"),
|
||||
("DEPTH_SOLID", "SURFACE_NCURVE"),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ from enum import StrEnum
|
||||
import bpy
|
||||
|
||||
from ...common.depth import camera_depth_values, depth_uniform_scale
|
||||
from ...common.material import (
|
||||
IMAGE_MATERIAL_NODE_GROUP_NAME,
|
||||
create_image_material,
|
||||
)
|
||||
from ...common.material import create_image_material
|
||||
from ...common.object import (
|
||||
finalize_object_result,
|
||||
modifier_input_identifier,
|
||||
@@ -18,7 +15,7 @@ from ...common.node import load_node_group
|
||||
from .depth_calibration import (
|
||||
DEFAULT_DEPTH_BASELINE,
|
||||
DEFAULT_DEPTH_DIRECTION,
|
||||
depth_balloon_projection_parameters,
|
||||
depth_symmetry_projection_parameters,
|
||||
metric_depth_reference,
|
||||
use_adaptive_depth_calibration,
|
||||
)
|
||||
@@ -31,7 +28,6 @@ from .geometry import (
|
||||
)
|
||||
from .shape import (
|
||||
BALLOON_ATTRIBUTE_NAME,
|
||||
NORMAL_SCALE_ATTRIBUTE_NAME,
|
||||
CUTOUT_NODE_GROUP_NAMES,
|
||||
DEPTH_CUTOUT_SHAPES,
|
||||
)
|
||||
@@ -75,24 +71,6 @@ def load_cutout_node_group(shape):
|
||||
return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape])
|
||||
|
||||
|
||||
def _connect_cutout_normal_scale(material):
|
||||
nodes = material.node_tree.nodes
|
||||
image_layer = next(
|
||||
node
|
||||
for node in nodes
|
||||
if node.bl_idname == "ShaderNodeGroup"
|
||||
and node.node_tree is not None
|
||||
and node.node_tree.name == IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
)
|
||||
attribute = nodes.new("ShaderNodeAttribute")
|
||||
attribute.attribute_type = "GEOMETRY"
|
||||
attribute.attribute_name = NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
attribute.location = (-550.0, -250.0)
|
||||
material.node_tree.links.new(
|
||||
attribute.outputs["Fac"], image_layer.inputs["Normal Scale"]
|
||||
)
|
||||
|
||||
|
||||
def create_shape_object(
|
||||
context,
|
||||
source_object,
|
||||
@@ -143,7 +121,6 @@ def create_shape_object(
|
||||
scene=context.scene,
|
||||
)
|
||||
depth_reference = None
|
||||
_connect_cutout_normal_scale(material)
|
||||
uniform_scale = None
|
||||
depth_direction = None
|
||||
if shape in DEPTH_CUTOUT_SHAPES:
|
||||
@@ -161,7 +138,7 @@ def create_shape_object(
|
||||
)
|
||||
if not use_adaptive_depth_calibration(transparent_fraction):
|
||||
depth_reference = DEFAULT_DEPTH_BASELINE
|
||||
if shape == "DEPTH_BALLOON":
|
||||
if shape == "DEPTH_SYMMETRY":
|
||||
depth_direction = DEFAULT_DEPTH_DIRECTION
|
||||
elif shape == "DEPTH_SOLID":
|
||||
depth_reference = metric_depth_reference(
|
||||
@@ -170,7 +147,7 @@ def create_shape_object(
|
||||
uniform_scale,
|
||||
)
|
||||
else:
|
||||
depth_reference, depth_direction = depth_balloon_projection_parameters(
|
||||
depth_reference, depth_direction = depth_symmetry_projection_parameters(
|
||||
base_shape.vertices,
|
||||
base_shape.uv,
|
||||
camera_depth,
|
||||
@@ -222,7 +199,7 @@ def create_shape_object(
|
||||
),
|
||||
value,
|
||||
)
|
||||
if shape in {"FLAT", "SOLID", "DEPTH_SOLID"} and gesture == "POLYLINE":
|
||||
if gesture == "POLYLINE" and shape != "DEPTH_SYMMETRY":
|
||||
set_modifier_input(
|
||||
modifier,
|
||||
modifier_input_identifier(node_group, "Mode"),
|
||||
@@ -246,7 +223,7 @@ def create_shape_object(
|
||||
modifier_input_identifier(node_group, name),
|
||||
value,
|
||||
)
|
||||
if shape == "DEPTH_BALLOON":
|
||||
if shape == "DEPTH_SYMMETRY":
|
||||
set_modifier_input(
|
||||
modifier,
|
||||
modifier_input_identifier(node_group, "Depth Direction"),
|
||||
|
||||
@@ -74,7 +74,7 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"}
|
||||
def normal_mode_for_shape(shape, generate_normal):
|
||||
if shape == "DEPTH_SOLID":
|
||||
return NormalMode.OBJECT
|
||||
if shape == "DEPTH_BALLOON":
|
||||
if shape == "DEPTH_SYMMETRY":
|
||||
return NormalMode.TANGENT
|
||||
if not generate_normal:
|
||||
return NormalMode.NONE
|
||||
@@ -82,7 +82,7 @@ def normal_mode_for_shape(shape, generate_normal):
|
||||
|
||||
|
||||
def shape_uses_depth(shape):
|
||||
return shape in {"DEPTH_SOLID", "DEPTH_BALLOON"}
|
||||
return shape in {"DEPTH_SOLID", "DEPTH_SYMMETRY"}
|
||||
|
||||
|
||||
def create_cutout_shape(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Cutout Shape identifiers shared by runtime and node-asset tools."""
|
||||
|
||||
BALLOON_ATTRIBUTE_NAME = "o_balloon"
|
||||
NORMAL_SCALE_ATTRIBUTE_NAME = "o_normal_scale"
|
||||
NORMAL_REDUCTION_ATTRIBUTE_NAME = "o_normal_reduction"
|
||||
CUTOUT_Z_TO_X = (
|
||||
(0.0, 0.0, -1.0, 0.0),
|
||||
(-1.0, 0.0, 0.0, 0.0),
|
||||
@@ -11,14 +11,14 @@ CUTOUT_Z_TO_X = (
|
||||
CUTOUT_NODE_GROUP_NAMES = {
|
||||
"FLAT": "O Image Cutout",
|
||||
"SOLID": "O Image Cutout",
|
||||
"DEPTH_BALLOON": "O Image Depth Balloon",
|
||||
"DEPTH_SYMMETRY": "O Image Depth Symmetry",
|
||||
"DEPTH_SOLID": "O Image Depth Cutout",
|
||||
}
|
||||
CUTOUT_SHAPE_LABELS = {
|
||||
"FLAT": "Flat",
|
||||
"SOLID": "Solid",
|
||||
"DEPTH_BALLOON": "Depth Balloon",
|
||||
"DEPTH_SYMMETRY": "Depth Symmetry",
|
||||
"DEPTH_SOLID": "Depth Solid",
|
||||
}
|
||||
CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES)
|
||||
DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SOLID"})
|
||||
DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_SYMMETRY", "DEPTH_SOLID"})
|
||||
|
||||
@@ -212,7 +212,7 @@ def cutout_generate_normal_property():
|
||||
name="Normal Map",
|
||||
description=(
|
||||
"Generate a normal map to add surface detail under lighting for Flat and Solid shapes. "
|
||||
"Depth Solid and Depth Balloon always generate normal maps."
|
||||
"Depth Solid and Depth Symmetry always generate normal maps."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -5,9 +5,7 @@ import numpy as np
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import material as material_data
|
||||
from anyimage.operators.cutout_tool import object as cutout_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs
|
||||
from tests.support.image_objects import _create_cutout_shape, _group_node
|
||||
|
||||
|
||||
def test_image_layer_switches_between_tangent_and_object_space_normal():
|
||||
@@ -201,28 +199,3 @@ def test_shared_conversion_images_use_blender_duplicate_suffix():
|
||||
assert first is not second
|
||||
assert first.name == "Poster_color.png"
|
||||
assert second.name == "Poster_color.png.001"
|
||||
|
||||
|
||||
def test_balloon_normal_scale_uses_existing_image_layer_input():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = material_data.material_node_group()
|
||||
original_inputs = _inputs(group)
|
||||
image_material = bpy.data.materials.new("Balloon")
|
||||
image_material.use_nodes = True
|
||||
image_material.node_tree.nodes.clear()
|
||||
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
|
||||
image_layer.node_tree = group
|
||||
|
||||
cutout_object._connect_cutout_normal_scale(image_material)
|
||||
|
||||
assert _inputs(group) == original_inputs
|
||||
attribute = next(
|
||||
node
|
||||
for node in image_material.node_tree.nodes
|
||||
if node != image_layer
|
||||
)
|
||||
assert attribute.bl_idname == "ShaderNodeAttribute"
|
||||
assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
link = next(iter(image_material.node_tree.links))
|
||||
assert link.from_socket == attribute.outputs["Fac"]
|
||||
assert link.to_socket == image_layer.inputs["Normal Scale"]
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain():
|
||||
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
|
||||
|
||||
|
||||
def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
def test_depth_symmetry_plane_calibration_matches_cutout_axes():
|
||||
vertices = []
|
||||
uv = []
|
||||
camera_depth = np.empty((3, 3), dtype=np.float32)
|
||||
@@ -40,7 +40,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
uv.append((column * 0.5, 1.0 - row * 0.5))
|
||||
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
|
||||
|
||||
reference, direction = depth_calibration.depth_balloon_projection_parameters(
|
||||
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
|
||||
vertices,
|
||||
uv,
|
||||
camera_depth,
|
||||
@@ -58,7 +58,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
assert np.allclose(direction, expected_direction, atol=1e-6)
|
||||
|
||||
|
||||
def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
|
||||
def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile():
|
||||
size = 10
|
||||
row, column = np.indices((size, size))
|
||||
camera_depth = np.full((size, size), 2.0, dtype=np.float32)
|
||||
@@ -70,7 +70,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
|
||||
vertices.append((float(x), float(size - 1 - y), 0.0))
|
||||
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
|
||||
|
||||
reference, direction = depth_calibration.depth_balloon_projection_parameters(
|
||||
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
|
||||
vertices,
|
||||
uv,
|
||||
camera_depth,
|
||||
|
||||
@@ -10,7 +10,7 @@ from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_depth_presets_always_generate_the_correct_normal_space(enabled):
|
||||
assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT"
|
||||
assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT"
|
||||
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "TANGENT"
|
||||
for shape in ("FLAT", "SOLID"):
|
||||
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
"FLAT",
|
||||
"SOLID",
|
||||
"DEPTH_BALLOON",
|
||||
"DEPTH_SYMMETRY",
|
||||
"DEPTH_SOLID",
|
||||
]
|
||||
assert all(call[2].generate_normal for call in window_manager.pie.calls)
|
||||
@@ -155,10 +155,10 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
|
||||
def test_cutout_normal_space_is_derived_from_shape():
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", False) == "OBJECT"
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", False) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("FLAT", True) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("SOLID", True) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "TANGENT"
|
||||
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"
|
||||
|
||||
|
||||
|
||||
@@ -175,22 +175,10 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
profile_attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
material_tree = result.data.materials[0].node_tree
|
||||
normal_scale_attribute = next(
|
||||
node
|
||||
for node in material_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeAttribute"
|
||||
)
|
||||
assert normal_scale_attribute.attribute_type == "GEOMETRY"
|
||||
assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes)
|
||||
image_layer = _group_node(material_data.material_node_group(), material_tree)
|
||||
normal_scale_link = next(
|
||||
link
|
||||
for link in material_tree.links
|
||||
if link.to_node == image_layer
|
||||
and link.to_socket == image_layer.inputs["Normal Scale"]
|
||||
)
|
||||
assert normal_scale_link.from_node == normal_scale_attribute
|
||||
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
|
||||
assert not image_layer.inputs["Normal Scale"].is_linked
|
||||
assert image_layer.inputs["Normal Scale"].default_value == 1.0
|
||||
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
|
||||
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
@@ -219,7 +207,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute():
|
||||
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
||||
attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
assert any(
|
||||
assert not any(
|
||||
node.bl_idname == "ShaderNodeAttribute"
|
||||
for node in result.data.materials[0].node_tree.nodes
|
||||
)
|
||||
@@ -585,10 +573,11 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
|
||||
assert len(_evaluated_positions(obj)) == 9
|
||||
|
||||
|
||||
def test_depth_balloon_uses_metric_plane_inputs():
|
||||
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
|
||||
def test_depth_symmetry_uses_metric_plane_inputs(gesture):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
|
||||
source = bpy.data.objects.new("Depth Balloon", None)
|
||||
image = bpy.data.images.new("Depth Symmetry", width=100, height=100, alpha=True)
|
||||
source = bpy.data.objects.new("Depth Symmetry", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
@@ -619,12 +608,13 @@ def test_depth_balloon_uses_metric_plane_inputs():
|
||||
selection_values[:, :2] = 0.0
|
||||
result = _create_cutout_shape(
|
||||
source,
|
||||
"DEPTH_BALLOON",
|
||||
"DEPTH_SYMMETRY",
|
||||
0.1,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
bounds=crop_bounds,
|
||||
selection_values=selection_values,
|
||||
gesture=gesture,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
@@ -634,6 +624,9 @@ def test_depth_balloon_uses_metric_plane_inputs():
|
||||
result.name,
|
||||
}
|
||||
modifier = result.modifiers[0]
|
||||
mode_id = object_data.modifier_input_identifier(modifier.node_group, "Mode")
|
||||
assert modifier_value(modifier, mode_id) == 1
|
||||
object_data.set_modifier_input(modifier, mode_id, 0)
|
||||
depth_image_id = object_data.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Depth Image",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""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
|
||||
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_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,
|
||||
"DEPTH_SYMMETRY": build_image_depth_symmetry_group,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Exercise automatic arrangement without changing geometry or publishing assets."""
|
||||
"""Exercise automatic arrangement while preserving node programs and geometry."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
@@ -6,12 +6,12 @@ import pytest
|
||||
|
||||
|
||||
from tools.nodes.build import build_node_groups
|
||||
from tools.nodes.check import GEOMETRY_NAMES
|
||||
from tools.nodes.check import ASSET_PATH, GEOMETRY_NAMES, SHADER_NAMES
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
|
||||
|
||||
|
||||
@pytest.fixture(params=sorted(GEOMETRY_NAMES))
|
||||
@pytest.fixture(params=sorted(GEOMETRY_NAMES | SHADER_NAMES))
|
||||
def node_group(request):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
return next(group for group in build_node_groups() if group.name == request.param)
|
||||
@@ -40,9 +40,18 @@ def _program(node_group):
|
||||
settings = {
|
||||
name: _value(getattr(node, name))
|
||||
for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp",
|
||||
"interpolation", "interpolation_type", "extension", "pattern_mode")
|
||||
"interpolation", "interpolation_type", "extension", "pattern_mode",
|
||||
"space", "uv_map", "attribute_type", "attribute_name", "rotation_type",
|
||||
"invert", "blend_type", "clamp_factor", "clamp_result", "distribution")
|
||||
if hasattr(node, name)
|
||||
}
|
||||
if hasattr(node, "mapping"):
|
||||
mapping = node.mapping
|
||||
settings["mapping"] = (
|
||||
mapping.extend, mapping.use_clip,
|
||||
tuple((tuple(point.location), point.handle_type)
|
||||
for curve in mapping.curves for point in curve.points),
|
||||
)
|
||||
inputs = [
|
||||
(socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None,
|
||||
[_source(link.from_socket) for link in sorted(
|
||||
@@ -88,7 +97,7 @@ def _surface(node_group):
|
||||
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,
|
||||
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
|
||||
"Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25}
|
||||
mode = None
|
||||
for item in node_group.interface.items_tree:
|
||||
@@ -119,9 +128,9 @@ def _surface(node_group):
|
||||
return evaluate
|
||||
|
||||
|
||||
def test_arrangement_preserves_program_and_geometry(node_group):
|
||||
def test_arrangement_preserves_program_and_evaluation(node_group):
|
||||
program = _program(node_group)
|
||||
evaluate = _surface(node_group)
|
||||
evaluate = _surface(node_group) if node_group.bl_idname == "GeometryNodeTree" else list
|
||||
geometry = evaluate()
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
@@ -142,3 +151,13 @@ def test_arrangement_preserves_program_and_geometry(node_group):
|
||||
arrange_nodes(node_group)
|
||||
assert _program(node_group) == program
|
||||
assert sum(node.bl_idname == "NodeGroupInput" for node in node_group.nodes) == input_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(SHADER_NAMES))
|
||||
def test_shader_asset_preserves_definition(name):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
definition = next(group for group in build_node_groups() if group.name == name)
|
||||
expected = _program(definition)
|
||||
with bpy.data.libraries.load(str(ASSET_PATH)) as (_, target):
|
||||
target.node_groups = [name]
|
||||
assert _program(target.node_groups[0]) == expected
|
||||
|
||||
@@ -8,6 +8,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
|
||||
from tools.nodes.common.boundary_smoothing import cut_boundary_influence
|
||||
|
||||
|
||||
def diagonal_surface(triangles=False):
|
||||
@@ -51,6 +52,66 @@ def edge_band(faces, seeds, rings=2):
|
||||
return selected
|
||||
|
||||
|
||||
def test_cutout_split_and_outline_use_distinct_influence():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
points, faces, split = [], [], []
|
||||
for side in range(2):
|
||||
offset = len(points)
|
||||
for y in range(9):
|
||||
for x in range(5):
|
||||
points.append((side * 4 + x, y, 0))
|
||||
split.append(x == (4 if side == 0 else 0))
|
||||
faces.extend(
|
||||
(
|
||||
offset + y * 5 + x,
|
||||
offset + y * 5 + x + 1,
|
||||
offset + (y + 1) * 5 + x + 1,
|
||||
offset + (y + 1) * 5 + x,
|
||||
)
|
||||
for y in range(8)
|
||||
for x in range(4)
|
||||
)
|
||||
mesh = bpy.data.meshes.new("Boundary influence")
|
||||
mesh.from_pydata(points, [], faces)
|
||||
mesh.attributes.new("test_split", "BOOLEAN", "POINT").data.foreach_set("value", split)
|
||||
obj = bpy.data.objects.new("Boundary influence", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Boundary influence", "GeometryNodeTree")
|
||||
for direction in ("INPUT", "OUTPUT"):
|
||||
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
split_attribute = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
split_attribute.data_type = "BOOLEAN"
|
||||
split_attribute.inputs["Name"].default_value = "test_split"
|
||||
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store.data_type, store.domain = "FLOAT", "POINT"
|
||||
store.inputs["Name"].default_value = "test_influence"
|
||||
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
|
||||
group.links.new(cut_boundary_influence(group, split_attribute.outputs["Attribute"]), store.inputs["Value"])
|
||||
output = group.nodes.new("NodeGroupOutput")
|
||||
group.links.new(store.outputs["Geometry"], output.inputs["Geometry"])
|
||||
obj.modifiers.new("Boundary influence", "NODES").node_group = group
|
||||
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated_obj.to_mesh()
|
||||
try:
|
||||
influence = np.array([value.value for value in result.attributes["test_influence"].data])
|
||||
finally:
|
||||
evaluated_obj.to_mesh_clear()
|
||||
np.testing.assert_allclose(influence[4 * 5], 0.1, atol=1e-6)
|
||||
np.testing.assert_allclose(influence[4 * 5 + 4], 1.0, atol=1e-6)
|
||||
np.testing.assert_allclose(influence[4], 1.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_cutout_boundary_smooth_defaults_to_four():
|
||||
obj, _ = surface()
|
||||
socket = next(
|
||||
item for item in obj.modifiers[0].node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Boundary Smooth"
|
||||
)
|
||||
assert socket.default_value == 4
|
||||
assert (socket.min_value, socket.max_value) == (0, 16)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("triangles", [False, True])
|
||||
def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
|
||||
obj, set_value = diagonal_surface(triangles)
|
||||
|
||||
@@ -13,7 +13,7 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ("definition", "asset"))
|
||||
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"))
|
||||
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_SYMMETRY"))
|
||||
def test_depth_axis_is_visible_in_options(shape, source):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)
|
||||
@@ -55,7 +55,7 @@ def test_cutout_thickness_modes():
|
||||
assert np.isfinite(z).all() and len(z)
|
||||
if thickness == 0:
|
||||
assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7
|
||||
assert all(abs(item.value - 1) < 1e-7 for item in result.attributes["o_normal_scale"].data)
|
||||
assert all(abs(item.value) < 1e-7 for item in result.attributes["o_normal_reduction"].data)
|
||||
else:
|
||||
back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0)
|
||||
assert np.isclose(z.min(), back) and np.isclose(z.max(), front)
|
||||
|
||||
@@ -162,9 +162,9 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = evaluated.to_mesh()
|
||||
try:
|
||||
attribute = mesh.attributes["o_normal_scale"]
|
||||
attribute = mesh.attributes["o_normal_reduction"]
|
||||
assert attribute.domain == "FACE"
|
||||
weights = [item.value for item in attribute.data]
|
||||
weights = [1.0 - item.value for item in attribute.data]
|
||||
assert weights.count(1) == len(obj.data.polygons)
|
||||
assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons)
|
||||
finally:
|
||||
|
||||
239
tests/tools/nodes/test_depth_symmetry.py
Normal file
239
tests/tools/nodes/test_depth_symmetry.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Check projected clipping, fixed-plane symmetry and straight closed walls."""
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import pytest
|
||||
from mathutils import Euler, Vector
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
|
||||
from tests.support.depth_surface import evaluated
|
||||
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group
|
||||
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
|
||||
|
||||
@pytest.fixture(params=['definition', 'asset'])
|
||||
def symmetry(request):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = (build_image_depth_symmetry_group() if request.param == 'definition'
|
||||
else load_cutout_node_group('DEPTH_SYMMETRY'))
|
||||
count = 17
|
||||
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
|
||||
points = np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size)))
|
||||
faces = []
|
||||
for row in range(count - 1):
|
||||
for col in range(count - 1):
|
||||
a = row * count + col
|
||||
faces.extend(((a, a + 1, a + count + 1), (a, a + count + 1, a + count)))
|
||||
mesh = bpy.data.meshes.new('Curved Depth')
|
||||
mesh.from_pydata(points, [], faces)
|
||||
uv = mesh.uv_layers.new(name='UVMap')
|
||||
for loop in mesh.loops:
|
||||
row, col = divmod(loop.vertex_index, count)
|
||||
uv.data[loop.index].uv = ((col + .5) / count, (row + .5) / count)
|
||||
profile = .3 * np.maximum(0, 1 - np.maximum(abs(x), abs(y)))
|
||||
mesh.attributes.new('o_balloon', 'FLOAT', 'POINT').data.foreach_set('value', profile.ravel())
|
||||
mesh.attributes.new('user_probe', 'FLOAT', 'POINT').data.foreach_set('value', np.ones(x.size))
|
||||
image = bpy.data.images.new('Depth', width=count, height=count, float_buffer=True)
|
||||
image.colorspace_settings.name = 'Non-Color'
|
||||
image.alpha_mode = 'CHANNEL_PACKED'
|
||||
depth = 1 + .6 * x ** 2 + .3 * y ** 2
|
||||
image.pixels.foreach_set(np.dstack((x, -y, depth, np.ones_like(x))).astype(np.float32).ravel())
|
||||
obj = bpy.data.objects.new('Symmetry', mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new('Symmetry', 'NODES')
|
||||
modifier.node_group = group
|
||||
|
||||
def set_value(name, value):
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
|
||||
obj.update_tag(refresh={'DATA'})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
for name, value in [('Mode', 1), ('Depth Image', image), ('Reference Depth', 1.45),
|
||||
('Depth Scale', 1.), ('Uniform Scale', 1.), ('Boundary Smooth', 0),
|
||||
('Smooth', 0), ('Double Sided', True)]:
|
||||
set_value(name, value)
|
||||
yield obj, set_value, depth.ravel(), faces
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('direction,smooth,scale', [((0, 0, 1), 0, .001), ((-.4, .2, 1), 0, 1),
|
||||
((.2, -.3, 1), 3, 100)])
|
||||
def test_projected_volume_is_closed_symmetric_and_has_straight_walls(symmetry, direction, smooth, scale):
|
||||
obj, set_value, _, _ = symmetry
|
||||
for name, value in [('Depth Direction', direction), ('Smooth', smooth),
|
||||
('Uniform Scale', scale), ('Reference Depth', 1.45 * scale)]:
|
||||
set_value(name, value)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
points = np.array([v.co[:] for v in mesh.vertices])
|
||||
assert len(points) and np.isfinite(points).all()
|
||||
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < scale * 1e-5
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
assert all(v.is_manifold for v in bm.verts)
|
||||
assert all(e.is_manifold for e in bm.edges)
|
||||
bm.free()
|
||||
orientation = Counter()
|
||||
wall_count = 0
|
||||
for polygon in mesh.polygons:
|
||||
face = tuple(polygon.vertices)
|
||||
for a, b in zip(face, (*face[1:], face[0])):
|
||||
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
|
||||
heights = points[list(face), 2]
|
||||
if heights.min() < 0 < heights.max():
|
||||
wall_count += 1
|
||||
assert abs(polygon.normal.z) < 1e-5
|
||||
assert not polygon.use_smooth
|
||||
assert wall_count and not any(orientation.values())
|
||||
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def test_projected_deletes_whole_crossing_faces_and_supports_empty_output(symmetry):
|
||||
obj, set_value, depth, source_faces = symmetry
|
||||
set_value('Double Sided', False)
|
||||
points, faces = evaluated(obj)
|
||||
assert (points[:, 2] > 0).all()
|
||||
expected = [face for face in source_faces if np.all(depth[list(face)] < 1.45 - 1e-7)]
|
||||
assert len(faces) == len(expected)
|
||||
assert set(np.concatenate(faces)) == set(range(len(points)))
|
||||
set_value('Reference Depth', .5)
|
||||
points, faces = evaluated(obj)
|
||||
assert not len(points) and not faces
|
||||
|
||||
|
||||
def test_mode_switch_and_output_axis_preserve_state(symmetry):
|
||||
obj, set_value, _, _ = symmetry
|
||||
projected, projected_faces = evaluated(obj)
|
||||
set_value('Mode', 0)
|
||||
balloon, balloon_faces = evaluated(obj)
|
||||
assert len(balloon_faces) != len(projected_faces)
|
||||
set_value('Mode', 1)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(restored, projected)
|
||||
assert restored_faces == projected_faces
|
||||
set_value('Depth Axis', 1)
|
||||
rotated, rotated_faces = evaluated(obj)
|
||||
np.testing.assert_allclose(rotated, projected @ np.array(CUTOUT_Z_TO_X)[:3, :3].T, atol=1e-6)
|
||||
assert rotated_faces == projected_faces
|
||||
set_value('Depth Axis', 0)
|
||||
set_value('Mode', 0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(restored, balloon)
|
||||
assert restored_faces == balloon_faces
|
||||
|
||||
|
||||
def test_symmetry_asset_has_direct_mode_branches(symmetry):
|
||||
obj, _, _, _ = symmetry
|
||||
group = obj.modifiers[0].node_group
|
||||
assert not any(node.bl_idname in {'GeometryNodeMeshBoolean', 'GeometryNodeGroup',
|
||||
'GeometryNodeCaptureAttribute'} for node in group.nodes)
|
||||
selector = next(node for node in group.nodes if node.bl_idname == 'GeometryNodeMenuSwitch'
|
||||
and node.inputs['Menu'].links[0].from_socket.name == 'Mode')
|
||||
assert [item.name for item in selector.enum_items] == ['Balloon', 'Projected']
|
||||
inputs = {item.name: item for item in group.interface.items_tree
|
||||
if item.item_type == 'SOCKET' and item.in_out == 'INPUT'}
|
||||
for name in ('Depth Direction', 'Depth Offset'):
|
||||
assert inputs[name].parent == inputs['Depth Scale'].parent
|
||||
for name in ('Double Sided', 'Reference Depth', 'Boundary Smooth'):
|
||||
assert inputs[name].parent.name == 'Options'
|
||||
assert inputs['Boundary Smooth'].default_value == 1
|
||||
assert inputs['Mode'].default_value == 'Projected'
|
||||
assert inputs['Fill Sides'].default_value is True
|
||||
assert inputs['Depth Offset'].default_value == 0
|
||||
assert inputs['Depth Offset'].min_value < 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [0, 1])
|
||||
def test_depth_offset_changes_depth_after_direction_without_moving_xy(symmetry, mode):
|
||||
obj, set_value, _, _ = symmetry
|
||||
for name, value in [('Mode', mode), ('Depth Direction', (.3, -.2, 1)),
|
||||
('Reference Depth', 4.), ('Depth Scale', .6)]:
|
||||
set_value(name, value)
|
||||
original, faces = evaluated(obj)
|
||||
for offset in (.2, -.1):
|
||||
set_value('Depth Offset', offset)
|
||||
points, changed_faces = evaluated(obj)
|
||||
assert changed_faces == faces
|
||||
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
|
||||
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
|
||||
delta = abs(points[:, 2]) - abs(original[:, 2])
|
||||
if mode == 1:
|
||||
np.testing.assert_allclose(delta, offset, atol=1e-6)
|
||||
else:
|
||||
if offset > 0:
|
||||
assert delta.max() > .1
|
||||
else:
|
||||
assert delta.min() < -.05
|
||||
boundary = abs(original[:, 2]) < 1e-8
|
||||
np.testing.assert_allclose(points[boundary, 2], 0, atol=1e-8)
|
||||
set_value('Depth Offset', 0.)
|
||||
restored, _ = evaluated(obj)
|
||||
np.testing.assert_allclose(restored, original, atol=1e-6)
|
||||
if mode == 1:
|
||||
set_value('Depth Offset', -10.)
|
||||
points, faces = evaluated(obj)
|
||||
assert not len(points) and not faces
|
||||
|
||||
|
||||
def test_material_attributes_match_evaluated_faces_and_direction(symmetry):
|
||||
obj, set_value, _, _ = symmetry
|
||||
direction = Vector((.3, -.2, 1)).normalized()
|
||||
set_value('Depth Direction', direction)
|
||||
for axis in (0, 1):
|
||||
set_value('Depth Axis', axis)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
points = np.array([v.co[:] for v in mesh.vertices])
|
||||
if axis:
|
||||
points = points @ np.array(CUTOUT_Z_TO_X)[:3, :3]
|
||||
for name in (ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE):
|
||||
assert mesh.attributes[name].domain == 'FACE'
|
||||
for polygon in mesh.polygons:
|
||||
index = polygon.index
|
||||
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[index].vector)
|
||||
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
|
||||
heights = points[list(polygon.vertices), 2]
|
||||
kind = 1 if heights.min() > 0 else 2 if heights.max() < 0 else 3
|
||||
assert mesh.attributes[FACE_ATTRIBUTE].data[index].value == kind
|
||||
assert mesh.attributes[AXIS_ATTRIBUTE].data[index].value == axis
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
set_value('Mode', 0)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
assert ROTATION_ATTRIBUTE not in result.data.attributes
|
||||
assert FACE_ATTRIBUTE not in result.data.attributes
|
||||
|
||||
|
||||
def test_fill_sides_only_controls_projected_walls(symmetry):
|
||||
obj, set_value, _, _ = symmetry
|
||||
closed, closed_faces = evaluated(obj)
|
||||
set_value('Fill Sides', False)
|
||||
points, faces = evaluated(obj)
|
||||
assert len(faces) < len(closed_faces)
|
||||
assert cKDTree(points).query(closed)[0].max() < 1e-6
|
||||
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
|
||||
assert all(not (points[list(face), 2].min() < 0 < points[list(face), 2].max()) for face in faces)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces
|
||||
for a, b in zip(face, (*face[1:], face[0])))
|
||||
assert any(count == 1 for count in edges.values())
|
||||
set_value('Fill Sides', True)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(restored, closed)
|
||||
assert restored_faces == closed_faces
|
||||
for name, value in [('Double Sided', False), ('Mode', 0)]:
|
||||
set_value(name, value)
|
||||
before, before_faces = evaluated(obj)
|
||||
set_value('Fill Sides', False)
|
||||
after, after_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(after, before)
|
||||
assert after_faces == before_faces
|
||||
set_value('Fill Sides', True)
|
||||
@@ -27,7 +27,7 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
"Thickness", "Depth Split", "Smooth", "Smooth Weight",
|
||||
"Boundary Smooth", "Normal Smooth", "Front Inflation",
|
||||
},
|
||||
"O Image Depth Balloon": {"Thickness", "Smooth", "Smooth Weight"},
|
||||
"O Image Depth Symmetry": {"Thickness", "Smooth", "Smooth Weight", "Boundary Smooth"},
|
||||
"O Image Depth Panorama": {
|
||||
"Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight",
|
||||
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
|
||||
@@ -50,7 +50,7 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
assert all(item.description for item in sockets), group.name
|
||||
if group.name in {"O Image Depth Plane", "O Image Depth Cutout"}:
|
||||
boundary = next(item for item in sockets if item.name == "Boundary Smooth")
|
||||
assert boundary.default_value == (4 if group.name == "O Image Depth Plane" else 0)
|
||||
assert boundary.default_value == 4
|
||||
assert boundary.min_value == 0
|
||||
assert boundary.max_value == 16
|
||||
assert boundary.parent.name == "Options"
|
||||
@@ -141,6 +141,9 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
|
||||
"UVMap": ("FLOAT2", "CORNER"),
|
||||
"o_balloon": ("FLOAT", "POINT"),
|
||||
".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
|
||||
"o_depth_face": ("FLOAT", "FACE"),
|
||||
"o_depth_axis": ("FLOAT", "FACE"),
|
||||
}
|
||||
while pending:
|
||||
group = pending.pop()
|
||||
@@ -154,7 +157,7 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
|
||||
if node.bl_idname != "GeometryNodeStoreNamedAttribute":
|
||||
continue
|
||||
name = node.inputs["Name"].default_value
|
||||
if name == "o_normal_scale":
|
||||
if name == "o_normal_reduction":
|
||||
assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"}
|
||||
else:
|
||||
assert name in allowed, (group.name, name)
|
||||
|
||||
79
tests/tools/nodes/test_normal_scale.py
Normal file
79
tests/tools/nodes/test_normal_scale.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Render optional geometry weights against ordinary normal map strength."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from tests.support.materials import image_layer
|
||||
|
||||
|
||||
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
|
||||
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
|
||||
@pytest.mark.parametrize('domain', ['POINT', 'FACE'])
|
||||
def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, domain, tmp_path):
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = engine
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.resolution_x, scene.render.resolution_y = 256, 64
|
||||
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 / 'normal_scale.exr')
|
||||
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||
scene.camera = bpy.context.object
|
||||
scene.camera.data.type = 'ORTHO'
|
||||
scene.camera.data.ortho_scale = 8
|
||||
for mode_index, space in enumerate(('TANGENT', 'OBJECT')):
|
||||
shared = None
|
||||
for weight_index, weight in enumerate((None, 0.0, 0.4, 1.0)):
|
||||
column = mode_index * 4 + weight_index
|
||||
for reference in (False, True):
|
||||
bpy.ops.mesh.primitive_plane_add(
|
||||
size=.8, location=(column - 3.5, .5 if reference else -.5, 0),
|
||||
)
|
||||
obj = bpy.context.object
|
||||
obj.data.uv_layers.active.name = 'UVMap'
|
||||
if weight is not None and not reference:
|
||||
attr = obj.data.attributes.new(NORMAL_REDUCTION_ATTRIBUTE_NAME, 'FLOAT', domain)
|
||||
for item in attr.data:
|
||||
item.value = 1.0 - weight
|
||||
if not reference and shared is not None:
|
||||
obj.data.materials.append(shared)
|
||||
continue
|
||||
material = bpy.data.materials.new(f'{space} {weight} {reference}')
|
||||
material.use_nodes = True
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
nodes.clear()
|
||||
normal = nodes.new('ShaderNodeNormalMap' if reference else 'ShaderNodeGroup')
|
||||
color = (.7, .65, .9, 1)
|
||||
if reference:
|
||||
normal.space = space
|
||||
normal.inputs['Color'].default_value = color
|
||||
normal.inputs['Strength'].default_value = .5 * (1 if weight is None else weight)
|
||||
else:
|
||||
normal.node_tree = image_layer
|
||||
normal.inputs['Object Space'].default_value = space == 'OBJECT'
|
||||
normal.inputs['Normal'].default_value = color
|
||||
normal.inputs['Normal Scale'].default_value = .5
|
||||
shared = material
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(normal.outputs['Normal'], encode.inputs[0])
|
||||
emission = nodes.new('ShaderNodeEmission')
|
||||
output = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(encode.outputs[0], emission.inputs['Color'])
|
||||
links.new(emission.outputs[0], output.inputs['Surface'])
|
||||
obj.data.materials.append(material)
|
||||
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(64, 256, 4)
|
||||
columns = np.arange(8) * 32 + 16
|
||||
np.testing.assert_allclose(pixels[16, columns, :3], pixels[48, columns, :3], atol=2e-4)
|
||||
assert np.linalg.norm(pixels[16, 16, :3] - pixels[16, 48, :3]) > .01
|
||||
finally:
|
||||
bpy.data.images.remove(rendered)
|
||||
86
tests/tools/nodes/test_object_normal_rotation.py
Normal file
86
tests/tools/nodes/test_object_normal_rotation.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Render the image material's object normals from persistent geometry attributes."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from mathutils import Euler, Vector, Matrix
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
|
||||
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
|
||||
|
||||
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
|
||||
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
|
||||
def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, engine, tmp_path):
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = engine
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.resolution_x, scene.render.resolution_y = 224, 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 / 'normals.exr')
|
||||
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||
camera = bpy.context.object
|
||||
camera.data.type = 'ORTHO'
|
||||
camera.data.ortho_scale = 7
|
||||
scene.camera = camera
|
||||
normal = Vector((.2, .3, .9)).normalized()
|
||||
rotation = Euler((.25, -.4, .15))
|
||||
expected = []
|
||||
# Missing attributes, front, back, wall, final axis, zero strength, tangent.
|
||||
cases = [(0, 0, 1, True), (1, 0, 1, True), (2, 0, 1, True),
|
||||
(3, 0, 1, True), (1, 1, 1, True), (1, 0, 0, True), (1, 0, 1, False)]
|
||||
for index, (face, axis, strength, object_space) in enumerate(cases):
|
||||
bpy.ops.mesh.primitive_plane_add(size=.9, location=(index - 3, 0, 0))
|
||||
obj = bpy.context.object
|
||||
obj.data.uv_layers.active.name = 'UVMap'
|
||||
if face:
|
||||
attr = obj.data.attributes.new(ROTATION_ATTRIBUTE, 'FLOAT_VECTOR', 'FACE')
|
||||
for item in attr.data:
|
||||
item.vector = rotation
|
||||
for name, value in ((FACE_ATTRIBUTE, face), (AXIS_ATTRIBUTE, axis)):
|
||||
attr = obj.data.attributes.new(name, 'FLOAT', 'FACE')
|
||||
for item in attr.data:
|
||||
item.value = value
|
||||
material = bpy.data.materials.new(f'Normal {index}')
|
||||
material.use_nodes = True
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
nodes.clear()
|
||||
layer = nodes.new('ShaderNodeGroup')
|
||||
layer.node_tree = image_layer
|
||||
layer.inputs['Object Space'].default_value = object_space
|
||||
layer.inputs['Normal Scale'].default_value = strength
|
||||
layer.inputs['Normal'].default_value = (*((np.array(normal) + 1) / 2), 1)
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(layer.outputs['Normal'], encode.inputs[0])
|
||||
emission = nodes.new('ShaderNodeEmission')
|
||||
output = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(encode.outputs[0], emission.inputs['Color'])
|
||||
links.new(emission.outputs[0], output.inputs['Surface'])
|
||||
obj.data.materials.append(material)
|
||||
value = normal.copy()
|
||||
if face and object_space:
|
||||
value = rotation.to_matrix().transposed() @ value
|
||||
if face == 2:
|
||||
value.z *= -1
|
||||
if axis:
|
||||
value = Matrix(CUTOUT_Z_TO_X).to_3x3() @ value
|
||||
if strength == 0 or face == 3:
|
||||
value = Vector((0, 0, 1))
|
||||
expected.append((np.array(value) + 1) / 2)
|
||||
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, 224, 4)
|
||||
np.testing.assert_allclose(
|
||||
pixels[16, np.arange(7) * 32 + 16, :3], expected,
|
||||
atol=1e-3 if engine == 'BLENDER_EEVEE_NEXT' else 2e-4,
|
||||
)
|
||||
finally:
|
||||
bpy.data.images.remove(rendered)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Materialize the joint calculation and connection plan in geometry node trees."""
|
||||
"""Materialize the joint calculation and connection plan in node trees."""
|
||||
|
||||
from .branches import calculation_blocks
|
||||
from .dimensions import estimate_node_dimensions, visible_socket
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Identify connected field calculations by their nearest geometry consumers."""
|
||||
"""Identify connected calculations by their nearest geometry or shader consumers."""
|
||||
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
@@ -17,7 +17,8 @@ def calculation_blocks(nodes, edges):
|
||||
i
|
||||
for i, n in enumerate(nodes)
|
||||
if n.bl_idname == "NodeGroupOutput"
|
||||
or any(s.type == "GEOMETRY" and s.is_linked for s in n.outputs)
|
||||
or n.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeBump", "ShaderNodeDisplacement"}
|
||||
or any(s.type in {"GEOMETRY", "SHADER"} and s.is_linked for s in n.outputs)
|
||||
or getattr(n, "paired_output", None) is not None
|
||||
}
|
||||
inputs = {i for i, n in enumerate(nodes) if n.bl_idname == "NodeGroupInput"}
|
||||
|
||||
@@ -74,6 +74,19 @@
|
||||
"ShaderNodeMix": 3,
|
||||
"ShaderNodeSeparateXYZ": 0,
|
||||
"ShaderNodeValue": 1,
|
||||
"ShaderNodeVectorMath": 1
|
||||
"ShaderNodeVectorMath": 1,
|
||||
"FunctionNodeAlignEulerToVector": 2,
|
||||
"ShaderNodeVectorRotate": 2,
|
||||
"ShaderNodeAttribute": 2,
|
||||
"ShaderNodeFloatCurve": 12,
|
||||
"ShaderNodeBump": 2,
|
||||
"ShaderNodeNormalMap": 2,
|
||||
"ShaderNodeDisplacement": 1,
|
||||
"ShaderNodeBsdfDiffuse": 1,
|
||||
"ShaderNodeBsdfTransparent": 0,
|
||||
"ShaderNodeEmission": 0,
|
||||
"ShaderNodeLightPath": 0,
|
||||
"ShaderNodeMixShader": 0,
|
||||
"ShaderNodeGroup": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ def estimate_node_dimensions(node):
|
||||
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"
|
||||
"Attribute" in node.bl_idname or node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup", "ShaderNodeFloatCurve"}
|
||||
) else RULES["width"]
|
||||
if node.hide:
|
||||
height = max(RULES["collapsed_height"], 16 + 12 * max(len(inputs), len(outputs)))
|
||||
@@ -44,7 +44,7 @@ def estimate_node_dimensions(node):
|
||||
controls = RULES["controls"][node.bl_idname]
|
||||
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "enum_items"):
|
||||
controls += len(getattr(node, collection, ()))
|
||||
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
|
||||
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"} 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 = {}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from .branches import calculation_blocks
|
||||
|
||||
|
||||
SHARED_GEOMETRY_USES = 3
|
||||
|
||||
@@ -84,5 +86,9 @@ def geometry_paths(node_group):
|
||||
|
||||
def node_roles(node_group):
|
||||
"""Classify by contribution to the result, not by available socket types."""
|
||||
if node_group.bl_idname == "ShaderNodeTree":
|
||||
nodes = list(node_group.nodes)
|
||||
_, core = calculation_blocks(nodes, list(node_group.links))
|
||||
return {node: "shader" if i in core else "field" for i, node in enumerate(nodes)}
|
||||
core, _ = geometry_paths(node_group)
|
||||
return {node: "geometry" if node in core else "field" for node in node_group.nodes}
|
||||
|
||||
@@ -90,7 +90,7 @@ def plan_layout(nodes, edges, size, blocks, core):
|
||||
continue
|
||||
sy = items[u]["outputs"][e][1] if a in members else 16.0
|
||||
ty = items[v]["inputs"][e][1] if b in members else 16.0
|
||||
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
|
||||
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
|
||||
links.append((e, u, v, sy, ty, weight))
|
||||
parents = {k: set() for k in items}
|
||||
children = {k: set() for k in items}
|
||||
@@ -360,7 +360,7 @@ def plan_layout(nodes, edges, size, blocks, core):
|
||||
u, v = node_atom[a], node_atom[b]
|
||||
sy = seed[a][1] + size[a].sockets[edges[e].from_socket] - atoms[u]["preferred"]
|
||||
ty = seed[b][1] + size[b].sockets[edges[e].to_socket] - atoms[v]["preferred"]
|
||||
weight = 3.0 if edge_types[e] == "GEOMETRY" else 1.0
|
||||
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
|
||||
if e in edge_bus:
|
||||
weight *= 0.15
|
||||
sequence = [(u, sy), (v, ty)] if e not in edge_bus else [(u, sy), (edge_bus[e], 12.0), (v, ty)]
|
||||
|
||||
@@ -9,7 +9,7 @@ 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_depth_symmetry import build_image_depth_symmetry_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
|
||||
@@ -29,7 +29,7 @@ def build_node_groups():
|
||||
build_image_relief_plane_group(image_plane),
|
||||
build_image_cutout_group(),
|
||||
build_image_depth_cutout_group(),
|
||||
build_image_depth_balloon_group(),
|
||||
build_image_depth_symmetry_group(),
|
||||
build_image_layer_group(),
|
||||
build_image_depth_layer_group(),
|
||||
build_shadeless_group(),
|
||||
@@ -40,8 +40,7 @@ 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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyIma
|
||||
GEOMETRY_NAMES = {
|
||||
"O Image Depth Panorama",
|
||||
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
|
||||
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Balloon",
|
||||
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Symmetry",
|
||||
}
|
||||
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
|
||||
|
||||
@@ -60,6 +60,8 @@ def check_asset_library(asset_path):
|
||||
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
|
||||
"Depth Split": 0.0}
|
||||
if node_group.name == "O Image Depth Symmetry":
|
||||
values["Reference Depth"] = 2.5
|
||||
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]
|
||||
|
||||
@@ -5,10 +5,12 @@ from .depth_surface import _math
|
||||
from .smoothing import edge_boundary_field, expand_smooth
|
||||
|
||||
|
||||
def boundary_influence(group, protected=False):
|
||||
def boundary_influence(group, protected=False, *, boundary=None):
|
||||
"""Fade a boundary field through two rings while excluding protected points."""
|
||||
nodes, links = group.nodes, group.links
|
||||
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
if boundary is None:
|
||||
boundary = edge_boundary_field(nodes, links)
|
||||
boundary = evaluate_field(group, boundary, "FLOAT", "POINT")
|
||||
unprotected = _math(group, "SUBTRACT", 1, protected)
|
||||
cut = _math(group, "MULTIPLY", boundary, unprotected)
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
@@ -75,9 +77,20 @@ def reconstruct_camera_depth(group, camera, depth, smoothed):
|
||||
return choose.outputs[0]
|
||||
|
||||
|
||||
def smooth_cut_boundary(group, geometry, original_boundary=False):
|
||||
"""Blend O Smooth's pinned-boundary result through a two-ring edge mask."""
|
||||
influence = boundary_influence(group, original_boundary)
|
||||
def cut_boundary_influence(group, split_boundary):
|
||||
"""Give split edges full influence and the original outline one tenth."""
|
||||
nodes, links = group.nodes, group.links
|
||||
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
split = _math(group, "MULTIPLY", boundary, split_boundary)
|
||||
outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0)
|
||||
split_influence = boundary_influence(group, boundary=split)
|
||||
outline_influence = _math(group, "MULTIPLY", boundary_influence(group, boundary=outline), 0.1)
|
||||
return _math(group, "MAXIMUM", split_influence, outline_influence)
|
||||
|
||||
|
||||
def smooth_cut_boundary(group, geometry, split_boundary=False):
|
||||
"""Blend O Smooth through split and outline masks with distinct weights."""
|
||||
influence = cut_boundary_influence(group, split_boundary)
|
||||
# The support band depends on topology, which stays unchanged in the loop.
|
||||
return expand_smooth(
|
||||
group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight",
|
||||
|
||||
@@ -327,7 +327,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
|
||||
links.new(rim, back_parts.inputs["Geometry"])
|
||||
rear = back_parts.outputs["Geometry"]
|
||||
if back_mask is not None:
|
||||
rear = store_float_attribute(group, rear, back_mask, 0.0, domain="FACE")
|
||||
rear = store_float_attribute(group, rear, back_mask, 1.0, domain="FACE")
|
||||
for value in (
|
||||
saved,
|
||||
rear,
|
||||
|
||||
78
tools/nodes/common/normal_map.py
Normal file
78
tools/nodes/common/normal_map.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Share persistent face attributes between depth symmetry and image materials.
|
||||
|
||||
Rotation stores pre-inversion Euler angles, face kind is 1/2/3 for front/back/wall,
|
||||
and axis is 1 for the final +X orientation. Missing attributes mean identity.
|
||||
These FACE attributes stay on the output mesh for the material consumer.
|
||||
"""
|
||||
from mathutils import Matrix
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
|
||||
|
||||
ROTATION_ATTRIBUTE = 'o_depth_rotation'
|
||||
FACE_ATTRIBUTE = 'o_depth_face'
|
||||
AXIS_ATTRIBUTE = 'o_depth_axis'
|
||||
|
||||
|
||||
def transform_object_normal_color(group, color, strength):
|
||||
"""Rotate encoded object normals, reflect backs and disable wall normal maps."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def attribute(name):
|
||||
node = nodes.new('ShaderNodeAttribute')
|
||||
node.attribute_type = 'GEOMETRY'
|
||||
node.attribute_name = name
|
||||
return node
|
||||
|
||||
def math(operation, value, constant):
|
||||
node = nodes.new('ShaderNodeMath')
|
||||
node.operation = operation
|
||||
links.new(value, node.inputs[0])
|
||||
node.inputs[1].default_value = constant
|
||||
return node.outputs[0]
|
||||
|
||||
rotation = attribute(ROTATION_ATTRIBUTE)
|
||||
face = attribute(FACE_ATTRIBUTE)
|
||||
axis = attribute(AXIS_ATTRIBUTE)
|
||||
decode = nodes.new('ShaderNodeVectorMath')
|
||||
decode.operation = 'MULTIPLY_ADD'
|
||||
decode.inputs[1].default_value = (2., 2., 2.)
|
||||
decode.inputs[2].default_value = (-1., -1., -1.)
|
||||
links.new(color, decode.inputs[0])
|
||||
rotate = nodes.new('ShaderNodeVectorRotate')
|
||||
rotate.rotation_type = 'EULER_XYZ'
|
||||
rotate.invert = True
|
||||
links.new(decode.outputs[0], rotate.inputs['Vector'])
|
||||
links.new(rotation.outputs['Vector'], rotate.inputs['Rotation'])
|
||||
separate = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(rotate.outputs[0], separate.inputs[0])
|
||||
back = math('COMPARE', face.outputs['Fac'], 2.)
|
||||
# Reflect the normal's Z component on the back.
|
||||
difference = math('MULTIPLY', separate.outputs['Z'], -2.)
|
||||
correction = nodes.new('ShaderNodeMath')
|
||||
correction.operation = 'MULTIPLY_ADD'
|
||||
links.new(back, correction.inputs[0])
|
||||
links.new(difference, correction.inputs[1])
|
||||
links.new(separate.outputs['Z'], correction.inputs[2])
|
||||
combine = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(separate.outputs['X'], combine.inputs['X'])
|
||||
links.new(separate.outputs['Y'], combine.inputs['Y'])
|
||||
links.new(correction.outputs[0], combine.inputs['Z'])
|
||||
axis_rotation = nodes.new('ShaderNodeVectorMath')
|
||||
axis_rotation.operation = 'SCALE'
|
||||
axis_rotation.inputs[0].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
|
||||
links.new(axis.outputs['Fac'], axis_rotation.inputs['Scale'])
|
||||
orient = nodes.new('ShaderNodeVectorRotate')
|
||||
orient.rotation_type = 'EULER_XYZ'
|
||||
links.new(combine.outputs[0], orient.inputs['Vector'])
|
||||
links.new(axis_rotation.outputs[0], orient.inputs['Rotation'])
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(orient.outputs[0], encode.inputs[0])
|
||||
mapped = math('LESS_THAN', face.outputs['Fac'], 2.5)
|
||||
weight = nodes.new('ShaderNodeMath')
|
||||
weight.operation = 'MULTIPLY'
|
||||
links.new(mapped, weight.inputs[0])
|
||||
links.new(strength, weight.inputs[1])
|
||||
return encode.outputs[0], weight.outputs[0]
|
||||
@@ -5,22 +5,18 @@ BUMP_BASE_DISTANCE = 0.001
|
||||
def build_material_nodes(group):
|
||||
nodes = group.nodes
|
||||
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)
|
||||
return group_input, group_output, roughness_curve, bump
|
||||
|
||||
|
||||
def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, bump_location):
|
||||
def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha):
|
||||
nodes, links = group.nodes, group.links
|
||||
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
|
||||
links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"])
|
||||
@@ -33,7 +29,6 @@ def connect_material_outputs(group, group_input, group_output, roughness_curve,
|
||||
bump_distance = nodes.new("ShaderNodeMath")
|
||||
bump_distance.operation = "MULTIPLY"
|
||||
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
|
||||
bump_distance.location = bump_location
|
||||
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"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Build O Image Cutout."""
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_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.smoothing import expand_smooth
|
||||
from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _remove_zero_thickness, _set_layer_z, _shade_output
|
||||
@@ -198,7 +198,10 @@ def build_image_cutout_group():
|
||||
mode.inputs['Shell'].default_value = 1
|
||||
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
|
||||
scale = mode.outputs[0]
|
||||
geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale)
|
||||
reduction = math_node(group.nodes, 'SUBTRACT')
|
||||
reduction.inputs[0].default_value = 1.0
|
||||
group.links.new(scale, reduction.inputs[1])
|
||||
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction.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'])
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Build O Image Depth Balloon."""
|
||||
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, bool_input, vector_input,
|
||||
menu_switch, read_float_attribute, store_float_attribute, prepare_node_group,
|
||||
)
|
||||
from ..common.smoothing import expand_smooth
|
||||
from ..common.reference_plane import reference_plane_depth
|
||||
from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z, _shade_output
|
||||
|
||||
|
||||
NODE_GROUP_NAME = "O Image Depth Balloon"
|
||||
|
||||
|
||||
def _set_geometry_z(group, geometry, z_value):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
separate = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], separate.inputs["Vector"])
|
||||
combine = nodes.new("ShaderNodeCombineXYZ")
|
||||
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")
|
||||
links.new(geometry, set_position.inputs["Geometry"])
|
||||
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
|
||||
return set_position.outputs["Geometry"]
|
||||
|
||||
|
||||
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)
|
||||
camera_position = _position_field(group, depth_image)
|
||||
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(camera_position, separate_camera.inputs["Vector"])
|
||||
metric_depth = math_node(nodes, "MULTIPLY")
|
||||
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
|
||||
links.new(uniform_scale, metric_depth.inputs[1])
|
||||
plane_depth = reference_plane_depth(
|
||||
group,
|
||||
depth_direction,
|
||||
depth_reference,
|
||||
)
|
||||
corrected_depth = math_node(nodes, "SUBTRACT")
|
||||
links.new(plane_depth, corrected_depth.inputs[0])
|
||||
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
|
||||
nonnegative_depth = math_node(nodes, "MAXIMUM")
|
||||
nonnegative_depth.inputs[1].default_value = 0.0
|
||||
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
|
||||
scaled_depth = math_node(nodes, "MULTIPLY")
|
||||
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_node(nodes, "MULTIPLY")
|
||||
links.new(profile, base_front.inputs[0])
|
||||
links.new(thickness, base_front.inputs[1])
|
||||
negate_back = math_node(nodes, "MULTIPLY")
|
||||
negate_back.inputs[1].default_value = -1.0
|
||||
links.new(base_front.outputs[0], negate_back.inputs[0])
|
||||
front_mix = nodes.new("ShaderNodeMix")
|
||||
front_mix.data_type = "FLOAT"
|
||||
front_mix.clamp_factor = True
|
||||
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_node(nodes, "MULTIPLY")
|
||||
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"
|
||||
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)
|
||||
shaped = _set_layer_z(
|
||||
group,
|
||||
geometry,
|
||||
extrude,
|
||||
front_mix.outputs["Result"],
|
||||
back_choice.outputs["Output"],
|
||||
)
|
||||
front_only = _set_geometry_z(
|
||||
group, geometry, front_mix.outputs["Result"]
|
||||
)
|
||||
return _remove_zero_thickness(group, front_only, shaped, thickness)
|
||||
|
||||
|
||||
def build_image_depth_balloon_group():
|
||||
name = NODE_GROUP_NAME
|
||||
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'
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges. Zero removes the bulge.'
|
||||
float_input(group, 'Depth Scale', 1.0)
|
||||
bool_input(group, 'Double Sided', True)
|
||||
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
|
||||
iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.'
|
||||
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)
|
||||
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
|
||||
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
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_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'])
|
||||
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])
|
||||
for target in planar_inputs:
|
||||
group.links.new(cleaned, target)
|
||||
geometry = expand_smooth(group, geometry, iterations_name='Smooth', weight_name='Smooth Weight')
|
||||
profile = read_float_attribute(group, 'o_balloon')
|
||||
scale = profile
|
||||
geometry = store_float_attribute(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale)
|
||||
orient = group.nodes.new('GeometryNodeTransform')
|
||||
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
|
||||
group.links.new(geometry, orient.inputs['Geometry'])
|
||||
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
|
||||
group.links.new(geometry, axis.inputs['-Z'])
|
||||
group.links.new(orient.outputs[0], axis.inputs['+X'])
|
||||
_shade_output(group, axis.outputs[0])
|
||||
prepare_node_group(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 == 'Depth Axis':
|
||||
item.default_value = '-Z'
|
||||
return group
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Build O Image Depth Cutout."""
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
|
||||
from ..common.smoothing import expand_smooth
|
||||
from ..common.boundary_smoothing import smooth_cut_boundary
|
||||
@@ -235,7 +235,7 @@ def build_shell_position(group, position, front, is_shell, fields):
|
||||
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
|
||||
t = clamp.outputs[0]
|
||||
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
|
||||
rear = _math(group, "SUBTRACT", 1, read_float_attribute(group, "o_normal_scale"))
|
||||
rear = read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME)
|
||||
rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
|
||||
direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
|
||||
rear_shift = vector("SCALE", direction, rear_radius)
|
||||
@@ -277,7 +277,7 @@ def _build_depth_surface(group, geometry, controls):
|
||||
projection = project_depth_surface(
|
||||
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split,
|
||||
)
|
||||
projection = smooth_cut_boundary(group, projection)
|
||||
projection = smooth_cut_boundary(group, projection, cut_vertex)
|
||||
# Clamped depth pixels can collapse adjacent outline samples after projection.
|
||||
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
|
||||
length = group.nodes.new("ShaderNodeVectorMath")
|
||||
@@ -309,7 +309,7 @@ def _build_depth_surface(group, geometry, controls):
|
||||
front = build_front_fields(group, volume, normal, normal_weight)
|
||||
shell_fields = build_shell_fields(group, projection, volume, is_shell, normal_weight)
|
||||
shell = thicken_depth_surface(
|
||||
group, projection, shell_fields["thickness"], back_mask=NORMAL_SCALE_ATTRIBUTE_NAME, weld_zero_thickness=True,
|
||||
group, projection, shell_fields["thickness"], back_mask=NORMAL_REDUCTION_ATTRIBUTE_NAME, weld_zero_thickness=True,
|
||||
)
|
||||
fields = {
|
||||
name: sample_field(group, projection, value, data_type, index=shell["source_index"])
|
||||
@@ -357,7 +357,7 @@ def build_image_depth_cutout_group():
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
|
||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 0, 0, 16
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
|
||||
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
|
||||
normal_smooth = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 64, 16, 512
|
||||
@@ -372,7 +372,7 @@ def build_image_depth_cutout_group():
|
||||
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')
|
||||
cleaned = store_float_attribute(group, cleaned, NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE')
|
||||
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')
|
||||
|
||||
@@ -49,39 +49,26 @@ def build_image_depth_layer_group():
|
||||
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])
|
||||
@@ -94,7 +81,7 @@ def build_image_depth_layer_group():
|
||||
links.new(strength_attribute.outputs['Fac'], scaled_depth.inputs[1])
|
||||
processed_alpha = foreground_mask.outputs[0]
|
||||
processed_depth = scaled_depth.outputs[0]
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, (500.0, 100.0))
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
|
||||
links.new(processed_depth, displacement.inputs['Height'])
|
||||
links.new(displacement.outputs['Displacement'], group_output.inputs['Displacement'])
|
||||
group.color_tag = 'SHADER'
|
||||
|
||||
289
tools/nodes/groups/image_depth_symmetry.py
Normal file
289
tools/nodes/groups/image_depth_symmetry.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Build O Image Depth Symmetry."""
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, math_node, float_input, bool_input, vector_input,
|
||||
menu_switch, evaluate_field, read_float_attribute, store_float_attribute, prepare_node_group,
|
||||
)
|
||||
from ..common.smoothing import expand_smooth
|
||||
from ..common.boundary_smoothing import smooth_cut_boundary
|
||||
from ..common.depth_surface import _math, project_depth_surface
|
||||
from ..common.reference_plane import reference_plane_depth
|
||||
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
from ..common.cutout import _extrude_layers, _island_cleanup, _position_field, _profile_field, _remove_zero_thickness, _set_layer_z
|
||||
|
||||
|
||||
NODE_GROUP_NAME = "O Image Depth Symmetry"
|
||||
|
||||
|
||||
def _set_geometry_z(group, geometry, z_value):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
separate = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(position.outputs["Position"], separate.inputs["Vector"])
|
||||
combine = nodes.new("ShaderNodeCombineXYZ")
|
||||
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")
|
||||
links.new(geometry, set_position.inputs["Geometry"])
|
||||
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
|
||||
return set_position.outputs["Geometry"]
|
||||
|
||||
|
||||
def _build_balloon(
|
||||
group,
|
||||
geometry,
|
||||
thickness,
|
||||
depth_scale,
|
||||
double_sided,
|
||||
uniform_scale,
|
||||
depth_direction,
|
||||
depth_image,
|
||||
depth_reference,
|
||||
depth_offset,
|
||||
):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
profile, profile_factor = _profile_field(group, geometry)
|
||||
camera_position = _position_field(group, depth_image)
|
||||
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
|
||||
links.new(camera_position, separate_camera.inputs["Vector"])
|
||||
metric_depth = math_node(nodes, "MULTIPLY")
|
||||
links.new(separate_camera.outputs["Z"], metric_depth.inputs[0])
|
||||
links.new(uniform_scale, metric_depth.inputs[1])
|
||||
plane_depth = reference_plane_depth(
|
||||
group,
|
||||
depth_direction,
|
||||
depth_reference,
|
||||
)
|
||||
corrected_depth = math_node(nodes, "SUBTRACT")
|
||||
links.new(plane_depth, corrected_depth.inputs[0])
|
||||
links.new(metric_depth.outputs[0], corrected_depth.inputs[1])
|
||||
nonnegative_depth = math_node(nodes, "MAXIMUM")
|
||||
nonnegative_depth.inputs[1].default_value = 0.0
|
||||
links.new(corrected_depth.outputs[0], nonnegative_depth.inputs[0])
|
||||
scaled_depth = math_node(nodes, "MULTIPLY")
|
||||
links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0])
|
||||
links.new(depth_scale, scaled_depth.inputs[1])
|
||||
front_depth = _math(group, 'MAXIMUM', _math(group, 'ADD', scaled_depth.outputs[0], depth_offset), 0.0)
|
||||
base_front = math_node(nodes, "MULTIPLY")
|
||||
links.new(profile, base_front.inputs[0])
|
||||
links.new(thickness, base_front.inputs[1])
|
||||
negate_back = math_node(nodes, "MULTIPLY")
|
||||
negate_back.inputs[1].default_value = -1.0
|
||||
links.new(base_front.outputs[0], negate_back.inputs[0])
|
||||
front_mix = nodes.new("ShaderNodeMix")
|
||||
front_mix.data_type = "FLOAT"
|
||||
front_mix.clamp_factor = True
|
||||
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_node(nodes, "MULTIPLY")
|
||||
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"
|
||||
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)
|
||||
shaped = _set_layer_z(
|
||||
group,
|
||||
geometry,
|
||||
extrude,
|
||||
front_mix.outputs["Result"],
|
||||
back_choice.outputs["Output"],
|
||||
)
|
||||
front_only = _set_geometry_z(
|
||||
group, geometry, front_mix.outputs["Result"]
|
||||
)
|
||||
return _remove_zero_thickness(group, front_only, shaped, thickness)
|
||||
|
||||
|
||||
def _build_projected_front(group, geometry, controls):
|
||||
"""Project camera depth and align its direction with the fixed XY plane."""
|
||||
nodes, links = group.nodes, group.links
|
||||
camera = _position_field(group, controls['Depth Image'])
|
||||
projected = project_depth_surface(
|
||||
group, geometry, camera, controls['Uniform Scale'],
|
||||
controls['Reference Depth'], controls['Depth Scale'], 0.0,
|
||||
)
|
||||
align = nodes.new('FunctionNodeAlignEulerToVector')
|
||||
align.axis = 'Z'
|
||||
links.new(controls['Depth Direction'], align.inputs['Vector'])
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
rotate = nodes.new('ShaderNodeVectorRotate')
|
||||
rotate.rotation_type = 'EULER_XYZ'
|
||||
rotate.invert = True
|
||||
links.new(position.outputs[0], rotate.inputs['Vector'])
|
||||
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(projected, place.inputs['Geometry'])
|
||||
links.new(rotate.outputs[0], place.inputs['Position'])
|
||||
offset = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(controls['Depth Offset'], offset.inputs['Z'])
|
||||
links.new(offset.outputs[0], place.inputs['Offset'])
|
||||
store = nodes.new('GeometryNodeStoreNamedAttribute')
|
||||
store.data_type, store.domain = 'FLOAT_VECTOR', 'FACE'
|
||||
store.inputs['Name'].default_value = ROTATION_ATTRIBUTE
|
||||
links.new(place.outputs[0], store.inputs['Geometry'])
|
||||
links.new(align.outputs['Rotation'], store.inputs['Value'])
|
||||
return store.outputs['Geometry']
|
||||
|
||||
|
||||
def _delete_negative_faces(group, front, tolerance):
|
||||
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
|
||||
position = group.nodes.new('GeometryNodeInputPosition')
|
||||
components = group.nodes.new('ShaderNodeSeparateXYZ')
|
||||
group.links.new(position.outputs[0], components.inputs[0])
|
||||
negative = _math(group, 'LESS_THAN', components.outputs['Z'], tolerance)
|
||||
point_negative = evaluate_field(group, negative, 'FLOAT', 'POINT')
|
||||
face_negative = evaluate_field(group, point_negative, 'FLOAT', 'FACE')
|
||||
delete = group.nodes.new('GeometryNodeDeleteGeometry')
|
||||
delete.domain = 'FACE'
|
||||
delete.mode = 'ALL'
|
||||
group.links.new(front, delete.inputs['Geometry'])
|
||||
group.links.new(_math(group, 'GREATER_THAN', face_negative, 0), delete.inputs['Selection'])
|
||||
return delete.outputs[0]
|
||||
|
||||
|
||||
def _build_straight_volume(group, front, double_sided, fill_sides, tolerance):
|
||||
"""Mirror the positive surface and join matching boundary vertices with straight walls."""
|
||||
nodes, links = group.nodes, group.links
|
||||
shade = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
links.new(front, shade.inputs['Geometry'])
|
||||
front = shade.outputs[0]
|
||||
front = store_float_attribute(group, front, FACE_ATTRIBUTE, 1., domain='FACE')
|
||||
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
|
||||
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
|
||||
extrude = nodes.new('GeometryNodeExtrudeMesh')
|
||||
extrude.mode = 'EDGES'
|
||||
extrude.inputs['Offset'].default_value = (0, 0, 0)
|
||||
extrude.inputs['Offset Scale'].default_value = 0
|
||||
links.new(front, extrude.inputs['Mesh'])
|
||||
links.new(boundary, extrude.inputs['Selection'])
|
||||
position = nodes.new('GeometryNodeInputPosition')
|
||||
components = nodes.new('ShaderNodeSeparateXYZ')
|
||||
links.new(position.outputs[0], components.inputs[0])
|
||||
mirror_point = nodes.new('ShaderNodeCombineXYZ')
|
||||
links.new(components.outputs['X'], mirror_point.inputs['X'])
|
||||
links.new(components.outputs['Y'], mirror_point.inputs['Y'])
|
||||
links.new(_math(group, 'MULTIPLY', components.outputs['Z'], -1), mirror_point.inputs['Z'])
|
||||
place = nodes.new('GeometryNodeSetPosition')
|
||||
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
|
||||
links.new(extrude.outputs['Top'], place.inputs['Selection'])
|
||||
links.new(mirror_point.outputs[0], place.inputs['Position'])
|
||||
sides = nodes.new('GeometryNodeSeparateGeometry')
|
||||
sides.domain = 'FACE'
|
||||
links.new(place.outputs[0], sides.inputs['Geometry'])
|
||||
links.new(extrude.outputs['Side'], sides.inputs['Selection'])
|
||||
flat = nodes.new('GeometryNodeSetShadeSmooth')
|
||||
flat.domain = 'FACE'
|
||||
flat.inputs['Shade Smooth'].default_value = False
|
||||
links.new(sides.outputs['Selection'], flat.inputs['Geometry'])
|
||||
mirror = nodes.new('GeometryNodeTransform')
|
||||
mirror.inputs['Scale'].default_value = (1, 1, -1)
|
||||
links.new(front, mirror.inputs['Geometry'])
|
||||
flip = nodes.new('GeometryNodeFlipFaces')
|
||||
links.new(mirror.outputs[0], flip.inputs['Mesh'])
|
||||
join = nodes.new('GeometryNodeJoinGeometry')
|
||||
back = store_float_attribute(group, flip.outputs[0], FACE_ATTRIBUTE, 2., domain='FACE')
|
||||
walls = store_float_attribute(group, flat.outputs[0], FACE_ATTRIBUTE, 3., domain='FACE')
|
||||
wall_switch = nodes.new('GeometryNodeSwitch')
|
||||
wall_switch.input_type = 'GEOMETRY'
|
||||
links.new(fill_sides, wall_switch.inputs['Switch'])
|
||||
links.new(walls, wall_switch.inputs['True'])
|
||||
for geometry in (front, back, wall_switch.outputs[0]):
|
||||
links.new(geometry, join.inputs[0])
|
||||
merge = nodes.new('GeometryNodeMergeByDistance')
|
||||
links.new(tolerance, merge.inputs['Distance'])
|
||||
links.new(join.outputs[0], merge.inputs[0])
|
||||
switch = nodes.new('GeometryNodeSwitch')
|
||||
switch.input_type = 'GEOMETRY'
|
||||
links.new(double_sided, switch.inputs['Switch'])
|
||||
links.new(front, switch.inputs['False'])
|
||||
links.new(merge.outputs[0], switch.inputs['True'])
|
||||
return switch.outputs[0]
|
||||
|
||||
|
||||
def build_image_depth_symmetry_group():
|
||||
name = NODE_GROUP_NAME
|
||||
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'
|
||||
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
|
||||
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
|
||||
float_input(group, 'Thickness', 1.0).description = 'Control the bulge in Balloon mode. Zero keeps only the front surface.'
|
||||
float_input(group, 'Depth Scale', 1.0)
|
||||
vector_input(group, 'Depth Direction', (0, 0, 1), subtype='DIRECTION')
|
||||
float_input(group, 'Depth Offset', 0.0, minimum=-1e10, subtype='DISTANCE').description = 'Offset the front depth along the symmetry axis after direction adjustment. Positive values move it away from the symmetry plane.'
|
||||
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
|
||||
iterations.description = 'Set the number of mesh smoothing passes. Higher values smooth the surface more but may soften details. Zero disables smoothing.'
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = (0, 20)
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
bool_input(group, 'Double Sided', True, parent=options)
|
||||
bool_input(group, 'Fill Sides', True, parent=options).description = 'Close the gap between the front and mirrored back with straight walls in Projected mode.'
|
||||
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
|
||||
float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of each mesh smoothing pass. Higher values move vertices more.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options).description = 'Calibrate the source depth before direction adjustment.'
|
||||
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
|
||||
boundary = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary.default_value, boundary.min_value, boundary.max_value = 1, 0, 16
|
||||
boundary.description = 'Smooth the projected outline before clipping and closing the surface.'
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
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())
|
||||
inputs = controls.outputs
|
||||
cleaned = _island_cleanup(
|
||||
group, inputs['Geometry'], _math(group, 'MULTIPLY', inputs['Cleanup Threshold'], 0.01),
|
||||
)
|
||||
balloon = _build_balloon(
|
||||
group, cleaned, inputs['Thickness'], inputs['Depth Scale'], inputs['Double Sided'],
|
||||
inputs['Uniform Scale'], inputs['Depth Direction'], inputs['Depth Image'], inputs['Reference Depth'],
|
||||
inputs['Depth Offset'],
|
||||
)
|
||||
balloon = expand_smooth(group, balloon, iterations_name='Smooth', weight_name='Smooth Weight')
|
||||
shade = group.nodes.new('GeometryNodeSetShadeSmooth')
|
||||
shade.domain = 'FACE'
|
||||
group.links.new(balloon, shade.inputs['Geometry'])
|
||||
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', inputs['Uniform Scale'], 1e-8), 1e-7)
|
||||
front = smooth_cut_boundary(group, _build_projected_front(group, cleaned, inputs))
|
||||
front = _delete_negative_faces(group, front, tolerance)
|
||||
front = expand_smooth(group, front, iterations_name='Smooth', weight_name='Smooth Weight')
|
||||
projected = _build_straight_volume(group, front, inputs['Double Sided'], inputs['Fill Sides'], tolerance)
|
||||
modes = menu_switch(group, 'Mode', ('Balloon', 'Projected'), 'GEOMETRY')
|
||||
group.links.new(shade.outputs[0], modes.inputs['Balloon'])
|
||||
group.links.new(projected, modes.inputs['Projected'])
|
||||
geometry = modes.outputs[0]
|
||||
profile = read_float_attribute(group, 'o_balloon')
|
||||
reduction = _math(group, 'SUBTRACT', 1.0, profile)
|
||||
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction)
|
||||
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 0., domain='FACE')
|
||||
orient = group.nodes.new('GeometryNodeTransform')
|
||||
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
|
||||
group.links.new(geometry, orient.inputs['Geometry'])
|
||||
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
|
||||
group.links.new(geometry, axis.inputs['-Z'])
|
||||
oriented = store_float_attribute(group, orient.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
|
||||
group.links.new(oriented, axis.inputs['+X'])
|
||||
output = group.nodes.new('NodeGroupOutput')
|
||||
group.links.new(axis.outputs[0], output.inputs['Geometry'])
|
||||
prepare_node_group(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 = 'Projected'
|
||||
elif item.name == 'Depth Axis':
|
||||
item.default_value = '-Z'
|
||||
return group
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Build O Image Layer."""
|
||||
import bpy
|
||||
from anyimage.common.material import IMAGE_MATERIAL_NODE_GROUP_NAME
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.shader import build_material_nodes, connect_material_outputs
|
||||
from ..common.normal_map import transform_object_normal_color
|
||||
|
||||
|
||||
def build_image_layer_group():
|
||||
@@ -47,18 +49,30 @@ def build_image_layer_group():
|
||||
interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type)
|
||||
nodes, links = group.nodes, group.links
|
||||
group_input, group_output, roughness_curve, bump = build_material_nodes(group)
|
||||
normal_attribute = nodes.new('ShaderNodeAttribute')
|
||||
normal_attribute.attribute_type = 'GEOMETRY'
|
||||
normal_attribute.attribute_name = NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
normal_weight = nodes.new('ShaderNodeMath')
|
||||
normal_weight.operation = 'SUBTRACT'
|
||||
normal_weight.inputs[0].default_value = 1.0
|
||||
links.new(normal_attribute.outputs['Fac'], normal_weight.inputs[1])
|
||||
weighted_strength = nodes.new('ShaderNodeMath')
|
||||
weighted_strength.operation = 'MULTIPLY'
|
||||
links.new(group_input.outputs['Normal Scale'], weighted_strength.inputs[0])
|
||||
links.new(normal_weight.outputs[0], weighted_strength.inputs[1])
|
||||
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['Normal'], tangent_normal.inputs['Color'])
|
||||
links.new(weighted_strength.outputs[0], tangent_normal.inputs['Strength'])
|
||||
normal_color, normal_strength = transform_object_normal_color(
|
||||
group, group_input.outputs['Normal'], weighted_strength.outputs[0],
|
||||
)
|
||||
links.new(normal_color, object_normal.inputs['Color'])
|
||||
links.new(normal_strength, object_normal.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])
|
||||
@@ -76,14 +90,10 @@ def build_image_layer_group():
|
||||
for point in alpha_points:
|
||||
point.handle_type = 'AUTO_CLAMPED'
|
||||
alpha_curve.mapping.update()
|
||||
group_input.location = (-750.0, 150.0)
|
||||
group_output.location = (350.0, 450.0)
|
||||
alpha_curve.location = (-350.0, 1100.0)
|
||||
roughness_curve.location = (-350.0, 650.0)
|
||||
links.new(group_input.outputs['Alpha'], alpha_curve.inputs['Value'])
|
||||
links.new(group_input.outputs['Alpha Fix'], alpha_curve.inputs['Factor'])
|
||||
processed_alpha = alpha_curve.outputs['Value']
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha, (-250.0, 150.0))
|
||||
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
|
||||
group.color_tag = 'SHADER'
|
||||
group.description = f'Prepare {group_name} material outputs.'
|
||||
group.use_fake_user = True
|
||||
|
||||
@@ -20,61 +20,46 @@ def build_shadeless_group():
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
group_input = nodes.new("NodeGroupInput")
|
||||
group_input.location = (-900.0, -350.0)
|
||||
group_output = nodes.new("NodeGroupOutput")
|
||||
group_output.location = (850.0, 100.0)
|
||||
|
||||
diffuse = nodes.new("ShaderNodeBsdfDiffuse")
|
||||
diffuse.location = (250.0, -150.0)
|
||||
emission = nodes.new("ShaderNodeEmission")
|
||||
emission.location = (250.0, -400.0)
|
||||
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
|
||||
links.new(group_input.outputs["Color"], emission.inputs["Color"])
|
||||
|
||||
light_path = nodes.new("ShaderNodeLightPath")
|
||||
light_path.location = (-900.0, 150.0)
|
||||
bounce_count = nodes.new("ShaderNodeMath")
|
||||
bounce_count.operation = "SUBTRACT"
|
||||
bounce_count.location = (-500.0, -50.0)
|
||||
links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0])
|
||||
links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1])
|
||||
refracted = nodes.new("ShaderNodeMath")
|
||||
refracted.operation = "SUBTRACT"
|
||||
refracted.inputs[0].default_value = 1.0
|
||||
refracted.location = (-250.0, -50.0)
|
||||
links.new(bounce_count.outputs[0], refracted.inputs[1])
|
||||
|
||||
reflection_limit = nodes.new("ShaderNodeMath")
|
||||
reflection_limit.operation = "SUBTRACT"
|
||||
reflection_limit.inputs[0].default_value = 2.0
|
||||
reflection_limit.location = (-650.0, 450.0)
|
||||
links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1])
|
||||
camera_reflected = nodes.new("ShaderNodeMath")
|
||||
camera_reflected.operation = "MULTIPLY"
|
||||
camera_reflected.location = (-400.0, 400.0)
|
||||
links.new(reflection_limit.outputs[0], camera_reflected.inputs[0])
|
||||
links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1])
|
||||
shadow_or_reflect = nodes.new("ShaderNodeMath")
|
||||
shadow_or_reflect.operation = "MAXIMUM"
|
||||
shadow_or_reflect.location = (-100.0, 350.0)
|
||||
links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0])
|
||||
links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1])
|
||||
shader_factor = nodes.new("ShaderNodeMath")
|
||||
shader_factor.operation = "MAXIMUM"
|
||||
shader_factor.location = (150.0, 300.0)
|
||||
links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0])
|
||||
links.new(refracted.outputs[0], shader_factor.inputs[1])
|
||||
|
||||
mix = nodes.new("ShaderNodeMixShader")
|
||||
mix.location = (550.0, 100.0)
|
||||
links.new(shader_factor.outputs[0], mix.inputs["Fac"])
|
||||
links.new(diffuse.outputs["BSDF"], mix.inputs[1])
|
||||
links.new(emission.outputs["Emission"], mix.inputs[2])
|
||||
transparent = nodes.new("ShaderNodeBsdfTransparent")
|
||||
transparent.location = (550.0, -200.0)
|
||||
alpha_mix = nodes.new("ShaderNodeMixShader")
|
||||
alpha_mix.location = (850.0, 100.0)
|
||||
group_output.location = (1100.0, 100.0)
|
||||
links.new(group_input.outputs["Alpha"], alpha_mix.inputs["Fac"])
|
||||
links.new(transparent.outputs["BSDF"], alpha_mix.inputs[1])
|
||||
links.new(mix.outputs["Shader"], alpha_mix.inputs[2])
|
||||
|
||||
@@ -46,7 +46,7 @@ def _node_group_data(node_group):
|
||||
label = prop.enum_items[value].name if prop.type == "ENUM" and value in prop.enum_items else str(value)
|
||||
settings.append(label)
|
||||
title = node.bl_label
|
||||
if node.bl_idname == "GeometryNodeGroup":
|
||||
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"}:
|
||||
title = node.node_tree.name
|
||||
elif hasattr(node, "operation") and settings:
|
||||
title = settings[0]
|
||||
@@ -120,7 +120,7 @@ button,select { padding: 8px; font: inherit; max-width: 100%; }
|
||||
|
||||
|
||||
def main():
|
||||
"""Build and arrange geometry assets in memory for preview."""
|
||||
"""Build and arrange node assets in memory for preview."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True, help="Output HTML path")
|
||||
parser.add_argument("--fragment", action="store_true", help="Write a fragment for an inline preview")
|
||||
@@ -130,7 +130,7 @@ def main():
|
||||
from ..arrangement.arrange import arrange_nodes
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_groups = [node_group for node_group in build_node_groups() if node_group.bl_idname == "GeometryNodeTree"]
|
||||
node_groups = build_node_groups()
|
||||
for node_group in node_groups:
|
||||
arrange_nodes(node_group)
|
||||
print(write_node_preview(node_groups, output_path, fragment=args.fragment))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<canvas id="layout-canvas" role="img" aria-label="按实际排列坐标绘制的节点组,尺寸和曲线使用估算模型"></canvas>
|
||||
<div class="viz-row text-small" id="layout-legend">
|
||||
<span><i class="geometry-key"></i>几何连接</span>
|
||||
<span><i class="geometry-key"></i>几何与着色连接</span>
|
||||
<span><i class="field-key"></i>字段与参数连接</span>
|
||||
<span id="layout-count"></span>
|
||||
</div>
|
||||
@@ -113,15 +113,15 @@
|
||||
return;
|
||||
}
|
||||
if (view.value === 'middle') {
|
||||
const geometryNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role === 'geometry');
|
||||
const midX = (Math.min(...geometryNodes.map(n => n.x)) + Math.max(...geometryNodes.map(n => n.x))) / 2;
|
||||
const target = geometryNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
|
||||
const mainNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role !== 'field');
|
||||
const midX = (Math.min(...mainNodes.map(n => n.x)) + Math.max(...mainNodes.map(n => n.x))) / 2;
|
||||
const target = mainNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
|
||||
fit([target.x - 850, target.y - 560, target.x + 670, target.y + 600]);
|
||||
detail.textContent = `${current.name} · 中段计算局部`;
|
||||
return;
|
||||
}
|
||||
if (view.value === 'input') {
|
||||
const target = current.nodes.filter(n => n.role === 'geometry' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
|
||||
const target = current.nodes.filter(n => n.role !== 'field' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
|
||||
fit([target.x - 850, target.y - 650, target.x + 750, target.y + 650]);
|
||||
detail.textContent = `${current.name} · 起点输入附近`; return;
|
||||
}
|
||||
@@ -192,9 +192,9 @@
|
||||
const x2 = wx2 * scale + dx, y2 = wy2 * scale + dy;
|
||||
const bend = Math.max(40, Math.abs(wx2 - wx1) * 0.42) * scale;
|
||||
const active = selected === source || selected === target;
|
||||
context.globalAlpha = selected !== null && !active ? 0.18 : type === 'GEOMETRY' ? 0.85 : 0.46;
|
||||
context.strokeStyle = type === 'GEOMETRY' ? geometry : fields;
|
||||
context.lineWidth = active ? 2.5 : type === 'GEOMETRY' ? 1.35 : 0.8;
|
||||
context.globalAlpha = selected !== null && !active ? 0.18 : ['GEOMETRY', 'SHADER'].includes(type) ? 0.85 : 0.46;
|
||||
context.strokeStyle = ['GEOMETRY', 'SHADER'].includes(type) ? geometry : fields;
|
||||
context.lineWidth = active ? 2.5 : ['GEOMETRY', 'SHADER'].includes(type) ? 1.35 : 0.8;
|
||||
context.beginPath(); context.moveTo(x1, y1);
|
||||
context.bezierCurveTo(x1 + bend, y1, x2 - bend, y2, x2, y2); context.stroke();
|
||||
}
|
||||
@@ -202,9 +202,9 @@
|
||||
for (const node of current.nodes) {
|
||||
const x = node.x * scale + dx, y = node.y * scale + dy, w = node.w * scale, h = node.h * scale;
|
||||
if (x + w < 0 || x > width || y + h < 0 || y > height) continue;
|
||||
const geometryNode = node.type === 'NodeReroute'
|
||||
? node.sockets.some(socket => socket[3] === 'GEOMETRY') : node.role === 'geometry';
|
||||
const hue = geometryNode ? geometry : other;
|
||||
const mainNode = node.type === 'NodeReroute'
|
||||
? node.sockets.some(socket => ['GEOMETRY', 'SHADER'].includes(socket[3])) : node.role !== 'field';
|
||||
const hue = mainNode ? geometry : other;
|
||||
if (node.type === 'NodeReroute') {
|
||||
context.fillStyle = hue; context.beginPath();
|
||||
context.arc(x + w / 2, y + h / 2, Math.max(1.5, w / 3), 0, Math.PI * 2); context.fill(); continue;
|
||||
@@ -241,7 +241,7 @@
|
||||
}
|
||||
if (scale >= 0.22) {
|
||||
for (const socket of node.sockets) {
|
||||
context.fillStyle = socket[3] === 'GEOMETRY' ? geometry : fields;
|
||||
context.fillStyle = ['GEOMETRY', 'SHADER'].includes(socket[3]) ? geometry : fields;
|
||||
context.beginPath(); context.arc(socket[2] ? x + w : x, y + socket[1] * scale,
|
||||
Math.max(1.4, 3.6 * scale), 0, Math.PI * 2); context.fill();
|
||||
}
|
||||
@@ -285,7 +285,7 @@
|
||||
const x = (event.clientX - rect.left - dx) / scale, y = (event.clientY - rect.top - dy) / scale;
|
||||
const node = current.nodes.findLast(n => x >= n.x && x <= n.x + n.w && y >= n.y && y <= n.y + n.h);
|
||||
selected = node ? node.id : null;
|
||||
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role === 'geometry' ? '几何主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
|
||||
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role !== 'field' ? '结果主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
|
||||
draw();
|
||||
}
|
||||
dragging = null;
|
||||
|
||||
Reference in New Issue
Block a user