diff --git a/openspec/changes/compose-depth-cutout-symmetry/.openspec.yaml b/openspec/changes/compose-depth-cutout-symmetry/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/compose-depth-cutout-symmetry/design.md b/openspec/changes/compose-depth-cutout-symmetry/design.md new file mode 100644 index 0000000..cda8624 --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/design.md @@ -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 + +无阻塞产品决策。侧壁条带的内部采样数量在实现时以现有样例验证,不增加公开控制项。 diff --git a/openspec/changes/compose-depth-cutout-symmetry/proposal.md b/openspec/changes/compose-depth-cutout-symmetry/proposal.md new file mode 100644 index 0000000..d939cd7 --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/proposal.md @@ -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` 内嵌节点不会自动迁移,后续从资产加载使用新接口。 diff --git a/openspec/changes/compose-depth-cutout-symmetry/specs/post-cutout-symmetry/spec.md b/openspec/changes/compose-depth-cutout-symmetry/specs/post-cutout-symmetry/spec.md new file mode 100644 index 0000000..f796d57 --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/specs/post-cutout-symmetry/spec.md @@ -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 开关分别比较,新旧结果的顶点位置在浮点容差内一致,面数、连接及朝向一致 diff --git a/openspec/changes/compose-depth-cutout-symmetry/specs/shell-normal-smoothing/spec.md b/openspec/changes/compose-depth-cutout-symmetry/specs/shell-normal-smoothing/spec.md new file mode 100644 index 0000000..d0e328c --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/specs/shell-normal-smoothing/spec.md @@ -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 也不改变壳体形状 diff --git a/openspec/changes/compose-depth-cutout-symmetry/tasks.md b/openspec/changes/compose-depth-cutout-symmetry/tasks.md new file mode 100644 index 0000000..221e976 --- /dev/null +++ b/openspec/changes/compose-depth-cutout-symmetry/tasks.md @@ -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/`。 + diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 509e43a..acbc79e 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:2155b18564e8f22620538cad3551f5fd4af0a8704c496b0a8b0eb57495c78e8f -size 9340892 +oid sha256:251579e2a6f6b96b2ee8fe3d403a6ae8c292a3026d79f75ce86dcae8f2bbbf66 +size 8188083 diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index edec212..f636a81 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -90,7 +90,7 @@ def create_shape_object( ): 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( source_object, content_bounds, @@ -180,7 +180,7 @@ def create_shape_object( crop_center, ) 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.node_group = node_group modifier.show_group_selector = False @@ -188,18 +188,26 @@ def create_shape_object( ("Cleanup Threshold", cleanup_value), ( "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( modifier, modifier_input_identifier( 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, ) - 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( modifier, modifier_input_identifier(node_group, "Mode"), @@ -224,9 +232,13 @@ def create_shape_object( value, ) 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( - modifier, - modifier_input_identifier(node_group, "Depth Direction"), + symmetry, + modifier_input_identifier(symmetry_group, "Symmetry Direction"), depth_direction, ) return finalize_object_result( diff --git a/src/anyimage/operators/cutout_tool/operators.py b/src/anyimage/operators/cutout_tool/operators.py index 74c41b1..1f2fdd9 100644 --- a/src/anyimage/operators/cutout_tool/operators.py +++ b/src/anyimage/operators/cutout_tool/operators.py @@ -72,10 +72,8 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"} def normal_mode_for_shape(shape, generate_normal): - if shape == "DEPTH_SOLID": + if shape in DEPTH_CUTOUT_SHAPES: return NormalMode.OBJECT - if shape == "DEPTH_SYMMETRY": - return NormalMode.TANGENT if not generate_normal: return NormalMode.NONE return NormalMode.TANGENT diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 1dd2f42..24ca9bf 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -11,7 +11,7 @@ CUTOUT_Z_TO_X = ( CUTOUT_NODE_GROUP_NAMES = { "FLAT": "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", } CUTOUT_SHAPE_LABELS = { diff --git a/tests/anyimage/operators/test_cutout_depth.py b/tests/anyimage/operators/test_cutout_depth.py index 9bc72a7..7d54a38 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_SYMMETRY", enabled) == "TANGENT" + assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "OBJECT" 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 920c9c5..3c6e20b 100644 --- a/tests/anyimage/operators/test_cutout_interaction.py +++ b/tests/anyimage/operators/test_cutout_interaction.py @@ -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_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("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" diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index 526fd7f..866aad7 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -250,8 +250,10 @@ def test_depth_surface_protected_smooth_evaluates(gesture): assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] 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, "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 object_data.set_modifier_input( modifier, @@ -623,134 +625,47 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture): source.name, 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", - ) - 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, - ) + assert len(result.modifiers) == 2 + modifier, symmetry = result.modifiers + assert modifier.node_group.name == "O Image Depth Cutout" + assert symmetry.node_group.name == "O Image Cutout Symmetry" + layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree) + assert layer.inputs["Object Space"].default_value is True + assert not modifier.show_group_selector and not symmetry.show_group_selector - depth_reference_id = object_data.modifier_input_identifier( - modifier.node_group, - "Reference Depth", - ) - assert not np.isclose(modifier_value(modifier, depth_reference_id), 8.0) - depth_direction_id = object_data.modifier_input_identifier( - modifier.node_group, - "Depth Direction", - ) - direction = np.asarray(modifier_value(modifier, depth_direction_id), dtype=np.float64) - assert not np.allclose(direction, (0.0, 0.0, 1.0)) - uniform_scale_id = object_data.modifier_input_identifier( - modifier.node_group, - "Uniform Scale", - ) - assert modifier_value(modifier, uniform_scale_id) > 0.0 - thickness_id = object_data.modifier_input_identifier( - modifier.node_group, - "Thickness", - ) - smooth_id = object_data.modifier_input_identifier( - modifier.node_group, - "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() + def value(modifier, name, subtype=None): + return modifier_value(modifier, object_data.modifier_input_identifier( + modifier.node_group, name, subtype=subtype)) + + assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0) + for subtype in ("NONE", "DISTANCE"): + assert value(modifier, "Thickness", subtype) == 0 + initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"} + for socket in modifier.node_group.interface.items_tree: + if (socket.item_type == "SOCKET" and socket.in_out == "INPUT" + and socket.name not in initialized + and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}): + assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value) + assert value(modifier, "Depth Axis") == 0 + assert isinstance(value(modifier, "Depth Image"), bpy.types.Image) + assert value(modifier, "Uniform Scale") > 0 + assert value(modifier, "Reference Depth") > 0 + assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1)) + crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2, + (local_bounds[2] + local_bounds[3]) / 2, 0)) + np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5) + from scipy.spatial import cKDTree positions = _evaluated_positions(result) - center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) - nearest_center = center_distance <= center_distance.min() + 1e-6 - depth_at_thickness_one = positions[nearest_center, 2].max() - object_data.set_modifier_input(modifier, thickness_id, 2.0) + assert len(positions) and np.isfinite(positions).all() + assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5 + # Both modes start from the same front, including after changing the menu. + 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"}) bpy.context.view_layer.update() - positions = _evaluated_positions(result) - center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) - 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, - ) + changed = _evaluated_positions(result) + np.testing.assert_array_equal(changed, positions) def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup(): diff --git a/tests/support/nodes.py b/tests/support/nodes.py index 249094c..4cd42fa 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_symmetry import build_image_depth_symmetry_group +from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group CUTOUT_BUILDERS = { "FLAT": build_image_cutout_group, "SOLID": build_image_cutout_group, "DEPTH_SOLID": build_image_depth_cutout_group, - "DEPTH_SYMMETRY": build_image_depth_symmetry_group, + "DEPTH_SYMMETRY": build_image_cutout_symmetry_group, } diff --git a/tests/tools/nodes/fixtures/projected_symmetry.json b/tests/tools/nodes/fixtures/projected_symmetry.json new file mode 100644 index 0000000..5a01698 --- /dev/null +++ b/tests/tools/nodes/fixtures/projected_symmetry.json @@ -0,0 +1 @@ +{"count":5,"reference":1.45,"cases":[{"direction":[0,0,1],"filled":false,"points":[[0.0,-1.0,-0.15000009536743164],[-0.5,-0.5,-0.2250000238418579],[0.0,-0.5,-0.375],[0.5,-0.5,-0.2250000238418579],[-0.5,0.0,-0.30000007152557373],[0.0,0.0,-0.4500000476837158],[0.5,0.0,-0.30000007152557373],[-0.5,0.5,-0.2250000238418579],[0.0,0.5,-0.375],[0.5,0.5,-0.2250000238418579],[0.0,1.0,-0.15000009536743164],[0.0,-1.0,0.15000009536743164],[-0.5,-0.5,0.2250000238418579],[0.0,-0.5,0.375],[0.5,-0.5,0.2250000238418579],[-0.5,0.0,0.30000007152557373],[0.0,0.0,0.4500000476837158],[0.5,0.0,0.30000007152557373],[-0.5,0.5,0.2250000238418579],[0.0,0.5,0.375],[0.5,0.5,0.2250000238418579],[0.0,1.0,0.15000009536743164]],"faces":[[0,2,3],[1,5,2],[1,4,5],[2,6,3],[2,5,6],[4,8,5],[4,7,8],[5,9,6],[5,8,9],[7,10,8],[11,14,13],[12,13,16],[12,16,15],[13,14,17],[13,17,16],[15,16,19],[15,19,18],[16,17,20],[16,20,19],[18,19,21]]},{"direction":[0,0,1],"filled":true,"points":[[0.0,-1.0,0.15000009536743164],[-0.5,-0.5,0.2250000238418579],[0.0,-0.5,0.375],[0.5,-0.5,0.2250000238418579],[-0.5,0.0,0.30000007152557373],[0.5,0.0,0.30000007152557373],[-0.5,0.5,0.2250000238418579],[0.0,0.5,0.375],[0.5,0.5,0.2250000238418579],[0.0,1.0,0.15000009536743164],[0.0,-1.0,-0.15000009536743164],[-0.5,-0.5,-0.2250000238418579],[0.0,-0.5,-0.375],[0.5,-0.5,-0.2250000238418579],[-0.5,0.0,-0.30000007152557373],[0.5,0.0,-0.30000007152557373],[-0.5,0.5,-0.2250000238418579],[0.0,0.5,-0.375],[0.5,0.5,-0.2250000238418579],[0.0,1.0,-0.15000009536743164],[0.0,0.0,-0.4500000476837158],[0.0,0.0,0.4500000476837158]],"faces":[[0,2,12,10],[1,11,12,2],[0,10,13,3],[1,4,14,11],[3,13,15,5],[4,6,16,14],[5,15,18,8],[7,8,18,17],[6,9,19,16],[7,17,19,9],[10,12,13],[11,20,12],[11,14,20],[12,15,13],[12,20,15],[14,17,20],[14,16,17],[20,18,15],[20,17,18],[16,19,17],[0,3,2],[1,2,21],[1,21,4],[2,3,5],[2,5,21],[4,21,7],[4,7,6],[21,5,8],[21,8,7],[6,7,9]]},{"direction":[0.3,-0.2,1],"filled":false,"points":[[-0.5068399310112,-0.9954401254653931,-0.04703602194786072],[-0.06969207525253296,-0.9535386562347412,-0.3292523920536041],[0.45212072134017944,-0.9680805206298828,-0.32925230264663696],[-0.5566588044166565,-0.4622274935245514,-0.16462618112564087],[-0.11951090395450592,-0.4203261137008667,-0.4468424320220947],[0.4023018479347229,-0.4348679184913635,-0.4468424916267395],[1.008779525756836,-0.505852997303009,-0.16462622582912445],[-0.5641452074050903,0.042763471603393555,-0.14110822975635529],[-0.12699733674526215,0.08466488122940063,-0.42332449555397034],[0.3948154151439667,0.07012305408716202,-0.4233245253562927],[1.0012930631637573,-0.0008620340377092361,-0.14110815525054932],[-0.09215129911899567,0.5614342093467712,-0.2586982846260071],[0.42966145277023315,0.5468924045562744,-0.25869831442832947],[-0.5068399310112,-0.9954401254653931,0.04703602194786072],[-0.06969207525253296,-0.9535386562347412,0.3292523920536041],[0.45212072134017944,-0.9680805206298828,0.32925230264663696],[-0.5566588044166565,-0.4622274935245514,0.16462618112564087],[-0.11951090395450592,-0.4203261137008667,0.4468424320220947],[0.4023018479347229,-0.4348679184913635,0.4468424916267395],[1.008779525756836,-0.505852997303009,0.16462622582912445],[-0.5641452074050903,0.042763471603393555,0.14110822975635529],[-0.12699733674526215,0.08466488122940063,0.42332449555397034],[0.3948154151439667,0.07012305408716202,0.4233245253562927],[1.0012930631637573,-0.0008620340377092361,0.14110815525054932],[-0.09215129911899567,0.5614342093467712,0.2586982846260071],[0.42966145277023315,0.5468924045562744,0.25869831442832947]],"faces":[[0,4,1],[0,3,4],[1,5,2],[1,4,5],[2,5,6],[3,8,4],[3,7,8],[4,9,5],[4,8,9],[5,10,6],[5,9,10],[7,11,8],[8,12,9],[8,11,12],[13,14,17],[13,17,16],[14,15,18],[14,18,17],[15,19,18],[16,17,21],[16,21,20],[17,18,22],[17,22,21],[18,19,23],[18,23,22],[20,21,24],[21,22,25],[21,25,24]]},{"direction":[0.3,-0.2,1],"filled":true,"points":[[-0.5068399310112,-0.9954401254653931,0.04703602194786072],[-0.06969207525253296,-0.9535386562347412,0.3292523920536041],[0.45212072134017944,-0.9680805206298828,0.32925230264663696],[-0.5566588044166565,-0.4622274935245514,0.16462618112564087],[1.008779525756836,-0.505852997303009,0.16462622582912445],[-0.5641452074050903,0.042763471603393555,0.14110822975635529],[0.3948154151439667,0.07012305408716202,0.4233245253562927],[1.0012930631637573,-0.0008620340377092361,0.14110815525054932],[-0.09215129911899567,0.5614342093467712,0.2586982846260071],[0.42966145277023315,0.5468924045562744,0.25869831442832947],[-0.5068399310112,-0.9954401254653931,-0.04703602194786072],[-0.06969207525253296,-0.9535386562347412,-0.3292523920536041],[0.45212072134017944,-0.9680805206298828,-0.32925230264663696],[-0.5566588044166565,-0.4622274935245514,-0.16462618112564087],[1.008779525756836,-0.505852997303009,-0.16462622582912445],[-0.5641452074050903,0.042763471603393555,-0.14110822975635529],[0.3948154151439667,0.07012305408716202,-0.4233245253562927],[1.0012930631637573,-0.0008620340377092361,-0.14110815525054932],[-0.09215129911899567,0.5614342093467712,-0.2586982846260071],[0.42966145277023315,0.5468924045562744,-0.25869831442832947],[-0.11951090395450592,-0.4203261137008667,-0.4468424320220947],[0.4023018479347229,-0.4348679184913635,-0.4468424916267395],[-0.12699733674526215,0.08466488122940063,-0.42332449555397034],[-0.11951090395450592,-0.4203261137008667,0.4468424320220947],[0.4023018479347229,-0.4348679184913635,0.4468424916267395],[-0.12699733674526215,0.08466488122940063,0.42332449555397034]],"faces":[[0,3,13,10],[0,10,11,1],[1,11,12,2],[2,12,14,4],[3,5,15,13],[4,14,17,7],[6,7,17,16],[5,8,18,15],[6,16,19,9],[8,9,19,18],[10,20,11],[10,13,20],[11,21,12],[11,20,21],[12,21,14],[13,22,20],[13,15,22],[20,16,21],[20,22,16],[21,17,14],[21,16,17],[15,18,22],[22,19,16],[22,18,19],[0,1,23],[0,23,3],[1,2,24],[1,24,23],[2,4,24],[3,23,25],[3,25,5],[23,24,6],[23,6,25],[24,4,7],[24,7,6],[5,25,8],[25,6,9],[25,9,8]]}]} \ No newline at end of file diff --git a/tests/tools/nodes/test_arrangement.py b/tests/tools/nodes/test_arrangement.py index 6bdc5d1..749b620 100644 --- a/tests/tools/nodes/test_arrangement.py +++ b/tests/tools/nodes/test_arrangement.py @@ -98,7 +98,8 @@ def _surface(node_group): image.pixels.foreach_set(pixels.ravel()) values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image, "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 for item in node_group.interface.items_tree: if item.item_type != "SOCKET" or item.in_out != "INPUT": diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py index eb2a362..5841b6c 100644 --- a/tests/tools/nodes/test_cutout_boundary.py +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -83,7 +83,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect): @pytest.mark.parametrize("mode", [0, 1]) @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) group = obj.modifiers[0].node_group # 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[:])) finally: ev.to_mesh_clear() - np.testing.assert_array_equal(results[0], [0,0,1]) 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 diff --git a/tests/tools/nodes/test_cutout_symmetry.py b/tests/tools/nodes/test_cutout_symmetry.py new file mode 100644 index 0000000..336099f --- /dev/null +++ b/tests/tools/nodes/test_cutout_symmetry.py @@ -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) diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index 3f9affe..91d6eee 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_SYMMETRY")) +@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID")) 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) diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index b61f4fc..4c2731b 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -50,6 +50,43 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness 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): """Control structural amplitude without depending on storage nodes.""" from tools.nodes.groups import image_depth_cutout diff --git a/tests/tools/nodes/test_depth_symmetry.py b/tests/tools/nodes/test_depth_symmetry.py deleted file mode 100644 index e4b4f8b..0000000 --- a/tests/tools/nodes/test_depth_symmetry.py +++ /dev/null @@ -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) diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index d4a798b..6f0f8ed 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 Symmetry": {"Thickness", "Smooth", "Smooth Weight", "Boundary Smooth"}, + "O Image Cutout Symmetry": {"Symmetry Direction", "Depth Offset", "Fill Sides", "Fill Smooth"}, "O Image Depth Panorama": { "Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight", "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"] assert len(thickness) == 2 and len(set(thickness)) == 2 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"} & { item.name for item in group.interface.items_tree if item.item_type == "SOCKET" } diff --git a/tools/nodes/arrangement/dimensions.json b/tools/nodes/arrangement/dimensions.json index ddc5941..8d3e636 100644 --- a/tools/nodes/arrangement/dimensions.json +++ b/tools/nodes/arrangement/dimensions.json @@ -53,6 +53,7 @@ "GeometryNodeRepeatOutput": 3, "GeometryNodeSampleIndex": 3, "GeometryNodeSampleNearest": 1, + "GeometryNodeProximity": 1, "GeometryNodeSampleNearestSurface": 1, "GeometryNodeSeparateGeometry": 1, "GeometryNodeSetMaterialIndex": 0, diff --git a/tools/nodes/build.py b/tools/nodes/build.py index 2aa32f8..7e532bd 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_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_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_symmetry_group(), + build_image_cutout_symmetry_group(), build_image_layer_group(), build_image_depth_layer_group(), build_shadeless_group(), diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 56be395..68172dd 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 Symmetry", + "O Image Cutout", "O Image Depth Cutout", "O Image Cutout Symmetry", } 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, "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 + if node_group.name == "O Image Cutout Symmetry": + values["Depth Offset"] = 0.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/groups/image_cutout_symmetry.py b/tools/nodes/groups/image_cutout_symmetry.py new file mode 100644 index 0000000..8598171 --- /dev/null +++ b/tools/nodes/groups/image_cutout_symmetry.py @@ -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 diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index ceaae2c..33372bb 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -15,15 +15,13 @@ NODE_GROUP_NAME = "O Image Depth Cutout" def build_normal_weight(group, is_shell): - """Scale normal smoothing by the active mode's relative thickness.""" - control = group_input(group.nodes, {"Thickness", "Shell Thickness", "Reference Depth"}) + """Use full shell smoothing and thickness-weighted balloon smoothing.""" + control = group_input(group.nodes, {"Thickness"}) amount = group.nodes.new("GeometryNodeSwitch") amount.input_type = "FLOAT" group.links.new(is_shell, amount.inputs["Switch"]) group.links.new(control.outputs["Thickness"], amount.inputs["False"]) - ratio = _math(group, "DIVIDE", control.outputs["Shell Thickness"], - _math(group, "MAXIMUM", control.outputs["Reference Depth"], 1e-8)) - group.links.new(ratio, amount.inputs["True"]) + amount.inputs["True"].default_value = 1 ramp = group.nodes.new("ShaderNodeMapRange") ramp.interpolation_type = "SMOOTHSTEP" ramp.clamp = True @@ -343,8 +341,8 @@ def build_image_depth_cutout_group(): group.color_tag = 'GEOMETRY' interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry') 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, 'Shell Thickness', 0.5, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.' + 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.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 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') diff --git a/tools/nodes/groups/image_depth_symmetry.py b/tools/nodes/groups/image_depth_symmetry.py deleted file mode 100644 index ac42255..0000000 --- a/tools/nodes/groups/image_depth_symmetry.py +++ /dev/null @@ -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