feat: Implement depth cutout symmetry and associated functionalities

- Introduced a new proposal for depth cutout symmetry, allowing for the reuse of depth replacement and geometry operations.
- Added requirements for maintaining default thickness values for cutout nodes and ensuring proper initialization during object creation.
- Enhanced symmetry node to handle existing geometry, including alignment, mirroring, and boundary filling.
- Implemented fill smoothing to control the geometry of generated walls while preserving original surfaces.
- Created tests to validate the functionality of the new symmetry features against baseline geometry.
- Updated existing assets and node groups to support the new symmetry capabilities without introducing additional dependencies.
This commit is contained in:
2026-09-14 14:58:48 +08:00
parent dc000e50b1
commit ab282e4fdf
28 changed files with 716 additions and 689 deletions

View File

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

View File

@@ -0,0 +1,77 @@
## Context
现有 Cutout 负责图像深度投影与 Balloon / Shell 成形;现有 Depth Symmetry 同时承担投影及对称。桌面独立实验已将后者的 Projected 后半段取出,接在原 Cutout 后面。
`C:/Users/icrdr/Desktop/anyimage/post-cutout-symmetry-20260914/comparison.json` 记录了两张图片、两种模式、补面开关的零厚度对比:八组顶点位置误差均为 0,面数量及带方向拓扑一致。非零厚度对比也验证了镜像和封闭直壁。该证据对应实验参数;本次精简平滑接口后还需验证匹配预处理条件下的一致性。
Shell 的 `build_normal_weight` 使用 `smoothstep(Shell Thickness / Reference Depth)` 混合法线。实验恐龙的 0.075 厚度与约 5.505 参考深度只留下约 0.055% 的平滑权重,解释了薄壳调节不明显。
## Goals / Non-Goals
目标是两个入口共用 Cutout 成形,独立对称节点负责后续镜像与补面,并让薄壳法线平滑有效。本轮实现范围为节点资产、创建流程及对应验证;Cutout 继续使用 Balloon / Shell,深度推理流程保持现有实现。
## Decisions
### 1. 对称作为成形后的节点
使用资产名称 `O Image Cutout Symmetry`,将其改为几何处理节点。Depth Solid 使用 Cutout;Depth Symmetry 使用以下顺序:
```mermaid
flowchart TD
A[原 Depth Symmetry 入口] --> B[O Image Depth Cutout:Thickness = 0]
B --> C[O Image Cutout Symmetry]
```
先完成 Balloon 或 Shell,再将结果整体交给对称节点。这与已验证实验一致,避免在 Cutout 内组合成形和镜像的模式分支。旧 Symmetry 的 Balloon 分支由上游 Cutout 的 Balloon 承担。
对称沿用原 Projected 的方向对齐、Z 向偏移、负侧面移除、镜像翻面及对应边界连接顺序。Shell 的内外表面都属于输入结果,分别保留其边界连接关系。负侧面移除沿用原面选择语义。
### 2. 简化接口与坐标约定
| 输入 | 行为 |
| --- | --- |
| Geometry | 接收 Cutout 已成形的几何 |
| Symmetry Direction | 复用原 Depth Direction 的方向对齐语义,默认 (0, 0, 1) |
| Depth Offset | 方向调整后沿对称轴偏移,默认 0 |
| Fill Sides | 连接前后对应边界,默认开启 |
| Fill Smooth | 仅控制新增侧壁的几何平滑次数,默认 2 |
始终保留镜像后的两侧。移除 Mode、图像及深度标定输入、Double Sided、Depth Axis、Smooth、Boundary Smooth、Smooth Weight、Uniform Scale。对称坐标系沿用原 Projected 的局部 XY 平面和 Z 轴;创建流程将上游 Cutout 初始化为与之匹配的 -Z 输出。接缝容差由输入几何尺度获得。
### 3. 补面平滑约束在新增侧壁内部
Fill Smooth 为 0 时直接使用原直壁补面。开启时,在对应前后边界间生成含中间环的侧壁条带,对中间顶点进行受限平滑,固定与原几何相接的两端;保持成对顶点关于对称平面对称。条带采样采用内部固定策略,不新增采样参数。
这使侧壁平滑具备可移动的内部顶点,并保持输入保留下来的面形状。只有改变着色法线不足以满足几何平滑要求。Fill Sides 关闭时跳过条带及平滑。复用 common 中的字段与平滑函数;不用 Capture Attribute,临时属性仅在确有跨几何保存需求时使用并清理。
### 4. 入口分配参数与零厚度基线
Cutout 节点资产的 Balloon 与 Shell 两种 Thickness 默认值均为 0,手动添加节点时也使用零厚度初态。Depth Solid 创建时按 Thickness 的 subtype 分别设置 Balloon 为 1、Shell 为 0.2;两个输入均初始化,切换模式时使用各自的创建值。手势对应模式沿用 Cutout 的 Balloon / Shell 选择约定。
Depth Symmetry 入口创建两个依次求值的 Geometry Nodes 修改器。深度图、参考深度和尺度交给 Cutout,原标定得到的方向交给 Symmetry。Cutout 使用两种 Thickness 的零默认值,切换模式也保持零厚度初态。
Depth Solid 与 Depth Symmetry 均请求并读取 `object_normal` 结果;对象创建时为深度 Cutout 材质指定 OBJECT,开启 `O Image Layer` 的 Object Space。
Depth Symmetry 创建时保留 Cutout 的参数默认值,包括 Depth Split、Boundary Smooth、Smooth 与 Depth Axis;仅沿用通用的手势模式选择、清理阈值和图像标定输入。下述一致性测试所需的关闭分割/平滑条件只在测试中显式设置。
一致性验收使用同一输入网格、深度、参考值、尺度、方向和偏移;新链路关闭额外 Cutout 分割/平滑,旧 Projected 的 Smooth 与 Boundary Smooth 也设为 0,新 Fill Smooth 为 0。分别验证 Balloon/Shell 零厚度与 Fill Sides 开关。额外开启上游平滑后的结果由该上游处理决定。
### 5. Shell 使用完整的平滑厚度方向
Shell 使用在原正面点域求值、按 Normal Smooth 模糊并归一化的法线作为厚度偏移方向,不再以厚度与参考深度之比衰减。厚度仅控制偏移距离;零厚度输出保持原正面。Balloon 保留现有厚度相关权重。
Normal Smooth 平滑的是壳体挤出方向,原始深度正面的几何平滑仍由 Cutout 的 Smooth 控制。测试覆盖实际较薄厚度及较大参考深度,避免只测试厚壳而遗漏衰减问题。
## Risks / Trade-offs
- 侧壁平滑可能收缩狭窄凹口或在复杂轮廓附近自交 → 固定接缝、保持镜像配对,使用现有两张样例检查小幅平滑效果;不将全局无自交作为任意参数的保证。
- 精简接口与旧图文件中的内嵌组不同 → 创建流程统一使用新资产接口,不自动重写用户已有对象;构建前保存基线测试资料,按新的参数分配更新节点测试。
- 同名资产及待归档旧提案可能描述不同职责 → 本变更作为后续修订;实现与归档时协调 `add-projected-depth-symmetry` 的最终规格,防止旧的单节点要求覆盖此设计。
## Migration Plan
先保留旧 Projected 的小型基线数据,再替换构建逻辑、接入双修改器入口并修正 Shell 法线。运行相关测试和 `uv run node-group build`,同步源码与资产。出现回归时成套恢复此次构建逻辑、入口参数和资产。
## Open Questions
无阻塞产品决策。侧壁条带的内部采样数量在实现时以现有样例验证,不增加公开控制项。

View File

@@ -0,0 +1,28 @@
## Why
Depth Cutout 已负责深度置换及 Balloon / Shell 成形,对称应当作为后续几何操作复用这些结果。独立实验已验证零厚度 Cutout 接对称节点可以复现原 Depth Symmetry 的 Projected 构成方式,同时发现薄 Shell 的 Normal Smooth 被厚度权重压低,需要修正。
## What Changes
- 保留 Depth Solid、Depth Symmetry 两个入口;Depth Symmetry 创建串联的 `O Image Depth Cutout`(两个模式的 Thickness 均为 0)和 `O Image Cutout Symmetry`。
- Cutout 节点资产中 Balloon / Shell 两种 Thickness 的默认值均为 0;Depth Solid 创建时分别设置为 1 和 0.2,Depth Symmetry 使用零厚度默认值。
- **BREAKING**:`O Image Cutout Symmetry` 改为接收已成形几何的对称节点,提取原 Projected 的方向对齐、偏移、负侧面移除、镜像及边界直壁补面逻辑;移除图像置换和 Mode。
- 对称节点提供 Geometry、Symmetry Direction、Depth Offset、Fill Sides、Fill Smooth;始终生成镜像,移除 Double Sided、Depth Axis、全局 Smooth、Boundary Smooth 及其配套输入。
- Fill Smooth 只平滑生成的侧壁,默认 2;Cutout 保持 Balloon / Shell 两种模式及其自身平滑控制。
- 两种深度 Cutout 入口均使用 Object Space 法线贴图,并开启材质 `O Image Layer` 的 Object Space。
- Shell 的 Normal Smooth 独立于厚度与参考深度的比例,实际控制壳体厚度偏移方向的平滑。
## Capabilities
### New Capabilities
- `post-cutout-symmetry`: 对已成形几何执行方向对齐、镜像、补面和补面平滑,定义原入口的节点串联及零厚度一致性。
- `shell-normal-smoothing`: 定义薄 Shell 的法线平滑作用及零厚度行为。
### Modified Capabilities
无。当前主规格目录为空;本变更接续 `add-projected-depth-symmetry` 中的节点实现。
## Impact
影响 `tools/nodes/groups/image_cutout_symmetry.py`、`image_depth_cutout.py`、相关共用节点函数、Cutout 对象创建与参数初始化、相关测试及 `src/anyimage/assets/O_AnyImage.blend`。不增加依赖。既有 `.blend` 内嵌节点不会自动迁移,后续从资产加载使用新接口。

View File

@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: 原入口串联 Cutout 与对称节点
系统 SHALL 保留 Depth Solid 与 Depth Symmetry 入口。Depth Symmetry SHALL 按顺序创建 `O Image Depth Cutout` 和 `O Image Cutout Symmetry` 修改器,将 Cutout 两种 Thickness 输入均初始化为 0,并分别将深度标定参数与对称方向交给对应节点。
#### Scenario: 创建默认对称对象
- **WHEN** 用户通过原 Depth Symmetry 入口完成轮廓创建
- **THEN** 对象先执行零厚度 Cutout,再执行对称;Cutout 保留节点默认参数,包括 Depth Split、Boundary Smooth、Smooth 与 Depth Axis,仅沿用通用的手势模式选择、清理阈值和图像标定输入
- **AND** 切换 Balloon / Shell 后厚度仍为 0
### Requirement: 节点厚度默认值与入口初始化
`O Image Depth Cutout` 节点资产的 Balloon / Shell 两种 Thickness 默认值 SHALL 均为 0。Depth Solid 创建时 SHALL 按输入 subtype 分别设置 Balloon Thickness 为 1、Shell Thickness 为 0.2,其他创建默认设置保持不变;Depth Symmetry SHALL 使用两种厚度的零默认值。
#### Scenario: 手动添加 Cutout 节点
- **WHEN** 用户从资产添加未经入口覆写的 Cutout 节点
- **THEN** Balloon / Shell 两种 Thickness 均为 0
#### Scenario: 创建 Depth Solid
- **WHEN** 用户通过 Depth Solid 入口创建对象
- **THEN** Balloon Thickness 为 1,Shell Thickness 为 0.2,两个输入均完成初始化
- **AND** 切换模式时使用对应厚度值,边界平滑等其他默认设置保持不变
### Requirement: 成形后镜像与补面
对称节点 SHALL 接收上游完整几何,沿用原 Projected 的方向对齐、偏移、负侧面移除、镜像翻面与对应边界直壁连接语义。Fill Sides SHALL 控制是否添加连接侧壁。
#### Scenario: 非零厚度的两种成形结果
- **WHEN** Balloon 或 Shell 的非零厚度输出连接至对称节点
- **THEN** 节点对已成形结果执行对称;Shell 的壳体内外表面一同参与
- **AND** Fill Sides 关闭时不添加连接壁,开启时连接对应边界且保持面方向一致
### Requirement: 精简对称控制
对称节点 SHALL 仅公开 Geometry、Symmetry Direction、Depth Offset、Fill Sides、Fill Smooth 输入及 Geometry 输出,始终生成镜像两侧。默认方向 SHALL 为 (0, 0, 1),偏移 SHALL 为 0,Fill Smooth SHALL 为 2,Fill Sides SHALL 开启。
#### Scenario: 默认补面平滑
- **WHEN** 从资产加载新的对称节点
- **THEN** Fill Smooth 默认为 2
#### Scenario: 调整对称方向
- **WHEN** 用户调整 Symmetry Direction 和 Depth Offset
- **THEN** 几何使用原方向对齐及轴向偏移语义更新,对称平面位于调整后的局部 XY 平面
- **AND** 界面不提供 Double Sided、Depth Axis、全局 Smooth 或 Boundary Smooth
### Requirement: 深度 Cutout 使用 Object Space 法线
Depth Solid 与 Depth Symmetry SHALL 使用 Object Space 法线贴图,并在创建材质时开启 `O Image Layer` 的 Object Space。
#### Scenario: 创建深度 Cutout 材质
- **WHEN** 用户通过任一深度 Cutout 入口生成对象
- **THEN** 生成流程请求并读取 object_normal,材质的 Object Space 为开启状态
### Requirement: 仅平滑新增侧壁
Fill Smooth SHALL 控制新增侧壁的几何平滑,固定与保留输入几何相接的端点并保持镜像对称;数值为 0 SHALL 使用原直壁构成。
#### Scenario: 平滑连接壁
- **WHEN** Fill Sides 开启且 Fill Smooth 大于 0
- **THEN** 有曲率的轮廓所生成侧壁内部顶点可被平滑移动,原保留表面及接缝端点不受该平滑移动
- **AND** 对应连接保持封闭,镜像对称关系保持成立
#### Scenario: 禁用补面
- **WHEN** Fill Sides 关闭而 Fill Smooth 改变
- **THEN** 输出几何保持一致
### Requirement: 零厚度匹配原 Projected
在相同输入、标定与方向偏移、相同预处理条件下,Cutout 零厚度接对称节点 SHALL 与原 Depth Symmetry 的 Projected 在顶点位置、面连接及朝向上等价。
#### Scenario: 两种模式与补面开关的一致性
- **WHEN** 分别选择 Balloon / Shell 且两个厚度均为 0,Cutout 分割与平滑关闭、Depth Axis 为 -Z,旧 Projected 的 Smooth 与 Boundary Smooth 为 0,新 Fill Smooth 为 0
- **THEN** 对 Fill Sides 开关分别比较,新旧结果的顶点位置在浮点容差内一致,面数、连接及朝向一致

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 薄壳法线平滑不受相对厚度衰减
Shell 的 Normal Smooth SHALL 控制原正面点域法线的模糊次数,归一化后的结果用于壳体厚度偏移方向。系统 SHALL 不使用 Shell Thickness / Reference Depth 衰减该平滑;厚度控制偏移距离,Balloon 保留现有权重行为。
#### Scenario: 小厚度下调节平滑
- **WHEN** 非平面且法线有起伏的正面以小厚度生成 Shell,用户增大 Normal Smooth
- **THEN** 壳体的归一化偏移方向发生相应平滑变化,且不会因厚度与参考深度的比值接近零而退回原始法线
- **AND** 保持前表面相同而改变厚度时,平滑方向保持一致、偏移距离随厚度变化
### Requirement: 法线平滑保留正面与零厚度行为
Shell 的 Normal Smooth SHALL 仅通过厚度偏移作用于壳体,保留原深度正面;零厚度 SHALL 不因该参数改变几何。
#### Scenario: 零厚度与平面
- **WHEN** Shell 厚度为 0 时改变 Normal Smooth
- **THEN** 顶点位置与面连接保持一致
- **AND** 对常法线平面使用非零厚度时,改变 Normal Smooth 也不改变壳体形状

View File

@@ -0,0 +1,27 @@
## 1. 对称节点
- [x] 1.1 保存原 Projected 的小型确定性基线数据,覆盖关闭额外平滑后的顶点位置、面连接和朝向。
- [x] 1.2 将 `O Image Cutout Symmetry` 改为几何处理节点,保留方向对齐、偏移、负侧面移除、镜像和直壁补面,按设计精简接口并处理几何尺度容差。
- [x] 1.3 实现 Fill Smooth:生成侧壁内部采样,固定接缝并保持镜像配对;关闭补面时跳过,平滑为 0 时使用原直壁。
## 2. 入口与 Shell
- [x] 2.1 将 Cutout 资产中两种 Thickness 默认值设为 0;Depth Solid 创建时按 subtype 分别设置 Balloon 为 1、Shell 为 0.2。更新 Depth Symmetry 创建为 Cutout → Symmetry 两个修改器,保留 Cutout 节点默认参数,沿用通用的手势模式选择、清理阈值与图像标定,为对称节点初始化方向。
- [x] 2.2 修正 Shell 厚度方向使用完整平滑法线,解除相对厚度衰减,保留原正面、零厚度行为和 Balloon 权重逻辑。
## 3. 验证与资产
- [x] 3.1 更新相关入口及接口测试,验证资产两种厚度默认值为 0、Depth Solid 创建值为 1 / 0.2、Depth Symmetry 使用零厚度且切换模式正确;验证两种模式零厚度、Fill Sides 开关与原 Projected 基线一致。
- [x] 3.2 验证 Balloon/Shell 非零厚度的镜像与补面,以及 Fill Smooth 的侧壁位移、接缝固定、封闭性和镜像关系;使用已有两张去背景图片复核外观。
- [x] 3.3 增加薄 Shell 法线方向平滑的有效覆盖,同时验证零厚度、常法线平面及 Balloon 行为。
- [x] 3.4 运行相关测试和 `uv run node-group build`,验证源码与保存资产无 Capture Attribute、临时属性正确清理,检查最终差异;不打包扩展。
## 验证记录
- 后续调整:Fill Smooth 默认值改为 2;两个深度入口均请求 Object 法线并开启材质 Object Space。资产重建及独立校验通过,66 项对应测试通过。
- 81 项相关测试通过,覆盖入口、节点定义与资产、零厚度基线、补面平滑、薄 Shell、材质属性与布局。
- `uv run node-group build` 完成资产生成与独立进程校验;随后的全量测试发现 5 个需要同步的新接口/行为断言及 1 个既有 Depth Plane 边界平滑失败。前 5 个已修正并通过相关复测。
- 既有失败为 `test_validity_cut_smoothing_preserves_original_outline_and_interior`;在内存中恢复 HEAD 版本的两份节点构建模块后仍能复现,本变更未修改该用例或 Depth Plane 平滑逻辑。
- 最终 `uv run node-group build --skip-tests` 正常退出,10 个保存节点组均通过校验;`git diff --check` 通过。
- 两张已去背景的原样例分别检查 Balloon=1、Shell=0.2 和 Fill Smooth=0/3:八组均无边界边或非流形边,镜像位置误差为 0;已检查渲染。结果位于 `C:/Users/icrdr/Desktop/anyimage/post-cutout-symmetry-20260914/applied/`。

Binary file not shown.

View File

@@ -90,7 +90,7 @@ def create_shape_object(
): ):
import numpy as np import numpy as np
normal_mode = NormalMode(normal_mode) normal_mode = NormalMode.OBJECT if shape in DEPTH_CUTOUT_SHAPES else NormalMode(normal_mode)
bounds = crop_plane_bounds( bounds = crop_plane_bounds(
source_object, source_object,
content_bounds, content_bounds,
@@ -180,7 +180,7 @@ def create_shape_object(
crop_center, crop_center,
) )
mesh_object["anyimage_mesh_shape"] = shape mesh_object["anyimage_mesh_shape"] = shape
node_group = load_cutout_node_group(shape) node_group = load_cutout_node_group("DEPTH_SOLID" if shape == "DEPTH_SYMMETRY" else shape)
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group modifier.node_group = node_group
modifier.show_group_selector = False modifier.show_group_selector = False
@@ -188,18 +188,26 @@ def create_shape_object(
("Cleanup Threshold", cleanup_value), ("Cleanup Threshold", cleanup_value),
( (
"Thickness", "Thickness",
0.0 if shape == "FLAT" else 1.0, 0.0 if shape in {"FLAT", "DEPTH_SYMMETRY"} else 1.0,
), ),
): ):
if name == "Thickness" and shape == "DEPTH_SYMMETRY":
continue
set_modifier_input( set_modifier_input(
modifier, modifier,
modifier_input_identifier( modifier_input_identifier(
node_group, name, node_group, name,
subtype="NONE" if name == "Thickness" and shape == "DEPTH_SOLID" else None, subtype="NONE" if name == "Thickness" and shape in DEPTH_CUTOUT_SHAPES else None,
), ),
value, value,
) )
if gesture == "POLYLINE" and shape != "DEPTH_SYMMETRY": if shape == "DEPTH_SOLID":
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Thickness", subtype="DISTANCE"),
0.2,
)
if gesture == "POLYLINE":
set_modifier_input( set_modifier_input(
modifier, modifier,
modifier_input_identifier(node_group, "Mode"), modifier_input_identifier(node_group, "Mode"),
@@ -224,9 +232,13 @@ def create_shape_object(
value, value,
) )
if shape == "DEPTH_SYMMETRY": if shape == "DEPTH_SYMMETRY":
symmetry_group = load_cutout_node_group("DEPTH_SYMMETRY")
symmetry = mesh_object.modifiers.new(name=symmetry_group.name, type="NODES")
symmetry.node_group = symmetry_group
symmetry.show_group_selector = False
set_modifier_input( set_modifier_input(
modifier, symmetry,
modifier_input_identifier(node_group, "Depth Direction"), modifier_input_identifier(symmetry_group, "Symmetry Direction"),
depth_direction, depth_direction,
) )
return finalize_object_result( return finalize_object_result(

View File

@@ -72,10 +72,8 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"}
def normal_mode_for_shape(shape, generate_normal): def normal_mode_for_shape(shape, generate_normal):
if shape == "DEPTH_SOLID": if shape in DEPTH_CUTOUT_SHAPES:
return NormalMode.OBJECT return NormalMode.OBJECT
if shape == "DEPTH_SYMMETRY":
return NormalMode.TANGENT
if not generate_normal: if not generate_normal:
return NormalMode.NONE return NormalMode.NONE
return NormalMode.TANGENT return NormalMode.TANGENT

View File

@@ -11,7 +11,7 @@ CUTOUT_Z_TO_X = (
CUTOUT_NODE_GROUP_NAMES = { CUTOUT_NODE_GROUP_NAMES = {
"FLAT": "O Image Cutout", "FLAT": "O Image Cutout",
"SOLID": "O Image Cutout", "SOLID": "O Image Cutout",
"DEPTH_SYMMETRY": "O Image Depth Symmetry", "DEPTH_SYMMETRY": "O Image Cutout Symmetry",
"DEPTH_SOLID": "O Image Depth Cutout", "DEPTH_SOLID": "O Image Depth Cutout",
} }
CUTOUT_SHAPE_LABELS = { CUTOUT_SHAPE_LABELS = {

View File

@@ -10,7 +10,7 @@ from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
@pytest.mark.parametrize("enabled", [False, True]) @pytest.mark.parametrize("enabled", [False, True])
def test_depth_presets_always_generate_the_correct_normal_space(enabled): 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_SOLID", enabled) == "OBJECT"
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "TANGENT" assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "OBJECT"
for shape in ("FLAT", "SOLID"): for shape in ("FLAT", "SOLID"):
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")

View File

@@ -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(): 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_SOLID", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "TANGENT" assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("FLAT", True) == "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("SOLID", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "TANGENT" assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT" assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"

View File

@@ -250,8 +250,10 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0] modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
assert layer.inputs["Object Space"].default_value is True
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0) assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == 0.5 assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == pytest.approx(0.2)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0 assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0
object_data.set_modifier_input( object_data.set_modifier_input(
modifier, modifier,
@@ -623,134 +625,47 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
source.name, source.name,
result.name, result.name,
} }
modifier = result.modifiers[0] assert len(result.modifiers) == 2
mode_id = object_data.modifier_input_identifier(modifier.node_group, "Mode") modifier, symmetry = result.modifiers
assert modifier_value(modifier, mode_id) == 1 assert modifier.node_group.name == "O Image Depth Cutout"
object_data.set_modifier_input(modifier, mode_id, 0) assert symmetry.node_group.name == "O Image Cutout Symmetry"
depth_image_id = object_data.modifier_input_identifier( layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
modifier.node_group, assert layer.inputs["Object Space"].default_value is True
"Depth Image", assert not modifier.show_group_selector and not symmetry.show_group_selector
)
assert isinstance(modifier_value(modifier, depth_image_id), bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
crop_center = Vector(
(
(local_bounds[0] + local_bounds[1]) * 0.5,
(local_bounds[2] + local_bounds[3]) * 0.5,
0.0,
)
)
expected_origin = source.matrix_world @ crop_center
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(expected_origin),
atol=1e-5,
)
depth_reference_id = object_data.modifier_input_identifier( def value(modifier, name, subtype=None):
modifier.node_group, return modifier_value(modifier, object_data.modifier_input_identifier(
"Reference Depth", modifier.node_group, name, subtype=subtype))
)
assert not np.isclose(modifier_value(modifier, depth_reference_id), 8.0) assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0)
depth_direction_id = object_data.modifier_input_identifier( for subtype in ("NONE", "DISTANCE"):
modifier.node_group, assert value(modifier, "Thickness", subtype) == 0
"Depth Direction", initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"}
) for socket in modifier.node_group.interface.items_tree:
direction = np.asarray(modifier_value(modifier, depth_direction_id), dtype=np.float64) if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
assert not np.allclose(direction, (0.0, 0.0, 1.0)) and socket.name not in initialized
uniform_scale_id = object_data.modifier_input_identifier( and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
modifier.node_group, assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
"Uniform Scale", assert value(modifier, "Depth Axis") == 0
) assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
assert modifier_value(modifier, uniform_scale_id) > 0.0 assert value(modifier, "Uniform Scale") > 0
thickness_id = object_data.modifier_input_identifier( assert value(modifier, "Reference Depth") > 0
modifier.node_group, assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1))
"Thickness", crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
) (local_bounds[2] + local_bounds[3]) / 2, 0))
smooth_id = object_data.modifier_input_identifier( np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
modifier.node_group, from scipy.spatial import cKDTree
"Smooth Weight",
)
assert modifier_value(modifier, smooth_id) == 1.0
object_data.set_modifier_input(modifier, smooth_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result) positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) assert len(positions) and np.isfinite(positions).all()
nearest_center = center_distance <= center_distance.min() + 1e-6 assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5
depth_at_thickness_one = positions[nearest_center, 2].max() # Both modes start from the same front, including after changing the menu.
object_data.set_modifier_input(modifier, thickness_id, 2.0) object_data.set_modifier_input(modifier,
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
0 if gesture == "POLYLINE" else 1)
result.update_tag(refresh={"DATA"}) result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update() bpy.context.view_layer.update()
positions = _evaluated_positions(result) changed = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) np.testing.assert_array_equal(changed, positions)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_two = positions[nearest_center, 2].max()
assert np.isclose(
depth_at_thickness_one,
depth_at_thickness_two,
atol=1e-5,
)
object_data.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier_value(modifier, depth_reference_id) + 0.2,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
after_positions = _evaluated_positions(result)
assert np.isclose(
(after_positions[:, 2] - before_positions[:, 2]).max(),
0.2,
atol=1e-5,
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_before = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier_value(modifier, depth_reference_id) + 0.15,
)
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_after = _evaluated_positions(result)
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = object_data.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
object_data.set_modifier_input(modifier, double_sided_id, True)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
mirrored = _evaluated_positions(result)
layers = {}
for x, y, z in mirrored:
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
assert layers
for z_values in layers.values():
if len(z_values) == 1:
assert np.isclose(z_values[0], 0.0, atol=1e-5)
continue
assert np.isclose(
min(z_values) + max(z_values),
0.0,
atol=1e-5,
)
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup(): def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():

View File

@@ -1,11 +1,11 @@
"""Shared assertions and asset selection for node tests.""" """Shared assertions and asset selection for node tests."""
from tools.nodes.groups.image_cutout import build_image_cutout_group 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_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
CUTOUT_BUILDERS = { CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group, "FLAT": build_image_cutout_group,
"SOLID": build_image_cutout_group, "SOLID": build_image_cutout_group,
"DEPTH_SOLID": build_image_depth_cutout_group, "DEPTH_SOLID": build_image_depth_cutout_group,
"DEPTH_SYMMETRY": build_image_depth_symmetry_group, "DEPTH_SYMMETRY": build_image_cutout_symmetry_group,
} }

File diff suppressed because one or more lines are too long

View File

@@ -98,7 +98,8 @@ def _surface(node_group):
image.pixels.foreach_set(pixels.ravel()) image.pixels.foreach_set(pixels.ravel())
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image, values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
"Uniform Scale": 1.0, "Reference Depth": 2.5, "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} "Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25,
"Depth Offset": 0.5}
mode = None mode = None
for item in node_group.interface.items_tree: for item in node_group.interface.items_tree:
if item.item_type != "SOCKET" or item.in_out != "INPUT": if item.item_type != "SOCKET" or item.in_out != "INPUT":

View File

@@ -83,7 +83,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
@pytest.mark.parametrize("mode", [0, 1]) @pytest.mark.parametrize("mode", [0, 1])
@pytest.mark.parametrize("reference", [0, 2]) @pytest.mark.parametrize("reference", [0, 2])
def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference): def test_normal_blend_weights_balloon_and_keeps_full_shell_smoothing(mode, reference):
obj, set_value = surface(resolution=2) obj, set_value = surface(resolution=2)
group = obj.modifiers[0].node_group group = obj.modifiers[0].node_group
# The production interface renames the Shell thickness socket after building. # The production interface renames the Shell thickness socket after building.
@@ -117,6 +117,9 @@ def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, referenc
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:])) results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
finally: finally:
ev.to_mesh_clear() ev.to_mesh_clear()
np.testing.assert_array_equal(results[0], [0,0,1])
np.testing.assert_array_equal(results[-1], [1,0,0]) np.testing.assert_array_equal(results[-1], [1,0,0])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001 if mode:
np.testing.assert_array_equal(results, [[1,0,0]] * len(results))
else:
np.testing.assert_array_equal(results[0], [0,0,1])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001

View File

@@ -0,0 +1,174 @@
"""Check composition against the original Projected geometry baseline."""
from collections import Counter
import json
from pathlib import Path
import bpy
import numpy as np
import pytest
from scipy.spatial import cKDTree
from mathutils import Euler, Vector
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from tests.support.depth_surface import evaluated, assert_closed
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_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)
count = 5
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
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)))
# Match Cutout's outline cleanup before recording or comparing projection.
boundary = (np.maximum(abs(x), abs(y)) == 1).ravel()
faces = [face for face in faces if not all(boundary[i] for i in face)]
mesh = bpy.data.meshes.new('Curved Depth')
mesh.from_pydata(np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size))), [], 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)
setters = []
for name, builder in [('DEPTH_SOLID', build_image_depth_cutout_group),
('DEPTH_SYMMETRY', build_image_cutout_symmetry_group)]:
group = builder() if request.param == 'definition' else load_cutout_node_group(name)
modifier = obj.modifiers.new(group.name, 'NODES')
modifier.node_group = group
def set_value(name, value, subtype=None, modifier=modifier, group=group):
set_modifier_input(modifier, modifier_input_identifier(group, name, subtype=subtype), value)
obj.update_tag(refresh={'DATA'})
bpy.context.view_layer.update()
setters.append(set_value)
cutout, mirror = setters
mirror('Fill Smooth', 0)
for name, value in [('Depth Image', image), ('Reference Depth', 1.45),
('Depth Split', 0), ('Boundary Smooth', 0), ('Smooth', 0), ('Depth Axis', 0)]:
cutout(name, value)
yield obj, cutout, mirror
bpy.ops.wm.read_factory_settings(use_empty=True)
def oriented_faces(points, faces):
"""Ignore vertex numbering while retaining polygon winding."""
keys = [tuple(np.round(point, 6)) for point in points]
result = []
for face in faces:
loop = tuple(keys[i] for i in face)
result.append(min(loop[i:] + loop[:i] for i in range(len(loop))))
return Counter(result)
@pytest.mark.parametrize('mode', [0, 1])
def test_zero_thickness_matches_original_projected(symmetry, mode):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
baseline = json.loads((Path(__file__).parent / 'fixtures/projected_symmetry.json').read_text())
for case in baseline['cases']:
mirror('Symmetry Direction', case['direction'])
mirror('Fill Sides', case['filled'])
points, faces = evaluated(obj)
expected = np.array(case['points'])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert oriented_faces(points, faces) == oriented_faces(expected, case['faces'])
@pytest.mark.parametrize('mode', [0, 1])
@pytest.mark.parametrize('smooth', [0, 3])
def test_completed_geometry_is_mirrored_and_closed(symmetry, mode, smooth):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
cutout('Thickness', .2 if mode else .6, 'DISTANCE' if mode else 'NONE')
mirror('Symmetry Direction', (.3, -.2, 1))
mirror('Fill Smooth', smooth)
points, faces = evaluated(obj)
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert_closed(faces)
orientation = Counter()
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
assert not any(orientation.values())
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
direction = Vector((.3, -.2, 1)).normalized()
wall_count = 0
for polygon in mesh.polygons:
i = polygon.index
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[i].vector)
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
assert mesh.attributes[AXIS_ATTRIBUTE].data[i].value == 0
if mesh.attributes[FACE_ATTRIBUTE].data[i].value == 3:
wall_count += 1
if smooth == 0:
assert abs(polygon.normal.z) < 1e-5
assert wall_count
finally:
result.to_mesh_clear()
def test_fill_smoothing_preserves_surfaces_and_only_moves_wall_interiors(symmetry):
obj, _, mirror = symmetry
mirror('Fill Sides', False)
original, original_faces = evaluated(obj)
mirror('Fill Smooth', 3)
points, faces = evaluated(obj)
np.testing.assert_array_equal(points, original)
assert faces == original_faces
mirror('Fill Sides', True)
points, faces = evaluated(obj)
assert_closed(faces)
assert cKDTree(points).query(original)[0].max() < 1e-6
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert len(points) > len(original)
assert cKDTree(original[:, :2]).query(points[:, :2])[0].max() > .001
assert oriented_faces(original, original_faces).items() <= oriented_faces(points, faces).items()
def test_direction_offset_and_empty_geometry(symmetry):
obj, _, mirror = symmetry
mirror('Depth Offset', 2.)
mirror('Symmetry Direction', (.3, -.2, 1))
original, original_faces = evaluated(obj)
mirror('Depth Offset', 2.2)
points, faces = evaluated(obj)
assert faces == original_faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
np.testing.assert_allclose(abs(points[:, 2]) - abs(original[:, 2]), .2, atol=1e-6)
mirror('Depth Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_symmetry_interface_and_cutout_defaults(symmetry):
obj, _, _ = symmetry
inputs = [item for item in obj.modifiers[1].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'INPUT']
assert [item.name for item in inputs] == ['Geometry', 'Symmetry Direction', 'Depth Offset', 'Fill Sides', 'Fill Smooth']
assert inputs[-1].default_value == 2 and inputs[-2].default_value is True
thickness = [item for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.name == 'Thickness']
assert len(thickness) == 2 and all(item.default_value == 0 for item in thickness)

View File

@@ -13,7 +13,7 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("source", ("definition", "asset")) @pytest.mark.parametrize("source", ("definition", "asset"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_SYMMETRY")) @pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID"))
def test_depth_axis_is_visible_in_options(shape, source): def test_depth_axis_is_visible_in_options(shape, source):
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape) group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)

View File

@@ -50,6 +50,43 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
assert np.max(np.abs(raw - smooth)) > 1e-5 assert np.max(np.abs(raw - smooth)) > 1e-5
def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0, resolution=12)
image = bpy.data.images['Camera']
pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + .15 * np.sin(pixels[..., 0] * 6) * np.cos(pixels[..., 1] * 5)
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value('Reference Depth', 10.)
set_value('Thickness', 0.)
front, _ = evaluated(obj)
def offsets(thickness, smoothing, reference=10.):
set_value('Thickness', thickness)
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 0, reference - 10.)
from scipy.spatial import cKDTree
distances, indices = cKDTree(adjusted_front).query(points)
np.testing.assert_allclose(np.sort(distances)[:len(front)], 0, atol=2e-6)
rear = distances > thickness * .5
assert rear.sum() == len(front)
order = np.argsort(indices[rear])
return ((points[rear] - adjusted_front[indices[rear]]) / thickness)[order], faces
raw, _ = offsets(.02, 16)
smooth, faces = offsets(.02, 512)
assert np.max(np.linalg.norm(raw - smooth, axis=1)) > .05
thinner, thinner_faces = offsets(.01, 512)
distant, _ = offsets(.02, 512, 20.)
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
np.testing.assert_allclose(distant, smooth, atol=2e-4)
assert thinner_faces == faces
def set_test_structure(monkeypatch, value): def set_test_structure(monkeypatch, value):
"""Control structural amplitude without depending on storage nodes.""" """Control structural amplitude without depending on storage nodes."""
from tools.nodes.groups import image_depth_cutout from tools.nodes.groups import image_depth_cutout

View File

@@ -1,239 +0,0 @@
"""Check projected clipping, fixed-plane symmetry and straight closed walls."""
from collections import Counter
import bpy
import bmesh
import numpy as np
import pytest
from mathutils import Euler, Vector
from scipy.spatial import cKDTree
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from tests.support.depth_surface import evaluated
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
@pytest.fixture(params=['definition', 'asset'])
def symmetry(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = (build_image_depth_symmetry_group() if request.param == 'definition'
else load_cutout_node_group('DEPTH_SYMMETRY'))
count = 17
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
points = np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size)))
faces = []
for row in range(count - 1):
for col in range(count - 1):
a = row * count + col
faces.extend(((a, a + 1, a + count + 1), (a, a + count + 1, a + count)))
mesh = bpy.data.meshes.new('Curved Depth')
mesh.from_pydata(points, [], faces)
uv = mesh.uv_layers.new(name='UVMap')
for loop in mesh.loops:
row, col = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((col + .5) / count, (row + .5) / count)
profile = .3 * np.maximum(0, 1 - np.maximum(abs(x), abs(y)))
mesh.attributes.new('o_balloon', 'FLOAT', 'POINT').data.foreach_set('value', profile.ravel())
mesh.attributes.new('user_probe', 'FLOAT', 'POINT').data.foreach_set('value', np.ones(x.size))
image = bpy.data.images.new('Depth', width=count, height=count, float_buffer=True)
image.colorspace_settings.name = 'Non-Color'
image.alpha_mode = 'CHANNEL_PACKED'
depth = 1 + .6 * x ** 2 + .3 * y ** 2
image.pixels.foreach_set(np.dstack((x, -y, depth, np.ones_like(x))).astype(np.float32).ravel())
obj = bpy.data.objects.new('Symmetry', mesh)
bpy.context.collection.objects.link(obj)
modifier = obj.modifiers.new('Symmetry', 'NODES')
modifier.node_group = group
def set_value(name, value):
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
obj.update_tag(refresh={'DATA'})
bpy.context.view_layer.update()
for name, value in [('Mode', 1), ('Depth Image', image), ('Reference Depth', 1.45),
('Depth Scale', 1.), ('Uniform Scale', 1.), ('Boundary Smooth', 0),
('Smooth', 0), ('Double Sided', True)]:
set_value(name, value)
yield obj, set_value, depth.ravel(), faces
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize('direction,smooth,scale', [((0, 0, 1), 0, .001), ((-.4, .2, 1), 0, 1),
((.2, -.3, 1), 3, 100)])
def test_projected_volume_is_closed_symmetric_and_has_straight_walls(symmetry, direction, smooth, scale):
obj, set_value, _, _ = symmetry
for name, value in [('Depth Direction', direction), ('Smooth', smooth),
('Uniform Scale', scale), ('Reference Depth', 1.45 * scale)]:
set_value(name, value)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
points = np.array([v.co[:] for v in mesh.vertices])
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < scale * 1e-5
bm = bmesh.new()
bm.from_mesh(mesh)
assert all(v.is_manifold for v in bm.verts)
assert all(e.is_manifold for e in bm.edges)
bm.free()
orientation = Counter()
wall_count = 0
for polygon in mesh.polygons:
face = tuple(polygon.vertices)
for a, b in zip(face, (*face[1:], face[0])):
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
heights = points[list(face), 2]
if heights.min() < 0 < heights.max():
wall_count += 1
assert abs(polygon.normal.z) < 1e-5
assert not polygon.use_smooth
assert wall_count and not any(orientation.values())
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
finally:
result.to_mesh_clear()
def test_projected_deletes_whole_crossing_faces_and_supports_empty_output(symmetry):
obj, set_value, depth, source_faces = symmetry
set_value('Double Sided', False)
points, faces = evaluated(obj)
assert (points[:, 2] > 0).all()
expected = [face for face in source_faces if np.all(depth[list(face)] < 1.45 - 1e-7)]
assert len(faces) == len(expected)
assert set(np.concatenate(faces)) == set(range(len(points)))
set_value('Reference Depth', .5)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_mode_switch_and_output_axis_preserve_state(symmetry):
obj, set_value, _, _ = symmetry
projected, projected_faces = evaluated(obj)
set_value('Mode', 0)
balloon, balloon_faces = evaluated(obj)
assert len(balloon_faces) != len(projected_faces)
set_value('Mode', 1)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, projected)
assert restored_faces == projected_faces
set_value('Depth Axis', 1)
rotated, rotated_faces = evaluated(obj)
np.testing.assert_allclose(rotated, projected @ np.array(CUTOUT_Z_TO_X)[:3, :3].T, atol=1e-6)
assert rotated_faces == projected_faces
set_value('Depth Axis', 0)
set_value('Mode', 0)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, balloon)
assert restored_faces == balloon_faces
def test_symmetry_asset_has_direct_mode_branches(symmetry):
obj, _, _, _ = symmetry
group = obj.modifiers[0].node_group
assert not any(node.bl_idname in {'GeometryNodeMeshBoolean', 'GeometryNodeGroup',
'GeometryNodeCaptureAttribute'} for node in group.nodes)
selector = next(node for node in group.nodes if node.bl_idname == 'GeometryNodeMenuSwitch'
and node.inputs['Menu'].links[0].from_socket.name == 'Mode')
assert [item.name for item in selector.enum_items] == ['Balloon', 'Projected']
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'INPUT'}
for name in ('Depth Direction', 'Depth Offset'):
assert inputs[name].parent == inputs['Depth Scale'].parent
for name in ('Double Sided', 'Reference Depth', 'Boundary Smooth'):
assert inputs[name].parent.name == 'Options'
assert inputs['Boundary Smooth'].default_value == 1
assert inputs['Mode'].default_value == 'Projected'
assert inputs['Fill Sides'].default_value is True
assert inputs['Depth Offset'].default_value == 0
assert inputs['Depth Offset'].min_value < 0
@pytest.mark.parametrize('mode', [0, 1])
def test_depth_offset_changes_depth_after_direction_without_moving_xy(symmetry, mode):
obj, set_value, _, _ = symmetry
for name, value in [('Mode', mode), ('Depth Direction', (.3, -.2, 1)),
('Reference Depth', 4.), ('Depth Scale', .6)]:
set_value(name, value)
original, faces = evaluated(obj)
for offset in (.2, -.1):
set_value('Depth Offset', offset)
points, changed_faces = evaluated(obj)
assert changed_faces == faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
delta = abs(points[:, 2]) - abs(original[:, 2])
if mode == 1:
np.testing.assert_allclose(delta, offset, atol=1e-6)
else:
if offset > 0:
assert delta.max() > .1
else:
assert delta.min() < -.05
boundary = abs(original[:, 2]) < 1e-8
np.testing.assert_allclose(points[boundary, 2], 0, atol=1e-8)
set_value('Depth Offset', 0.)
restored, _ = evaluated(obj)
np.testing.assert_allclose(restored, original, atol=1e-6)
if mode == 1:
set_value('Depth Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_material_attributes_match_evaluated_faces_and_direction(symmetry):
obj, set_value, _, _ = symmetry
direction = Vector((.3, -.2, 1)).normalized()
set_value('Depth Direction', direction)
for axis in (0, 1):
set_value('Depth Axis', axis)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
points = np.array([v.co[:] for v in mesh.vertices])
if axis:
points = points @ np.array(CUTOUT_Z_TO_X)[:3, :3]
for name in (ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE):
assert mesh.attributes[name].domain == 'FACE'
for polygon in mesh.polygons:
index = polygon.index
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[index].vector)
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
heights = points[list(polygon.vertices), 2]
kind = 1 if heights.min() > 0 else 2 if heights.max() < 0 else 3
assert mesh.attributes[FACE_ATTRIBUTE].data[index].value == kind
assert mesh.attributes[AXIS_ATTRIBUTE].data[index].value == axis
finally:
result.to_mesh_clear()
set_value('Mode', 0)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
assert ROTATION_ATTRIBUTE not in result.data.attributes
assert FACE_ATTRIBUTE not in result.data.attributes
def test_fill_sides_only_controls_projected_walls(symmetry):
obj, set_value, _, _ = symmetry
closed, closed_faces = evaluated(obj)
set_value('Fill Sides', False)
points, faces = evaluated(obj)
assert len(faces) < len(closed_faces)
assert cKDTree(points).query(closed)[0].max() < 1e-6
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert all(not (points[list(face), 2].min() < 0 < points[list(face), 2].max()) for face in faces)
edges = Counter(tuple(sorted((a, b))) for face in faces
for a, b in zip(face, (*face[1:], face[0])))
assert any(count == 1 for count in edges.values())
set_value('Fill Sides', True)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, closed)
assert restored_faces == closed_faces
for name, value in [('Double Sided', False), ('Mode', 0)]:
set_value(name, value)
before, before_faces = evaluated(obj)
set_value('Fill Sides', False)
after, after_faces = evaluated(obj)
np.testing.assert_array_equal(after, before)
assert after_faces == before_faces
set_value('Fill Sides', True)

View File

@@ -27,7 +27,7 @@ def test_saved_parameter_descriptions_match_definitions():
"Thickness", "Depth Split", "Smooth", "Smooth Weight", "Thickness", "Depth Split", "Smooth", "Smooth Weight",
"Boundary Smooth", "Normal Smooth", "Front Inflation", "Boundary Smooth", "Normal Smooth", "Front Inflation",
}, },
"O Image Depth Symmetry": {"Thickness", "Smooth", "Smooth Weight", "Boundary Smooth"}, "O Image Cutout Symmetry": {"Symmetry Direction", "Depth Offset", "Fill Sides", "Fill Smooth"},
"O Image Depth Panorama": { "O Image Depth Panorama": {
"Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight", "Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight",
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance", "Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
@@ -72,6 +72,7 @@ def test_saved_parameter_descriptions_match_definitions():
thickness = [item.description for item in sockets if item.name == "Thickness"] thickness = [item.description for item in sockets if item.name == "Thickness"]
assert len(thickness) == 2 and len(set(thickness)) == 2 assert len(thickness) == 2 and len(set(thickness)) == 2
if group.name == "O Image Depth Cutout": if group.name == "O Image Depth Cutout":
assert all(item.default_value == 0 for item in sockets if item.name == "Thickness")
assert not {"Outline Depth Fix", "Center Smooth", "Center Subdivide", "Show Center"} & { assert not {"Outline Depth Fix", "Center Smooth", "Center Subdivide", "Show Center"} & {
item.name for item in group.interface.items_tree if item.item_type == "SOCKET" item.name for item in group.interface.items_tree if item.item_type == "SOCKET"
} }

View File

@@ -53,6 +53,7 @@
"GeometryNodeRepeatOutput": 3, "GeometryNodeRepeatOutput": 3,
"GeometryNodeSampleIndex": 3, "GeometryNodeSampleIndex": 3,
"GeometryNodeSampleNearest": 1, "GeometryNodeSampleNearest": 1,
"GeometryNodeProximity": 1,
"GeometryNodeSampleNearestSurface": 1, "GeometryNodeSampleNearestSurface": 1,
"GeometryNodeSeparateGeometry": 1, "GeometryNodeSeparateGeometry": 1,
"GeometryNodeSetMaterialIndex": 0, "GeometryNodeSetMaterialIndex": 0,

View File

@@ -9,7 +9,7 @@ from .groups.image_depth_plane import build_image_depth_plane_group
from .groups.image_relief_plane import build_image_relief_plane_group from .groups.image_relief_plane import build_image_relief_plane_group
from .groups.image_cutout import build_image_cutout_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_cutout import build_image_depth_cutout_group
from .groups.image_depth_symmetry import build_image_depth_symmetry_group from .groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from .groups.image_layer import build_image_layer_group from .groups.image_layer import build_image_layer_group
from .groups.image_depth_layer import build_image_depth_layer_group from .groups.image_depth_layer import build_image_depth_layer_group
from .groups.shadeless import build_shadeless_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_relief_plane_group(image_plane),
build_image_cutout_group(), build_image_cutout_group(),
build_image_depth_cutout_group(), build_image_depth_cutout_group(),
build_image_depth_symmetry_group(), build_image_cutout_symmetry_group(),
build_image_layer_group(), build_image_layer_group(),
build_image_depth_layer_group(), build_image_depth_layer_group(),
build_shadeless_group(), build_shadeless_group(),

View File

@@ -9,7 +9,7 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyIma
GEOMETRY_NAMES = { GEOMETRY_NAMES = {
"O Image Depth Panorama", "O Image Depth Panorama",
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane", "O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Symmetry", "O Image Cutout", "O Image Depth Cutout", "O Image Cutout Symmetry",
} }
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"} SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
@@ -60,8 +60,8 @@ def check_asset_library(asset_path):
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0, values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0, "Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
"Depth Split": 0.0} "Depth Split": 0.0}
if node_group.name == "O Image Depth Symmetry": if node_group.name == "O Image Cutout Symmetry":
values["Reference Depth"] = 2.5 values["Depth Offset"] = 0.5
for item in node_group.interface.items_tree: for item in node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values: if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
modifier[item.identifier] = values[item.name] modifier[item.identifier] = values[item.name]

View File

@@ -0,0 +1,187 @@
"""Mirror completed depth geometry and fill matching boundaries."""
import bpy
from ..common.nodes import (
interface_socket, group_input, float_input, bool_input, vector_input,
evaluate_field, store_float_attribute, read_float_attribute, prepare_node_group,
)
from ..common.depth_surface import _math
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
NODE_GROUP_NAME = "O Image Cutout Symmetry"
def _align_front(group, geometry, controls):
"""Align existing geometry with the fixed XY symmetry plane."""
nodes, links = group.nodes, group.links
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Z'
links.new(controls['Symmetry 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(geometry, 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 _mirror(group, geometry):
mirror = group.nodes.new('GeometryNodeTransform')
mirror.inputs['Scale'].default_value = (1, 1, -1)
group.links.new(geometry, mirror.inputs['Geometry'])
flip = group.nodes.new('GeometryNodeFlipFaces')
group.links.new(mirror.outputs[0], flip.inputs['Mesh'])
return flip.outputs[0]
def _extrude_boundary(group, geometry, selection, z_factor):
"""Extrude selected edges to a fraction of their current height."""
nodes, links = group.nodes, group.links
extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES'
extrude.inputs['Offset Scale'].default_value = 0
links.new(geometry, extrude.inputs['Mesh'])
links.new(selection, extrude.inputs['Selection'])
position = nodes.new('GeometryNodeInputPosition')
scale = nodes.new('ShaderNodeVectorMath')
scale.operation = 'MULTIPLY'
scale.inputs[1].default_value = (1, 1, z_factor)
links.new(position.outputs[0], scale.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
links.new(extrude.outputs['Top'], place.inputs['Selection'])
links.new(scale.outputs[0], place.inputs['Position'])
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT', 'FACE'
store.inputs['Name'].default_value = FACE_ATTRIBUTE
store.inputs['Value'].default_value = 3
links.new(place.outputs[0], store.inputs['Geometry'])
links.new(extrude.outputs['Side'], store.inputs['Selection'])
return store.outputs[0], extrude.outputs['Top']
def _walls(group, front, boundary, smooth, tolerance, *, curved):
"""Build straight walls or a smoothed half-strip followed by its reflection."""
nodes, links = group.nodes, group.links
geometry, top = _extrude_boundary(group, front, boundary, .5 if curved else -1)
if curved:
geometry, _ = _extrude_boundary(group, geometry, top, 0)
sides = nodes.new('GeometryNodeSeparateGeometry')
sides.domain = 'FACE'
links.new(geometry, sides.inputs['Geometry'])
links.new(_math(group, 'COMPARE', read_float_attribute(group, FACE_ATTRIBUTE), 3), sides.inputs['Selection'])
geometry = sides.outputs['Selection']
if curved:
# Query the retained front to pin the original seam; only new ring XY moves.
proximity = nodes.new('GeometryNodeProximity')
proximity.target_element = 'POINTS'
links.new(front, proximity.inputs['Target'])
movable = _math(group, 'GREATER_THAN', proximity.outputs['Distance'], tolerance)
position = nodes.new('GeometryNodeInputPosition')
blur = nodes.new('GeometryNodeBlurAttribute')
blur.data_type = 'FLOAT_VECTOR'
links.new(position.outputs[0], blur.inputs['Value'])
links.new(smooth, blur.inputs['Iterations'])
# Keep heights fixed so neither the seam nor the symmetry plane can drift.
difference = nodes.new('ShaderNodeVectorMath')
difference.operation = 'SUBTRACT'
links.new(blur.outputs[0], difference.inputs[0])
links.new(position.outputs[0], difference.inputs[1])
mask = nodes.new('ShaderNodeVectorMath')
mask.operation = 'MULTIPLY'
mask.inputs[1].default_value = (.5, .5, 0)
links.new(difference.outputs[0], mask.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(movable, place.inputs['Selection'])
links.new(mask.outputs[0], place.inputs['Offset'])
join = nodes.new('GeometryNodeJoinGeometry')
links.new(place.outputs[0], join.inputs[0])
links.new(_mirror(group, place.outputs[0]), join.inputs[0])
geometry = join.outputs[0]
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
shade.inputs['Shade Smooth'].default_value = curved
links.new(geometry, shade.inputs['Geometry'])
return shade.outputs[0]
def build_image_cutout_symmetry_group():
existing = bpy.data.node_groups.get(NODE_GROUP_NAME)
if existing is not None:
return existing
group = bpy.data.node_groups.new(NODE_GROUP_NAME, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = True, True
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
vector_input(group, 'Symmetry Direction', (0, 0, 1), subtype='DIRECTION').description = 'Align this direction with the local symmetry axis.'
float_input(group, 'Depth Offset', 0, minimum=-1e10, subtype='DISTANCE').description = 'Offset the aligned geometry along the symmetry axis.'
bool_input(group, 'Fill Sides', True).description = 'Connect matching front and mirrored boundaries.'
smooth = interface_socket(group, 'Fill Smooth', 'INPUT', 'NodeSocketInt')
smooth.default_value, smooth.min_value, smooth.max_value = 2, 0, 20
smooth.description = 'Smooth only the generated wall interiors. Zero keeps straight walls.'
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
nodes, links = group.nodes, group.links
inputs = group_input(nodes, set()).outputs
bounds = nodes.new('GeometryNodeBoundBox')
links.new(inputs['Geometry'], bounds.inputs['Geometry'])
size = nodes.new('ShaderNodeVectorMath')
size.operation = 'DISTANCE'
links.new(bounds.outputs['Min'], size.inputs[0])
links.new(bounds.outputs['Max'], size.inputs[1])
tolerance = _math(group, 'MULTIPLY', _math(group, 'MAXIMUM', size.outputs['Value'], 1e-8), 1e-7)
front = _delete_negative_faces(group, _align_front(group, inputs['Geometry'], inputs), tolerance)
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
count = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
boundary = _math(group, 'COMPARE', count.outputs['Face Count'], 1)
walls = nodes.new('GeometryNodeSwitch')
walls.input_type = 'GEOMETRY'
links.new(_math(group, 'GREATER_THAN', inputs['Fill Smooth'], 0), walls.inputs['Switch'])
for curved, socket in ((False, 'False'), (True, 'True')):
links.new(_walls(group, front, boundary, inputs['Fill Smooth'], tolerance, curved=curved), walls.inputs[socket])
enabled = nodes.new('GeometryNodeSwitch')
enabled.input_type = 'GEOMETRY'
links.new(inputs['Fill Sides'], enabled.inputs['Switch'])
links.new(walls.outputs[0], enabled.inputs['True'])
back = store_float_attribute(group, _mirror(group, front), FACE_ATTRIBUTE, 2., domain='FACE')
join = nodes.new('GeometryNodeJoinGeometry')
for geometry in (front, back, enabled.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['Geometry'])
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 0., domain='FACE')
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
prepare_node_group(group)
return group

View File

@@ -15,15 +15,13 @@ NODE_GROUP_NAME = "O Image Depth Cutout"
def build_normal_weight(group, is_shell): def build_normal_weight(group, is_shell):
"""Scale normal smoothing by the active mode's relative thickness.""" """Use full shell smoothing and thickness-weighted balloon smoothing."""
control = group_input(group.nodes, {"Thickness", "Shell Thickness", "Reference Depth"}) control = group_input(group.nodes, {"Thickness"})
amount = group.nodes.new("GeometryNodeSwitch") amount = group.nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT" amount.input_type = "FLOAT"
group.links.new(is_shell, amount.inputs["Switch"]) group.links.new(is_shell, amount.inputs["Switch"])
group.links.new(control.outputs["Thickness"], amount.inputs["False"]) group.links.new(control.outputs["Thickness"], amount.inputs["False"])
ratio = _math(group, "DIVIDE", control.outputs["Shell Thickness"], amount.inputs["True"].default_value = 1
_math(group, "MAXIMUM", control.outputs["Reference Depth"], 1e-8))
group.links.new(ratio, amount.inputs["True"])
ramp = group.nodes.new("ShaderNodeMapRange") ramp = group.nodes.new("ShaderNodeMapRange")
ramp.interpolation_type = "SMOOTHSTEP" ramp.interpolation_type = "SMOOTHSTEP"
ramp.clamp = True ramp.clamp = True
@@ -343,8 +341,8 @@ def build_image_depth_cutout_group():
group.color_tag = 'GEOMETRY' group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.' float_input(group, 'Thickness', 0.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.5, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.' float_input(group, 'Shell Thickness', 0.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
float_input(group, 'Depth Scale', 1.0) float_input(group, 'Depth Scale', 1.0)
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.' float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.'
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')

View File

@@ -1,289 +0,0 @@
"""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