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:
2026-09-14 12:44:20 +08:00
parent 09778c4b48
commit dc000e50b1
53 changed files with 1350 additions and 364 deletions

View File

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

View 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`,检查新组存在、旧组不再由构建产出。已保存在用户工程中的旧节点组不执行自动扫描或重写。

View 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`,更新并验证源码及资产;不新增依赖。

View File

@@ -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 分支的求值收益,而非以固定毫秒值作为跨机器测试门槛

View 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 运行相关对象创建与节点测试,检查源码、资产中的旧名称及最终差异,确认工作正常退出;不构建文档或打包扩展。

View 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` 通过。

View File

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

View 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 优先规则的预期结果,测试交汇区域输出有限且拓扑不变。

View File

@@ -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`;不增加或移除公开输入。

View File

@@ -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 均不发生边界平滑位移

View File

@@ -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.

View File

@@ -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)

View File

@@ -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"),
)

View File

@@ -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"),

View File

@@ -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(

View File

@@ -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"})

View File

@@ -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,
)

View File

@@ -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"]

View File

@@ -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,

View File

@@ -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")

View File

@@ -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"

View File

@@ -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",

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View 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)

View File

@@ -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)

View 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)

View 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)

View File

@@ -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

View File

@@ -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"}

View File

@@ -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
}
}

View File

@@ -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 = {}

View File

@@ -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}

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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",

View File

@@ -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,

View 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]

View File

@@ -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"])

View File

@@ -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'])

View File

@@ -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

View File

@@ -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')

View File

@@ -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'

View 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

View File

@@ -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

View File

@@ -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])

View File

@@ -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))

View File

@@ -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;