diff --git a/openspec/changes/add-projected-depth-symmetry/.openspec.yaml b/openspec/changes/add-projected-depth-symmetry/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/add-projected-depth-symmetry/design.md b/openspec/changes/add-projected-depth-symmetry/design.md new file mode 100644 index 0000000..ff55d8b --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/design.md @@ -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`,检查新组存在、旧组不再由构建产出。已保存在用户工程中的旧节点组不执行自动扫描或重写。 diff --git a/openspec/changes/add-projected-depth-symmetry/proposal.md b/openspec/changes/add-projected-depth-symmetry/proposal.md new file mode 100644 index 0000000..031564d --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/proposal.md @@ -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`,更新并验证源码及资产;不新增依赖。 diff --git a/openspec/changes/add-projected-depth-symmetry/specs/image-depth-symmetry/spec.md b/openspec/changes/add-projected-depth-symmetry/specs/image-depth-symmetry/spec.md new file mode 100644 index 0000000..0510d53 --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/specs/image-depth-symmetry/spec.md @@ -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 分支的求值收益,而非以固定毫秒值作为跨机器测试门槛 diff --git a/openspec/changes/add-projected-depth-symmetry/tasks.md b/openspec/changes/add-projected-depth-symmetry/tasks.md new file mode 100644 index 0000000..619de2d --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/tasks.md @@ -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 运行相关对象创建与节点测试,检查源码、资产中的旧名称及最终差异,确认工作正常退出;不构建文档或打包扩展。 diff --git a/openspec/changes/add-projected-depth-symmetry/validation.md b/openspec/changes/add-projected-depth-symmetry/validation.md new file mode 100644 index 0000000..bf50812 --- /dev/null +++ b/openspec/changes/add-projected-depth-symmetry/validation.md @@ -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` 通过。 diff --git a/openspec/changes/weight-cutout-boundary-smoothing/.openspec.yaml b/openspec/changes/weight-cutout-boundary-smoothing/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/weight-cutout-boundary-smoothing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/weight-cutout-boundary-smoothing/design.md b/openspec/changes/weight-cutout-boundary-smoothing/design.md new file mode 100644 index 0000000..907abc6 --- /dev/null +++ b/openspec/changes/weight-cutout-boundary-smoothing/design.md @@ -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 优先规则的预期结果,测试交汇区域输出有限且拓扑不变。 diff --git a/openspec/changes/weight-cutout-boundary-smoothing/proposal.md b/openspec/changes/weight-cutout-boundary-smoothing/proposal.md new file mode 100644 index 0000000..d254447 --- /dev/null +++ b/openspec/changes/weight-cutout-boundary-smoothing/proposal.md @@ -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`;不增加或移除公开输入。 diff --git a/openspec/changes/weight-cutout-boundary-smoothing/specs/cutout-boundary-smoothing/spec.md b/openspec/changes/weight-cutout-boundary-smoothing/specs/cutout-boundary-smoothing/spec.md new file mode 100644 index 0000000..4a5c2d8 --- /dev/null +++ b/openspec/changes/weight-cutout-boundary-smoothing/specs/cutout-boundary-smoothing/spec.md @@ -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 均不发生边界平滑位移 diff --git a/openspec/changes/weight-cutout-boundary-smoothing/tasks.md b/openspec/changes/weight-cutout-boundary-smoothing/tasks.md new file mode 100644 index 0000000..3d4b312 --- /dev/null +++ b/openspec/changes/weight-cutout-boundary-smoothing/tasks.md @@ -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` 并确认保存资产验证通过。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 3ca9bd3..509e43a 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d06282be4f11b6b3b31b61120dabf23202281eb936d9807fde515ff7fab29f8d -size 8030938 +oid sha256:2155b18564e8f22620538cad3551f5fd4af0a8704c496b0a8b0eb57495c78e8f +size 9340892 diff --git a/src/anyimage/operators/cutout_tool/depth_calibration.py b/src/anyimage/operators/cutout_tool/depth_calibration.py index e7f83ff..d78a992 100644 --- a/src/anyimage/operators/cutout_tool/depth_calibration.py +++ b/src/anyimage/operators/cutout_tool/depth_calibration.py @@ -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) diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index b5f334c..c1c114c 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -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"), ) diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index a6f8487..edec212 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -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"), diff --git a/src/anyimage/operators/cutout_tool/operators.py b/src/anyimage/operators/cutout_tool/operators.py index 7a63651..74c41b1 100644 --- a/src/anyimage/operators/cutout_tool/operators.py +++ b/src/anyimage/operators/cutout_tool/operators.py @@ -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( diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 77c8543..1dd2f42 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -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"}) diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 7ec6d87..4159eff 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -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, ) diff --git a/tests/anyimage/common/test_image_materials.py b/tests/anyimage/common/test_image_materials.py index fbb38bb..002ac63 100644 --- a/tests/anyimage/common/test_image_materials.py +++ b/tests/anyimage/common/test_image_materials.py @@ -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"] diff --git a/tests/anyimage/operators/test_cutout_calibration.py b/tests/anyimage/operators/test_cutout_calibration.py index 11f72c5..160d4d9 100644 --- a/tests/anyimage/operators/test_cutout_calibration.py +++ b/tests/anyimage/operators/test_cutout_calibration.py @@ -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, diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py index 11b733a..9bc72a7 100644 --- a/tests/anyimage/operators/test_cutout_depth.py +++ b/tests/anyimage/operators/test_cutout_depth.py @@ -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") diff --git a/tests/anyimage/operators/test_cutout_interaction.py b/tests/anyimage/operators/test_cutout_interaction.py index 5fbd21d..920c9c5 100644 --- a/tests/anyimage/operators/test_cutout_interaction.py +++ b/tests/anyimage/operators/test_cutout_interaction.py @@ -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" diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index 006cb57..526fd7f 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -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", diff --git a/tests/support/nodes.py b/tests/support/nodes.py index 07802aa..249094c 100644 --- a/tests/support/nodes.py +++ b/tests/support/nodes.py @@ -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, } diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py index 00d5064..6bdc5d1 100644 --- a/tests/tools/nodes/test_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -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 diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index 618054b..0f1bdac 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -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) diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index 2a8bdce..3f9affe 100644 --- a/tests/tools/nodes/test_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -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) diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index d568011..b61f4fc 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -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: diff --git a/tests/tools/nodes/test_depth_symmetry.py b/tests/tools/nodes/test_depth_symmetry.py new file mode 100644 index 0000000..e4b4f8b --- /dev/null +++ b/tests/tools/nodes/test_depth_symmetry.py @@ -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) diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index fe8538b..d4a798b 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -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) diff --git a/tests/tools/nodes/test_normal_scale.py b/tests/tools/nodes/test_normal_scale.py new file mode 100644 index 0000000..83facb9 --- /dev/null +++ b/tests/tools/nodes/test_normal_scale.py @@ -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) diff --git a/tests/tools/nodes/test_object_normal_rotation.py b/tests/tools/nodes/test_object_normal_rotation.py new file mode 100644 index 0000000..5f32029 --- /dev/null +++ b/tests/tools/nodes/test_object_normal_rotation.py @@ -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) diff --git a/tools/nodes/arrangement/arrange.py b/tools/nodes/arrangement/arrange.py index e6f75da..6cd3915 100644 --- a/tools/nodes/arrangement/arrange.py +++ b/tools/nodes/arrangement/arrange.py @@ -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 diff --git a/tools/nodes/arrangement/branches.py b/tools/nodes/arrangement/branches.py index 3fd1686..9aa5b31 100644 --- a/tools/nodes/arrangement/branches.py +++ b/tools/nodes/arrangement/branches.py @@ -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"} diff --git a/tools/nodes/arrangement/dimensions.json b/tools/nodes/arrangement/dimensions.json index 87eb184..ddc5941 100644 --- a/tools/nodes/arrangement/dimensions.json +++ b/tools/nodes/arrangement/dimensions.json @@ -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 } } diff --git a/tools/nodes/arrangement/dimensions.py b/tools/nodes/arrangement/dimensions.py index 2e14d07..1e221ae 100644 --- a/tools/nodes/arrangement/dimensions.py +++ b/tools/nodes/arrangement/dimensions.py @@ -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 = {} diff --git a/tools/nodes/arrangement/flow.py b/tools/nodes/arrangement/flow.py index c11acbf..7753146 100644 --- a/tools/nodes/arrangement/flow.py +++ b/tools/nodes/arrangement/flow.py @@ -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} diff --git a/tools/nodes/arrangement/layout.py b/tools/nodes/arrangement/layout.py index bc8f048..cec3af7 100644 --- a/tools/nodes/arrangement/layout.py +++ b/tools/nodes/arrangement/layout.py @@ -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)] diff --git a/tools/nodes/build.py b/tools/nodes/build.py index 6d85ec9..2aa32f8 100644 --- a/tools/nodes/build.py +++ b/tools/nodes/build.py @@ -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) diff --git a/tools/nodes/check.py b/tools/nodes/check.py index b49d3e7..56be395 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -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] diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py index 536e2a5..ba555d6 100644 --- a/tools/nodes/common/boundary_smoothing.py +++ b/tools/nodes/common/boundary_smoothing.py @@ -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", diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 05791f2..f22b1b8 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -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, diff --git a/tools/nodes/common/normal_map.py b/tools/nodes/common/normal_map.py new file mode 100644 index 0000000..59b27db --- /dev/null +++ b/tools/nodes/common/normal_map.py @@ -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] diff --git a/tools/nodes/common/shader.py b/tools/nodes/common/shader.py index 729ad5e..c3476fb 100644 --- a/tools/nodes/common/shader.py +++ b/tools/nodes/common/shader.py @@ -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"]) diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index f626ea0..5731a8e 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -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']) diff --git a/tools/nodes/groups/image_depth_balloon.py b/tools/nodes/groups/image_depth_balloon.py deleted file mode 100644 index 45df602..0000000 --- a/tools/nodes/groups/image_depth_balloon.py +++ /dev/null @@ -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 diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index f475e77..ceaae2c 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -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') diff --git a/tools/nodes/groups/image_depth_layer.py b/tools/nodes/groups/image_depth_layer.py index 85e71d6..a02a2f3 100644 --- a/tools/nodes/groups/image_depth_layer.py +++ b/tools/nodes/groups/image_depth_layer.py @@ -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' diff --git a/tools/nodes/groups/image_depth_symmetry.py b/tools/nodes/groups/image_depth_symmetry.py new file mode 100644 index 0000000..ac42255 --- /dev/null +++ b/tools/nodes/groups/image_depth_symmetry.py @@ -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 diff --git a/tools/nodes/groups/image_layer.py b/tools/nodes/groups/image_layer.py index 54779cf..aec8a2a 100644 --- a/tools/nodes/groups/image_layer.py +++ b/tools/nodes/groups/image_layer.py @@ -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 diff --git a/tools/nodes/groups/shadeless.py b/tools/nodes/groups/shadeless.py index 3f4b84a..4023540 100644 --- a/tools/nodes/groups/shadeless.py +++ b/tools/nodes/groups/shadeless.py @@ -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]) diff --git a/tools/nodes/preview/export.py b/tools/nodes/preview/export.py index 8fb195a..07c4b53 100644 --- a/tools/nodes/preview/export.py +++ b/tools/nodes/preview/export.py @@ -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)) diff --git a/tools/nodes/preview/template.html b/tools/nodes/preview/template.html index 06d4dbe..4e862c2 100644 --- a/tools/nodes/preview/template.html +++ b/tools/nodes/preview/template.html @@ -27,7 +27,7 @@