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:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-14
|
||||||
77
openspec/changes/compose-depth-cutout-symmetry/design.md
Normal file
77
openspec/changes/compose-depth-cutout-symmetry/design.md
Normal 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
|
||||||
|
|
||||||
|
无阻塞产品决策。侧壁条带的内部采样数量在实现时以现有样例验证,不增加公开控制项。
|
||||||
28
openspec/changes/compose-depth-cutout-symmetry/proposal.md
Normal file
28
openspec/changes/compose-depth-cutout-symmetry/proposal.md
Normal 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` 内嵌节点不会自动迁移,后续从资产加载使用新接口。
|
||||||
@@ -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 开关分别比较,新旧结果的顶点位置在浮点容差内一致,面数、连接及朝向一致
|
||||||
@@ -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 也不改变壳体形状
|
||||||
27
openspec/changes/compose-depth-cutout-symmetry/tasks.md
Normal file
27
openspec/changes/compose-depth-cutout-symmetry/tasks.md
Normal 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.
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
174
tests/tools/nodes/test_cutout_symmetry.py
Normal file
174
tests/tools/nodes/test_cutout_symmetry.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
187
tools/nodes/groups/image_cutout_symmetry.py
Normal file
187
tools/nodes/groups/image_cutout_symmetry.py
Normal 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
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user