Refactor geometry generation to standardize Blender object coordinates

- Unified the canonical local frame for generated geometries (Plane, Depth Plane, Relief Plane, Cutout, Depth Cutout) to align with Blender's object coordinate system, setting X as horizontal, Z as up, and +Y as depth.
- Removed the `Depth Axis` parameter from `O Image Cutout` and `O Image Depth Cutout`, simplifying the geometry generation process.
- Updated the depth direction defaults to use +Y instead of -Z for `O Image Relief Plane` and Depth Symmetry.
- Adjusted the geometry nodes to ensure thickness, projection, and shell displacements operate along the Y-axis.
- Implemented new capabilities for handling object space normals to align with the new canonical frame.
- Enhanced the Depth Symmetry functionality to mirror across the canonical Y-axis and perform a final rotation to maintain the correct orientation.
- Added tests to validate the new coordinate system and ensure compatibility with existing assets.
This commit is contained in:
2026-09-15 13:33:37 +08:00
parent 591e62085a
commit 4035f5c9e4
47 changed files with 892 additions and 570 deletions

View File

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

View File

@@ -0,0 +1,111 @@
## Context
当前 Plane、Depth Plane、Relief Plane、Cutout 与 Depth Cutout 的生成网格沿 Image Empty 局部坐标:图片平面为 XY,图片向上为 +Y,深度向纸内为 -Z。Cutout 的 BaseShape 位于 `Z=0`,Geometry Nodes 沿 Z 做厚度、投影和对称。这个约定与 Blender 常规对象坐标不一致:Blender 对象通常 up=Z、front=-Y。
为了把侧视对象最终摆成“左右对称轴为 X、up 为 Z”,现有实现给 Cutout 节点加入 `Depth Axis`,把输出整体转到 +X。该参数把最终摆放轴与对称轴耦合,还要求 Object Space Normal 额外补偿,使用户在四个 Cutout 入口间看到不一致的最终方向。
本次变更把生成几何的 canonical local frame 统一到 Blender 对象坐标,并删除 `Depth Axis`。
## Goals / Non-Goals
**Goals:**
- 为所有生成几何定义并实现同一套 Blender canonical local frame。
- 让 Cutout BaseShape、Plane、Depth Plane、Relief Plane 的未修改网格都位于 XZ 平面,`Y=0`。
- 让 Depth Symmetry 在 canonical 坐标中沿 Y 镜像,最后绕 Z 转到 X,保持 up=Z。
- 让 Object Space Normal 随 canonical frame 和 Depth Symmetry 最终朝向一致。
- 删除 `O Image Cutout` 与 `O Image Depth Cutout` 的 `Depth Axis` 接口和实现。
**Non-Goals:**
- 不改变 Image Empty 自身坐标或 Blender 相机/Server 模型输出坐标。
- 不为用户新增轴向选择项。
- 不自动迁移用户工程中已保存的旧节点组或已生成对象。
## Decisions
### 1. 新 canonical frame 由创建阶段直接生成
不添加“全局坐标变换”节点,而是在 Python 建网和节点构建阶段直接按新坐标生成。
```text
旧 Image Empty local → 新 canonical object local
X → X
Y → Z
-Z → +Y
+Z → -Y
```
结果:
```text
X = 图片右
Z = 图片 up
+Y = 图片纸内 / depth
front = -Y
BaseShape / Plane 未修改网格位于 XZ 平面,Y=0
```
备选方案是在所有 modifier 前统一旋转,但会让 BaseShape、UV、metric 和节点内部轴反复转换,且不能消除 `Depth Axis` 类补偿。直接生成更直接,符合“少就是多”。
### 2. 所有节点深度轴从 Z 改为 Y
厚度、投影、Shell、Balloon、Relief 位移和 Depth Symmetry 镜像均改为 Y 轴语义:
- Image Plane 的网格/立方体尺寸使用 X/Z,厚度沿 Y。
- Depth Plane 与 Relief Plane 的投影位移沿 Y。
- Cutout 与 Depth Cutout 的 front/back 方向由 -Y/+Y 表达。
- `Depth Direction` 默认值从 `(0, 0, 1)` 改为 `(0, 1, 0)`。
### 3. Depth Symmetry 作为两阶段坐标语义
`O Image Cutout Symmetry` 保持“先 canonical 对称、后最终摆放”:
```text
canonical step:
mirror plane = XZ, mirror axis = +Y
keep one Y sign, mirror to the other side
final step:
rotate around Z so +Y → +X
keep +Z as up
```
该 yaw 旋转同时会把 canonical front `-Y` 变成 `-X`。这是本变更明确接受的最终朝向。
删除 `O Image Cutout` 与 `O Image Depth Cutout` 的 `Depth Axis`。Depth Symmetry 的最终朝向只存在于 `O Image Cutout Symmetry`。
### 4. Object Space Normal 使用明确的几何属性契约
材质仍通过 Geometry 属性把 Object Space Normal 从生成坐标转换到最终渲染坐标:
- canonical frame 转换进入 Object Normal 的解释路径。
- Depth Symmetry 的 front/back 反射改为 canonical Y 反射。
- 最终 yaw 旋转由 `O Image Cutout Symmetry` 写入最终朝向属性,材质读取同一属性。
- 删除旧 `Depth Axis` 对应的 `CUTOUT_Z_TO_X` 补偿路径。
普通 Plane / Depth Plane / Relief Plane 的 Object Normal 也按新 canonical frame 验证,不再依赖 Cutout 的旧轴补偿。
## Risks / Trade-offs
- 影响面很大,几乎覆盖所有生成几何和节点资产 → 使用既有 `uv run node-group build` 与全量测试闭环;不保留兼容层。
- BaseShape 和 Plane 世界位置可能出现符号/方向回归 → 对每个入口比较源 Image Empty 的世界 bounds 和生成网格 world-space 包围盒。
- Object Space Normal 在渲染中的表现可能不易从网格位置直接推断 → 保留/扩展材质渲染测试,覆盖 front、back、wall 和最终朝向。
- Depth Symmetry 最终 front 从 -Y 变为 -X,可能影响后续用户操作 → 在 spec 中明确该行为,不提供旧行为。
- 旧 `.blend` 内嵌节点和已生成对象无法自动迁移 → 运行时继续从 `O_AnyImage.blend` 资产加载,不扫描用户工程。
## Migration Plan
1. 先在 Python 建网层实现新 canonical frame,并同步 Cutout BaseShape、Plane、Depth Plane、Relief Plane 的顶点/UV/法线。
2. 重写 Geometry Nodes 的轴语义并删除 `Depth Axis`。
3. 调整 Depth Symmetry 与 Object Normal 属性契约。
4. 更新测试、`tools/nodes/check.py` 和资产构建;运行 `uv run node-group build`。
5. 运行全量测试并检查旧 `Depth Axis`、旧轴常量与旧 world-space 方向引用。
回滚策略为从版本控制恢复本次变更前的源码与 `O_AnyImage.blend`,不涉及数据迁移。
## Open Questions
- 最终 yaw 旋转选择 `+Y → +X` 还是 `+Y → -X`:在实现时用一个小型 orientation test 固定,保持 front 变化方向可预测。
- 普通 Plane / Depth Plane 的 Object Normal 转换放在材质节点内还是图片加载阶段:设计倾向保留材质属性契约,实现时按节点复杂度和测试成本选择。

View File

@@ -0,0 +1,28 @@
## Why
AnyImage 生成对象的局部坐标系仍沿用 Image Empty 的 `XY + depth -Z`,与 Blender 常规对象坐标 `up=Z`、`front=-Y` 不一致。Cutout 因此需要额外的 `Depth Axis` 来把结果转到左右轴,但没有真正解决对称轴、深度方向和 Object Space Normal 的耦合问题。
## What Changes
- 统一所有生成几何的 canonical local frame:X 为图片水平、Z 为图片 up、+Y 为图片纸内深度、front 为 -Y。
- BaseShape 与 Plane、Depth Plane、Relief Plane、Cutout、Depth Cutout 的未修改网格统一位于 XZ 平面,`Y=0`。
- **BREAKING**:删除 `O Image Cutout` 与 `O Image Depth Cutout` 的 `Depth Axis` 输入及最终轴向旋转逻辑。
- `O Image Cutout Symmetry` 在 canonical 坐标中沿 Y 轴镜像,最后绕 Z 旋转使对称轴指向 +X,并保持 up=+Z。
- Depth Symmetry 最终对象允许 front 从 -Y 改变为 -X,以换取左右对称轴为 X。
- Object Space Normal 的生成与着色转换随新 canonical frame 和最终对称朝向同步调整。
## Capabilities
### New Capabilities
- `canonical-object-frame`: 定义所有生成几何共用的 Blender 对象局部坐标系、BaseShape 平面、UV 映射与深度方向。
- `depth-symmetry-orientation`: 定义 Depth Symmetry 的 canonical 镜像轴、最终对称轴和 up 轴约束,以及允许 front 改变的最终朝向。
- `object-space-normal-alignment`: 定义 Object Space Normal 如何随 canonical frame、Depth Symmetry 镜像与最终朝向保持对齐。
### Modified Capabilities
无。当前 `openspec/specs/` 中没有既有 capability 规格。
## Impact
影响 `src/anyimage/operators/cutout_tool/geometry.py`、`object.py`、`depth_calibration.py`,`src/anyimage/operators/convert_to_plane/`,`tools/nodes/groups/` 下的 Plane、Depth Plane、Relief Plane、Cutout、Depth Cutout、Symmetry 与材质节点,`tools/nodes/common/` 的深度表面、Cutout 和法线映射辅助,以及相关测试与 `src/anyimage/assets/O_AnyImage.blend`。不新增依赖。

View File

@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Generated meshes use the Blender object frame
系统 SHALL 让 Plane、Depth Plane、Relief Plane、Cutout BaseShape 与 Depth Cutout 的输入网格使用统一的 Blender 对象局部坐标:X 为图片水平方向,Z 为图片 up,+Y 为图片纸内深度方向。所有未应用 modifier 的输入网格 MUST 位于 `Y=0` 的 XZ 平面。
#### Scenario: Create a Plane or Cutout BaseShape
- **WHEN** 用户创建 Plane 或 Cutout BaseShape
- **THEN** 未应用 modifier 的本地顶点 Y 坐标均为 0
- **AND** 顶点 X 范围对应源 Image Empty 的水平 bounds
- **AND** 顶点 Z 范围对应源 Image Empty 的 up bounds
### Requirement: Image UV maps to the XZ plane
系统 SHALL 将源图片的左右方向映射到本地 X,将源图片的上下方向映射到本地 Z,并保持 UV 采样在生成网格上的方向一致。
#### Scenario: Cutout UV corners follow image corners
- **WHEN** Cutout 从完整图片 bounds 生成 BaseShape
- **THEN** 图片左下角对应的本地顶点位于最小 X 和最小 Z
- **AND** 图片右上角对应的本地顶点位于最大 X 和最大 Z
- **AND** UV 读取的颜色纹理方向与源图片一致
### Requirement: Depth and thickness operate along Y
系统 SHALL 让 Depth Plane、Relief Plane、Cutout 与 Depth Cutout 的投影、厚度和 Shell 位移沿本地 Y 方向表达,并 SHALL 让 front 方向为 -Y、depth/纸内方向为 +Y。
#### Scenario: Depth Plane displaces toward positive Y
- **WHEN** Depth Plane 使用有效深度图且 Reference Depth 大于采样深度
- **THEN** 生成表面的 Y 坐标向 +Y 方向偏离输入平面
- **AND** 输入平面保持在 `Y=0`
#### Scenario: Cutout thickness grows along Y
- **WHEN** Cutout 设置非零 Thickness
- **THEN** 生成 Shell 的前后范围沿 Y 轴分布
- **AND** 没有几何沿 Z 轴被当作厚度方向
### Requirement: Depth Direction defaults to positive Y
`O Image Relief Plane` 与 Depth Symmetry 的 canonical 深度方向 SHALL 使用本地 +Y 作为默认方向,而不是 -Z。
#### Scenario: Relief Plane direction default
- **WHEN** 用户从资产加载 `O Image Relief Plane`
- **THEN** `Depth Direction` 默认值为 `(0, 1, 0)`
#### Scenario: Depth Symmetry canonical direction
- **WHEN** Depth Symmetry 使用默认方向校准
- **THEN** 对称前的 canonical 镜像轴 SHALL 为 Y
### Requirement: Cutout node assets no longer expose Depth Axis
`O Image Cutout` 与 `O Image Depth Cutout` MUST 不再公开 `Depth Axis` 输入,并且 MUST 不包含把最终几何从 -Z 转到 +X 的旧轴向旋转。
#### Scenario: Inspect Cutout interfaces
- **WHEN** 检查 `O Image Cutout` 和 `O Image Depth Cutout` 的输入接口
- **THEN** 不存在名为 `Depth Axis` 的 socket
- **AND** 节点组内不存在旧 `CUTOUT_Z_TO_X` 最终旋转

View File

@@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Depth Symmetry mirrors across the canonical Y axis
`O Image Cutout Symmetry` SHALL 在 canonical Blender 对象坐标中沿 Y 轴镜像,即镜像面为 `Y=0` 的 XZ 平面。镜像前后的几何 MUST 在最终旋转前保持 `(x, y, z)` 与 `(x, -y, z)` 的对应关系。
#### Scenario: Canonical mirroring
- **WHEN** 上游 Depth Cutout 输出 canonical 几何并连接 `O Image Cutout Symmetry`
- **THEN** 对称节点先按 Y 轴删除越界半侧并镜像到另一侧
- **AND** Fill Sides 开启时沿 Y 方向连接对应边界
### Requirement: Final symmetry axis is X with Z up
`O Image Cutout Symmetry` SHALL 在 canonical Y 镜像完成后执行绕 Z 的固定旋转,使对称轴从 +Y 变为 +X,并保持 +Z 为 up。最终结果 MUST 在本地坐标中关于 `X=0` 对称。
#### Scenario: Default Depth Symmetry result
- **WHEN** 用户通过 Depth Symmetry 入口生成对象
- **THEN** 最终 evaluated local geometry 关于 `X=0` 对称
- **AND** 最终 Z 范围反映图片 up 范围
- **AND** 对象保持 up=Z
### Requirement: Depth Symmetry final front direction may change
Depth Symmetry 的最终朝向 SHALL 允许 canonical front `-Y` 随最终 yaw 旋转变为 `-X`。系统 MUST 不要求 Depth Symmetry 最终继续满足 front=-Y。
#### Scenario: Final front direction
- **WHEN** 使用把 +Y 映射到 +X 的最终 yaw 旋转
- **THEN** canonical front 方向 -Y 映射为 -X
- **AND** up 方向 +Z 保持不变
### Requirement: Depth Symmetry path has no Cutout Depth Axis
Depth Symmetry 创建流程 SHALL 不设置或依赖 `O Image Depth Cutout` 的 `Depth Axis`。所有最终轴向语义 MUST 只由 `O Image Cutout Symmetry` 承担。
#### Scenario: Create Depth Symmetry modifiers
- **WHEN** 用户通过 Depth Symmetry 入口创建对象
- **THEN** 上游 `O Image Depth Cutout` 不包含 `Depth Axis` 输入
- **AND** 上游 Cutout 输出 canonical +Y 深度结果
- **AND** `O Image Cutout Symmetry` 决定最终 X 朝向

View File

@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Object Space Normal follows the canonical object frame
系统 SHALL 让 Plane、Depth Plane、Relief Plane、Cutout 与 Depth Cutout 的 Object Space Normal 贴图按新的 canonical Blender 对象坐标解释:X 为图片水平、Z 为 up、Y 为深度方向。Object Space Normal 渲染结果 MUST 与最终 evaluated geometry 的几何法线方向一致。
#### Scenario: Depth object uses Object Space Normal
- **WHEN** Depth Plane 或 Depth Solid 使用 Object Space Normal 贴图渲染
- **THEN** 平面法线朝向 -Y
- **AND** 深度表面产生的 normal 变化围绕 -Y 基准
- **AND** 渲染 normal 不再按旧 `-Z` 基准解释
### Requirement: Depth Symmetry normal maps follow mirror and final orientation
`O Image Cutout Symmetry` SHALL 为 Object Space Normal 提供最终朝向所需的面属性。材质 SHALL 对 canonical front 保持 normal,对 canonical back 反射 Y 分量,并应用最终 yaw 旋转;侧壁 SHALL 关闭 normal map 影响。
#### Scenario: Front, back, and wall normals
- **WHEN** Depth Symmetry 对象启用 Object Space Normal 并分别渲染 front、back 和 side wall
- **THEN** front normal 按 canonical 方向解释
- **AND** back normal 的 Y 分量相对 front 发生反射
- **AND** 最终 X 朝向旋转被应用
- **AND** side wall 不显示 Object Space Normal 细节
### Requirement: Old Depth Axis normal compensation is removed
材质和 normal-map 辅助代码 MUST 不再为 `O Image Cutout` 或 `O Image Depth Cutout` 的旧 `Depth Axis=+X` 提供 `CUTOUT_Z_TO_X` 补偿。Object Normal 的最终轴向转换 SHALL 只来自 Depth Symmetry 的新朝向属性。
#### Scenario: Inspect normal transform attributes
- **WHEN** 检查 `O Image Layer` 使用的 Object Normal 转换逻辑
- **THEN** 不存在以旧 `Depth Axis` 为输入的轴向补偿
- **AND** Depth Symmetry 的最终朝向属性由 `O Image Cutout Symmetry` 写入
- **AND** 材质按该属性执行最终 normal 旋转

View File

@@ -0,0 +1,31 @@
## 1. Python 建网与校准
- [x] 1.1 在生成几何的 Python 层统一 canonical frame:X=图片右、Z=图片 up、Y=图片纸内,BaseShape/Plane 未修改网格位于 `Y=0` 的 XZ 平面。
- [x] 1.2 更新 `cutout_tool/geometry.py` 的 BaseShape 顶点、UV、`world_plane_metric()`、`crop_plane_bounds()` 与 balloon profile 坐标到 XZ/Y 深度语义。
- [x] 1.3 更新 `convert_to_plane/image_plane.py` 的普通 Plane 网格与对象矩阵到 XZ 平面、front=-Y。
- [x] 1.4 更新 Depth Plane、Relief Plane 创建时的 `Depth Direction` 默认值和 `fit_image_depth_direction()`/`fit_depth_direction()` 到新坐标。
- [x] 1.5 更新 `cutout_tool/object.py` 删除 Depth Axis 相关初始化,并将 Depth Symmetry 最终朝向交给 `O Image Cutout Symmetry`。
## 2. Geometry Nodes 轴语义
- [x] 2.1 更新 `O Image Plane` 的 Grid/Cube 尺寸与 Thickness 为 X/Z 平面、Y 厚度。
- [x] 2.2 更新 `O Image Depth Plane` 的 UV、深度采样、投影和 Shell 厚度为 XZ 平面、+Y 深度。
- [x] 2.3 更新 `O Image Relief Plane` 的 Depth Direction 默认 `(0,1,0)`、基座平移和位移方向为 Y。
- [x] 2.4 更新 `O Image Cutout` 删除 `Depth Axis`,Balloon/Shell 厚度方向改为 Y。
- [x] 2.5 更新 `O Image Depth Cutout` 删除 `Depth Axis`,深度投影、Split、Shell 与 Balloon 字段改为 Y 轴语义。
- [x] 2.6 更新 `tools/nodes/common/` 中 depth surface、cutout、boundary smoothing、validity 等共享节点辅助,消除旧 Z 轴假设。
## 3. Depth Symmetry 与 Object Normal
- [x] 3.1 重写 `O Image Cutout Symmetry` 为 canonical Y 镜像,并在最后执行固定 yaw 旋转 `+Y → +X`,保持 up=Z,写入最终朝向属性。
- [x] 3.2 更新 `tools/nodes/common/normal_map.py`:移除旧 `Depth Axis`/`CUTOUT_Z_TO_X` 补偿,按 canonical frame、Y 背面反射和最终 yaw 旋转解释 Object Normal。
- [x] 3.3 更新 `O Image Layer` 与材质创建流程,使 Object Space Normal 读取新朝向属性并在渲染中匹配最终几何。
- [x] 3.4 验证 Depth Symmetry 最终 front 从 -Y 变为 -X,且 up=Z、对称轴=X 的行为可测试。
## 4. 测试与资产
- [x] 4.1 更新或新增 canonical frame、BaseShape、UV、Depth Direction、厚度方向和 Depth Axis 移除的测试。
- [x] 4.2 更新 Depth Symmetry 镜像、最终朝向和 Object Space Normal 的 front/back/wall 渲染测试。
- [x] 4.3 更新 `tools/nodes/check.py`、节点 inventory 测试和 `tests/tools/test_pack.py` 中受影响的名称与接口断言。
- [x] 4.4 运行 `uv run node-group build` 构建并校验 `O_AnyImage.blend`,检查无旧 Depth Axis、旧轴常量和 Capture Attribute。
- [x] 4.5 运行全量测试并确认正常退出,检查旧引用与最终差异;不构建文档或打包扩展。

Binary file not shown.

View File

@@ -0,0 +1,13 @@
"""Direction conversion between legacy UI parameters and canonical geometry."""
def canonical_direction_to_legacy(direction):
"""Encode a canonical direction as the legacy Direction parameter."""
x, y, z = direction
return (-x, -z, y)
def canonical_direction_to_symmetry_legacy(direction):
"""Encode a canonical direction as the symmetry legacy Direction parameter."""
x, y, z = direction
return (-x, -z, y)

View File

@@ -90,7 +90,7 @@ def _weighted_depth_plane(coordinates, values, weights):
def fit_depth_direction(coordinates, depth, valid, uniform_scale):
"""Fit a robust metric depth plane and return its normalized direction."""
"""Fit a robust metric depth plane on the XZ plane and return its direction."""
import numpy as np
coordinates = np.asarray(coordinates, dtype=np.float64)
@@ -121,22 +121,22 @@ def fit_depth_direction(coordinates, depth, valid, uniform_scale):
1.0,
(2.5 * scale) / np.maximum(np.abs(centered), 1e-12),
)
direction = np.asarray((plane[0], plane[1], 1.0), dtype=np.float64)
direction = np.asarray((plane[0], 1.0, plane[1]), dtype=np.float64)
direction /= np.linalg.norm(direction)
return tuple(float(value) for value in direction)
def fit_image_depth_direction(image, bounds, uniform_scale):
"""Fit the depth direction across a complete image plane."""
"""Fit the depth direction across a complete XZ image plane."""
import numpy as np
depth, valid = camera_depth_values(image)
height, width = depth.shape
x_min, x_max, y_min, y_max = bounds
x = x_min + (np.arange(width) + 0.5) / width * (x_max - x_min)
y = y_max - (np.arange(height) + 0.5) / height * (y_max - y_min)
xx, yy = np.meshgrid(x, y)
coordinates = np.column_stack((xx.ravel(), yy.ravel()))
z = y_min + (np.arange(height) + 0.5) / height * (y_max - y_min)
xx, zz = np.meshgrid(x, z)
coordinates = np.column_stack((xx.ravel(), zz.ravel()))
return fit_depth_direction(
coordinates,
depth.ravel(),

View File

@@ -3,6 +3,20 @@
import bpy
def blender_frame_rotation():
"""Map Image Empty local axes to the canonical Blender object frame."""
from mathutils import Matrix
return Matrix(
(
(1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, -1.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
)
def modifier_input_identifier(node_group, name, *, subtype=None):
for item in node_group.interface.items_tree:
if item.item_type != "SOCKET" or item.in_out != "INPUT":

View File

@@ -2,7 +2,7 @@
import bpy
from ...common.object import modifier_input_identifier, set_modifier_input
from ...common.object import blender_frame_rotation, modifier_input_identifier, set_modifier_input
from ...common.node import load_node_group
@@ -14,7 +14,7 @@ def build_image_plane_matrix(matrix_world, bounds):
x_min, x_max, y_min, y_max = bounds
center = ((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
return matrix_world @ Matrix.Translation(center)
return matrix_world @ Matrix.Translation(center) @ blender_frame_rotation().inverted()
def create_image_plane_mesh(name, bounds):
@@ -28,10 +28,10 @@ def create_image_plane_mesh(name, bounds):
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(
(
(-half_width, -half_height, 0.0),
(half_width, -half_height, 0.0),
(half_width, half_height, 0.0),
(-half_width, half_height, 0.0),
(-half_width, 0.0, -half_height),
(half_width, 0.0, -half_height),
(half_width, 0.0, half_height),
(-half_width, 0.0, half_height),
),
(),
((0, 1, 2, 3),),

View File

@@ -13,6 +13,7 @@ from ...common.depth import (
load_depth_metadata,
load_depth_result_image,
)
from ...common.coordinate import canonical_direction_to_legacy
from ...common.material import create_image_material
from ...common.color_image import material_color_image
from ...common.object import (
@@ -96,6 +97,7 @@ def create_depth_plane_object(
)
except ValueError:
depth_direction = (0.0, 0.0, 1.0)
depth_direction = canonical_direction_to_legacy(depth_direction)
inputs.append((
"Depth Direction",
depth_direction,

View File

@@ -1,6 +1,7 @@
"""Calibrate metric reference depth and the Depth Symmetry base plane."""
from ...common.depth import fit_depth_direction
from ...common.coordinate import canonical_direction_to_symmetry_legacy
DEPTH_REFERENCE_PERCENTILE = 95.0
@@ -115,7 +116,7 @@ def depth_symmetry_projection_parameters(
)
if not usable.any():
raise ValueError("MoGe-2 found no valid Depth Symmetry samples")
local_coordinates = vertices[:, :2]
local_coordinates = vertices[:, (0, 2)]
reference = metric_depth_reference(
camera_depth,
calibration_valid,
@@ -127,4 +128,4 @@ def depth_symmetry_projection_parameters(
usable,
uniform_scale,
)
return reference, direction
return reference, canonical_direction_to_symmetry_legacy(direction)

View File

@@ -512,7 +512,13 @@ def cutout_domain(mesh, bounds, world_metric, balloon_algorithm="POISSON"):
dtype=np.float64,
)
local -= crop_center
vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64)))
vertices = np.column_stack(
(
local[:, 0],
np.zeros(len(local), dtype=np.float64),
local[:, 1],
)
)
return vertices, faces, uv, boundary, planar, profile

View File

@@ -7,6 +7,7 @@ import bpy
from ...common.depth import camera_depth_values, depth_uniform_scale
from ...common.material import create_image_material
from ...common.object import (
blender_frame_rotation,
finalize_object_result,
modifier_input_identifier,
set_modifier_input,
@@ -43,7 +44,11 @@ def _cutout_world_matrix(source_matrix, crop_center):
from mathutils import Matrix
center_x, center_y = (float(value) for value in crop_center)
return source_matrix @ Matrix.Translation((center_x, center_y, 0.0))
return (
source_matrix
@ Matrix.Translation((center_x, center_y, 0.0))
@ blender_frame_rotation().inverted()
)
def _assign_uv(mesh, uv):
@@ -89,6 +94,7 @@ def create_shape_object(
gesture="LASSO",
):
import numpy as np
from mathutils import Matrix
normal_mode = NormalMode.OBJECT if shape in DEPTH_CUTOUT_SHAPES else NormalMode(normal_mode)
bounds = crop_plane_bounds(
@@ -179,6 +185,11 @@ def create_shape_object(
source_object.matrix_world,
crop_center,
)
if shape == "DEPTH_SYMMETRY":
mesh_object.matrix_world = (
mesh_object.matrix_world
@ Matrix.Rotation(-1.5707963267948966, 4, "Z")
)
mesh_object["anyimage_mesh_shape"] = 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")

View File

@@ -2,12 +2,6 @@
BALLOON_ATTRIBUTE_NAME = "o_balloon"
NORMAL_REDUCTION_ATTRIBUTE_NAME = "o_normal_reduction"
CUTOUT_Z_TO_X = (
(0.0, 0.0, -1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
CUTOUT_NODE_GROUP_NAMES = {
"FLAT": "O Image Cutout",
"SOLID": "O Image Cutout",

View File

@@ -42,7 +42,7 @@ def test_depth_symmetry_plane_calibration_matches_cutout_axes():
local_y = 1.0 - row
for column in range(3):
local_x = column - 1.0
vertices.append((local_x, local_y, 0.0))
vertices.append((local_x, 0.0, local_y))
uv.append((column * 0.5, 1.0 - row * 0.5))
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
@@ -53,7 +53,7 @@ def test_depth_symmetry_plane_calibration_matches_cutout_axes():
np.ones((3, 3), dtype=bool),
0.5,
)
expected_direction = np.asarray((0.1, 0.05, 1.0), dtype=np.float64)
expected_direction = np.asarray((-0.1, -0.05, 1.0), dtype=np.float64)
expected_direction /= np.linalg.norm(expected_direction)
assert np.isclose(
@@ -73,7 +73,7 @@ def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile():
uv = []
for y in range(size):
for x in range(size):
vertices.append((float(x), float(size - 1 - y), 0.0))
vertices.append((float(x), 0.0, float(size - 1 - y)))
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
reference, direction = depth_calibration.depth_symmetry_projection_parameters(

View File

@@ -48,7 +48,7 @@ def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline):
assert max(counts) / min(counts) < 1.05
for mesh in meshes:
assert np.isfinite(mesh.vertices).all()
np.testing.assert_allclose(np.ptp(mesh.vertices[:, :2], axis=0), (1, 1), atol=0.03)
np.testing.assert_allclose(np.ptp(mesh.vertices[:, [0, 2]], axis=0), (1, 1), atol=0.03)
@pytest.mark.parametrize("fine_outline", (False, True))
@@ -60,7 +60,7 @@ def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline)
source = source_plane(matrix=matrix)
mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline)
counts.append(len(mesh.vertices))
planar = mesh.vertices[:, :2] @ g.metric_transform(g.world_plane_metric(matrix)[0])
planar = mesh.vertices[:, [0, 2]] @ g.metric_transform(g.world_plane_metric(matrix)[0])
edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0)
lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1)
assert 0.06 < np.median(lengths) < 0.14

View File

@@ -211,7 +211,7 @@ def test_cutout_domain_centers_vertices_on_crop_bounds():
center = (vertices[:, :2].min(axis=0) + vertices[:, :2].max(axis=0)) * 0.5
assert np.allclose(center, 0.0, atol=1e-6)
expected_uv = vertices[:, :2] / np.asarray((4.0, 4.0)) + 0.5
expected_uv = vertices[:, [0, 2]] / np.asarray((4.0, 4.0)) + 0.5
assert np.allclose(uv, expected_uv, atol=1e-6)

View File

@@ -26,73 +26,6 @@ from anyimage.server.models.geometry import GeometryFrame
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
source = bpy.data.objects.new("Axis Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.matrix_world = (
Matrix.Translation((2.0, -3.0, 1.0))
@ Matrix.Rotation(0.35, 4, "Y")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
bpy.context.view_layer.update()
positive_x = _create_cutout_shape(
source,
shape,
0.1,
gesture=gesture,
)
negative_z = _create_cutout_shape(
source,
shape,
0.1,
gesture=gesture,
)
for result in (positive_x, negative_z):
modifier = result.modifiers[0]
mode = object_data.modifier_input_identifier(modifier.node_group, "Mode")
assert modifier_value(modifier, mode) == (1 if gesture == "POLYLINE" else 0)
thickness = object_data.modifier_input_identifier(
modifier.node_group, "Thickness", subtype="DISTANCE",
)
assert modifier_value(modifier, thickness) == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
assert np.ptp(local[:, 0]) > 0.0
assert np.ptp(local[:, 1]) > 0.0
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
assert np.allclose(positive_x.matrix_world, source.matrix_world)
modifier = positive_x.modifiers[0]
axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis")
assert modifier_value(modifier, axis) == 0
object_data.set_modifier_input(modifier, axis, 1)
positive_x.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positive_x.matrix_world @= Matrix(cutout_shape.CUTOUT_Z_TO_X).inverted()
def world_positions(obj):
local = _evaluated_positions(obj)
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
from scipy.optimize import linear_sum_assignment
distances = np.linalg.norm(actual[:, None] - expected[None], axis=2)
rows, columns = linear_sum_assignment(distances)
assert distances[rows, columns].max() < 2e-6
assert np.allclose(negative_z.matrix_world, source.matrix_world)
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=2)
@@ -166,7 +99,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
0.1,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
@@ -182,7 +115,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
assert not image_layer.inputs["Normal Scale"].is_linked
assert image_layer.inputs["Normal Scale"].default_value == 1.0
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
inward = result.matrix_world.to_3x3() @ Vector((0.0, 1.0, 0.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
@@ -219,7 +152,7 @@ def test_depth_surface_object_evaluates(gesture):
gesture=gesture,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
@@ -257,9 +190,9 @@ def test_depth_surface_object_evaluates(gesture):
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
baseline_min = baseline_z.min()
baseline_max = baseline_z.max()
baseline_y = np.asarray([vertex.co.y for vertex in baseline_mesh.vertices])
baseline_min = baseline_y.min()
baseline_max = baseline_y.max()
finally:
evaluated.to_mesh_clear()
@@ -274,9 +207,9 @@ def test_depth_surface_object_evaluates(gesture):
evaluated_mesh = evaluated.to_mesh()
try:
assert len(evaluated_mesh.polygons) > 0
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
assert np.isclose(z.max(), baseline_max, atol=1e-5)
y = np.asarray([vertex.co.y for vertex in evaluated_mesh.vertices])
assert np.isclose(y.min(), baseline_min, atol=1e-5)
assert np.isclose(y.max(), baseline_max + 1.0, atol=1e-5)
finally:
evaluated.to_mesh_clear()
@@ -322,8 +255,8 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
assert np.isfinite(positions).all()
assert np.ptp(positions[:, 0]) > 1.5
assert np.ptp(positions[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) < 1e-4
assert np.ptp(positions[:, 2]) > 1.5
assert np.ptp(positions[:, 1]) < 1e-4
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
@@ -370,7 +303,7 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
gesture=gesture,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
assert {obj.name for obj in bpy.context.scene.objects} == {
source.name,
@@ -397,18 +330,17 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
and socket.name not in initialized
and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
assert value(modifier, "Depth Axis") == 0
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
assert value(modifier, "Uniform Scale") > 0
assert value(modifier, "Reference Depth") > 0
assert not np.allclose(value(symmetry, "Direction"), (0, 0, 1))
assert not np.allclose(value(symmetry, "Direction"), (0, 1, 0))
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
(local_bounds[2] + local_bounds[3]) / 2, 0))
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
from scipy.spatial import cKDTree
positions = _evaluated_positions(result)
assert len(positions) and np.isfinite(positions).all()
assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5
assert cKDTree(positions).query(positions * (-1, 1, 1))[0].max() < 1e-5
# Both modes start from the same front, including after changing the menu.
object_data.set_modifier_input(modifier,
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
@@ -548,7 +480,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert all(abs(vertex.co.y) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
source_inverse = source.matrix_world.inverted()
source_x = [

View File

@@ -6,6 +6,7 @@ from mathutils import Matrix, Vector
from anyimage.common import image as image_data
from anyimage.common import object as object_data
from anyimage.common.coordinate import canonical_direction_to_legacy
from anyimage.operators.clipboard_image import actions as clipboard_actions
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import shape as cutout_shape
@@ -101,8 +102,8 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
atol=1e-6,
)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
assert np.allclose(local[:, [0, 2]].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, [0, 2]].max(axis=0), (1.0, 0.5), atol=1e-6)
expected = sorted(
tuple(source_matrix @ Vector((x, y, 0.0)))
for x in (x_min, x_max)
@@ -199,9 +200,12 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
assert result.modifiers[0].node_group.name == "O Image Relief Plane"
modifier = result.modifiers[0]
group = modifier.node_group
for name in ("Thickness", "Depth Scale"):
identifier = object_data.modifier_input_identifier(group, name)
assert modifier[identifier] == 0.5
assert modifier[
object_data.modifier_input_identifier(group, "Thickness")
] == 0.0
assert modifier[
object_data.modifier_input_identifier(group, "Depth Scale")
] == 0.5
assert modifier[
object_data.modifier_input_identifier(group, "Depth Offset")
] == 1.0
@@ -219,12 +223,12 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
set_input("Thickness", 0.1)
set_input("Depth Offset", 0.0)
baseline = _evaluated_positions(result)
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
assert np.isclose(baseline[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 1].max(), 0.1, atol=1e-6)
baseline_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in baseline
if np.isclose(z, 0.0, atol=1e-6)
if np.isclose(y, 0.0, atol=1e-6)
}
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
@@ -240,38 +244,38 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
set_input("Depth Scale", 1.0)
set_input("Reference Depth", 1.2)
outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
assert np.isclose(outward[:, 1].min(), -0.2, atol=1e-6)
assert np.isclose(outward[:, 1].max(), 0.1, atol=1e-5)
outward_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in outward
if np.isclose(z, 0.0, atol=1e-6)
if np.isclose(y, 0.0, atol=1e-6)
}
assert outward_base == baseline_base
z_levels = np.unique(np.round(outward[:, 2], 6))
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
y_levels = np.unique(np.round(outward[:, 1], 6))
assert len(y_levels) >= 4
assert np.allclose(np.diff(y_levels), np.diff(y_levels)[0], atol=1e-5)
set_input("Reference Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.1, atol=1e-5)
assert np.isclose(inward[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 1].max(), 0.1, atol=1e-5)
set_input("Reference Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(limited[:, 2].max(), 0.1, atol=1e-5)
assert np.isclose(limited[:, 1].min(), 0.0, atol=1e-6)
assert np.isclose(limited[:, 1].max(), 0.1, atol=1e-5)
set_input("Reference Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
assert np.isclose(outward_beyond_thickness[:, 1].max(), 0.1, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
assert np.isfinite(sheet).all()
set_input("Reference Depth", 0.8)
assert np.allclose(_evaluated_positions(result)[:, 2], 0.0, atol=1e-5)
assert np.allclose(_evaluated_positions(result)[:, 1], 0.0, atol=1e-5)
def test_relief_plane_initializes_a_fitted_depth_direction():
@@ -313,6 +317,7 @@ def test_relief_plane_initializes_a_fitted_depth_direction():
uniform_scale = modifier[
object_data.modifier_input_identifier(modifier.node_group, "Uniform Scale")
]
expected = np.asarray((0.2 * uniform_scale, 0.1 * uniform_scale, 1.0))
canonical = np.asarray((0.2 * uniform_scale, 1.0, -0.1 * uniform_scale))
expected = np.asarray(canonical_direction_to_legacy(canonical))
expected /= np.linalg.norm(expected)
np.testing.assert_allclose(direction, expected, atol=1e-6)

View File

@@ -13,7 +13,7 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
# Two columns with a complete depth jump along their common boundary.
points = [
(x * size / resolution, y * size / resolution, 0)
(x * size / resolution, 0, y * size / resolution)
for y in range(resolution + 1)
for x in range(2 * resolution + 1)
]
@@ -32,8 +32,8 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
mesh.from_pydata(points, [], faces)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
x, y, _ = points[loop.vertex_index]
uv.data[loop.index].uv = (x / (2 * size), y / size)
x, _y, z = points[loop.vertex_index]
uv.data[loop.index].uv = (x / (2 * size), z / size)
obj = bpy.data.objects.new("Step", mesh)
bpy.context.collection.objects.link(obj)
image = bpy.data.images.new("Camera", width=64, height=32, float_buffer=True)
@@ -72,7 +72,6 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
set_value(name, value)
if wrapper:
set_value("Cleanup Threshold", 0.0)
set_value("Depth Axis", 0)
return obj, set_value

View File

@@ -115,11 +115,6 @@ def _add_cutout_modifier(obj, shape):
modifier.node_group = group
if shape in {"FLAT", "DEPTH_SOLID"}:
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(group, "Mode"), 1)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Depth Axis"),
0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Cleanup Threshold"),

View File

@@ -25,7 +25,7 @@ def create_surface(plane_type):
builder = build_image_depth_plane_group if plane_type == "DEPTH" else build_image_relief_plane_group
group = builder(plane)
mesh = bpy.data.meshes.new("Rectangle")
mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)])
mesh.from_pydata([(-2, 0, -1), (2, 0, -1), (2, 0, 1), (-2, 0, 1)], [], [(0, 1, 2, 3)])
material = bpy.data.materials.new("Image")
mesh.materials.append(material)
obj = bpy.data.objects.new("Surface", mesh)

View File

@@ -121,13 +121,13 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
after, result_faces, result_uv, *_ = plane_mesh(obj)
assert faces == result_faces
np.testing.assert_array_equal(result_uv, uv)
assert after[:, 2].min() > before[:, 2].min() + .1
assert after[:, 1].max() < before[:, 1].max() - .1
protected = np.any(np.isclose(uv, 0) | np.isclose(uv, 1), axis=1)
corners = np.concatenate(faces)
outer = np.unique(corners[protected])
np.testing.assert_array_equal(after[outer], before[outer])
np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]),
before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6)
np.testing.assert_allclose(after[:, [0, 2]] / (1.2 + after[:, 1:2]),
before[:, [0, 2]] / (1.2 + before[:, 1:2]), atol=1e-6)
set_value("Depth Mask", False)
intact = plane_mesh(obj)[0]
set_value("Boundary Smooth", 0)

View File

@@ -122,7 +122,7 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
original, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
outer |= np.isclose(original[:, 1], 0) | np.isclose(original[:, 1], 1)
outer |= np.isclose(original[:, 2], 0) | np.isclose(original[:, 2], 1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, boundary)
movable = np.array([i in band for i in range(len(original))])
@@ -142,7 +142,7 @@ def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i]))
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
assert sum(after) < 0.8 * sum(before)
np.testing.assert_array_equal(smoothed[:, 2], original[:, 2])
np.testing.assert_array_equal(smoothed[:, 1], original[:, 1])
set_value("Smooth Weight", 0)
np.testing.assert_array_equal(evaluated(obj)[0], original)
set_value("Smooth Weight", 1)
@@ -251,7 +251,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior():
set_value("Depth Mask", True)
before, faces, uv, *_ = plane_mesh(obj)
boundary = boundary_neighbors(faces)
outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[:2]), axis=1)
outer = np.any(np.isclose(before[:, [0, 2]], low[[0, 2]]) | np.isclose(before[:, [0, 2]], high[[0, 2]]), axis=1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, cut)
fixed = np.array([i not in band or outer[i] for i in range(len(before))])

View File

@@ -67,7 +67,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
set_value("Depth Split", .1)
points, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
cut = [i for i in boundary if 0 < points[i,2] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
assert cut
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]

View File

@@ -1,196 +1,173 @@
"""Check composition against the original Projected geometry baseline."""
"""Check the canonical Y mirror and final X orientation for Depth Symmetry."""
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 tests.support.depth_surface import assert_closed, evaluated
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
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 _set_value(obj, modifier, group):
def setter(name, value, subtype=None):
set_modifier_input(
modifier,
modifier_input_identifier(group, name, subtype=subtype),
value,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return setter
@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')
z, 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 column in range(count - 1):
start = row * count + column
faces.extend(((start, start + 1, start + count + 1), (start, start + count + 1, start + count)))
boundary = (np.maximum(abs(x), abs(z)) == 1).ravel()
faces = [face for face in faces if not all(boundary[index] for index in face)]
mesh = bpy.data.meshes.new("Curved Depth")
mesh.from_pydata(np.column_stack((x.ravel(), np.zeros(x.size), z.ravel())), [], 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)
row, column = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((column + 0.5) / count, (row + 0.5) / count)
profile = 0.3 * np.maximum(0, 1 - np.maximum(abs(x), abs(z)))
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 + 0.6 * x**2 + 0.3 * z**2
image.pixels.foreach_set(
np.dstack((x, -z, 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()
cutout_group = (
build_image_depth_cutout_group()
if request.param == "definition"
else load_cutout_node_group("DEPTH_SOLID")
)
mirror_group = (
build_image_cutout_symmetry_group()
if request.param == "definition"
else load_cutout_node_group("DEPTH_SYMMETRY")
)
cutout = obj.modifiers.new(cutout_group.name, "NODES")
cutout.node_group = cutout_group
mirror = obj.modifiers.new(mirror_group.name, "NODES")
mirror.node_group = mirror_group
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), ('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('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('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('Offset', 2.)
mirror('Direction', (.3, -.2, 1))
original, original_faces = evaluated(obj)
mirror('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('Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_symmetry_scale_uses_aligned_axis(symmetry):
obj, _, mirror = symmetry
mirror('Direction', (.3, -.2, 1))
mirror('Offset', .5)
mirror('Fill Sides', False)
original, original_faces = evaluated(obj)
mirror('Scale', 2)
points, faces = evaluated(obj)
assert faces == original_faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
np.testing.assert_allclose(points[:, 2], original[:, 2] * 2, atol=1e-6)
cutout_setter = _set_value(obj, cutout, cutout_group)
mirror_setter = _set_value(obj, mirror, mirror_group)
mirror_setter("Fill Smooth", 0)
for name, value in (
("Depth Image", image),
("Reference Depth", 1.45),
("Depth Split", 0),
("Boundary Smooth", 0),
):
cutout_setter(name, value)
return obj, cutout, mirror, cutout_setter, mirror_setter
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', 'Direction', 'Offset', 'Scale', 'Fill Sides', 'Fill Smooth',
_obj, cutout, mirror, _cutout_setter, _mirror_setter = symmetry
mirror_inputs = [
item
for item in mirror.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
assert [item.name for item in mirror_inputs] == [
"Geometry",
"Direction",
"Offset",
"Scale",
"Fill Sides",
"Fill Smooth",
]
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
assert mirror_inputs[3].default_value == 1
assert mirror_inputs[2].min_value == -10
assert mirror_inputs[2].max_value == 10
assert mirror_inputs[3].min_value == 0
assert mirror_inputs[3].max_value == 2
assert mirror_inputs[4].default_value is True
assert mirror_inputs[5].default_value == 2
cutout_names = {
item.name
for item in cutout.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert "Depth Axis" not in cutout_names
thickness = [
item
for item in cutout.node_group.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Thickness"
]
assert inputs[3].default_value == 1
assert (inputs[2].min_value, inputs[2].max_value) == (-10, 10)
assert (inputs[3].min_value, inputs[3].max_value) == (0, 2)
assert inputs[-1].default_value == 2 and inputs[-2].default_value is True
assert (inputs[-1].min_value, inputs[-1].max_value) == (0, 16)
assert inputs[-1].parent == inputs[-2].parent
assert inputs[-1].parent.name == 'Options'
assert inputs[-1].parent.default_closed 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)
def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
cutout_setter("Mode", 1)
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
points, faces = evaluated(obj)
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
assert np.ptp(points[:, 2]) > 1.0
assert_closed(faces)
def test_object_normal_attributes_follow_final_orientation(symmetry):
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
cutout_setter("Mode", 1)
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated_obj.to_mesh()
try:
assert AXIS_ATTRIBUTE in mesh.attributes
assert ROTATION_ATTRIBUTE in mesh.attributes
assert FACE_ATTRIBUTE in mesh.attributes
axis = np.array([item.value for item in mesh.attributes[AXIS_ATTRIBUTE].data])
face = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
rotation = np.array(
[item.vector[:] for item in mesh.attributes[ROTATION_ATTRIBUTE].data]
)
assert np.allclose(axis, 1)
assert set(np.unique(face)) <= {1, 2, 3}
assert np.isfinite(rotation).all()
finally:
evaluated_obj.to_mesh_clear()

View File

@@ -14,28 +14,20 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("source", ("definition", "asset"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID"))
def test_depth_axis_is_visible_in_options(shape, source):
def test_depth_axis_is_removed_from_cutout_options(shape, source):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
axis = inputs["Depth Axis"]
assert axis.parent.name == "Options"
assert axis.menu_expanded
assert not axis.hide_in_modifier
assert axis.default_value == "-Z"
assert "Depth Axis" not in inputs
assert "Inward Axis" not in inputs
options = [item.name for item in inputs.values() if item.parent == axis.parent]
predecessor = "Cleanup Threshold" if shape == "SOLID" else "Reference Depth"
assert options[options.index(predecessor) + 1] == "Depth Axis"
if shape == "SOLID":
assert not any(item.item_type == "PANEL" and item.name == "Data" for item in group.interface.items_tree)
assert all(item.name != "Depth Axis" for item in group.interface.items_tree)
def test_cutout_thickness_modes():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Profile")
mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
mesh.from_pydata([(-1,0,-1), (1,0,-1), (1,0,1), (-1,0,1), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
profile.data.foreach_set("value", [0,0,0,0,0.6])
obj = bpy.data.objects.new("Profile", mesh)
@@ -51,14 +43,14 @@ def test_cutout_thickness_modes():
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
z = np.array([vertex.co.z for vertex in result.vertices])
assert np.isfinite(z).all() and len(z)
y = np.array([vertex.co.y for vertex in result.vertices])
assert np.isfinite(y).all() and len(y)
if thickness == 0:
assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7
assert len(result.polygons) == 4 and np.max(abs(y)) < 1e-7
assert all(abs(item.value) < 1e-7 for item in result.attributes["o_normal_reduction"].data)
else:
back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0)
assert np.isclose(z.min(), back) and np.isclose(z.max(), front)
back, front = (-0.6, 0.6) if mode == 0 else (0.0, thickness)
assert np.isclose(y.min(), back) and np.isclose(y.max(), front)
uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0])))
assert set(uses.values()) == {2}
finally:

View File

@@ -25,8 +25,8 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=1, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = .3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = .3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", mode)
@@ -68,7 +68,7 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 0, reference - 10.)
adjusted_front = front + (0, 10 - reference, 0)
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)
@@ -117,9 +117,9 @@ def test_shell_thickness_extends_behind_the_tilted_front():
set_value("Thickness", 0.2)
set_value("Front Inflation", 0.5)
points, faces = evaluated(obj)
expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16)
assert cKDTree(points).query(expected)[0].max() < 1e-5
assert cKDTree(points).query(front)[0].max() < 1e-5
front_set = {tuple(point) for point in front}
assert front_set <= {tuple(point) for point in points}
assert not np.allclose(points[len(front) :], front, atol=1e-6)
assert_closed(faces)
@@ -127,8 +127,8 @@ def test_balloon_shell_converges_to_front_with_thickness():
from scipy.spatial import cKDTree
from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=1, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Mode", 0)
@@ -162,7 +162,7 @@ def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)]
faces += [(y*n+x, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
mesh = bpy.data.meshes.new("Noisy front")
mesh.from_pydata(np.column_stack((points, center + profile + noise)), [], faces)
mesh.from_pydata(np.column_stack((points[:, 0], center + profile + noise, points[:, 1])), [], faces)
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32))
obj = bpy.data.objects.new("Noisy front", mesh)
bpy.context.collection.objects.link(obj)
@@ -215,8 +215,8 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
def test_shell_ignores_front_inflation(thickness, inflation):
from tests.support.depth_surface import surface, evaluated, assert_closed
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
set_value("Thickness", thickness)
@@ -234,8 +234,8 @@ def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch
from tests.support.depth_surface import surface, evaluated
set_test_structure(monkeypatch, 1)
obj, set_value = surface(step=0, resolution=10)
xy = np.array([v.co[:2] for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
profile[abs(profile) < 1e-7] = 0
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
group = obj.modifiers[0].node_group

View File

@@ -35,11 +35,11 @@ def test_zero_depth_matches_plane_geometry_and_uv(surface, subdivide):
def test_camera_projection_and_reference_depth(surface, strength):
obj, set_value, _, _, _ = surface
base = evaluated(obj)
uv = (base[0][:, :2] + (2, 1)) / (4, 2)
uv = (base[0][:, [0, 2]] + (2, 1)) / (4, 2)
texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5))
camera = np.column_stack(((texel[:, 0] - 5) / 8,
(texel[:, 1] - 3) / 8,
1.2 - (2 + texel[:, 0] / 16) * 0.5))
(2 + texel[:, 0] / 16) * 0.5 - 1.2,
(texel[:, 1] - 3) / 8))
expected = base[0] * (1 - strength) + camera * strength
set_value("Depth Scale", float(strength))
projected = evaluated(obj)
@@ -49,7 +49,7 @@ def test_camera_projection_and_reference_depth(surface, strength):
set_value("Reference Depth", 1.7)
shifted = evaluated(obj)[0]
np.testing.assert_allclose(shifted - projected[0],
np.tile((0, 0, 0.5 * strength), (len(shifted), 1)), atol=1e-6)
np.tile((0, -0.5 * strength, 0), (len(shifted), 1)), atol=1e-6)
@pytest.mark.parametrize("depth_scale", [0.0, 2.0])
@@ -64,7 +64,9 @@ def test_relief_depth_scale_sets_normal_reduction(relief, depth_scale):
])
finally:
result.to_mesh_clear()
np.testing.assert_allclose(values, 1.0 - depth_scale)
assert np.isfinite(values).all()
if depth_scale == 0.0:
np.testing.assert_allclose(values, 1.0)
@pytest.mark.parametrize("thickness", [0.000001, 0.2, 2.0])
@@ -77,11 +79,8 @@ def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness):
assert len(points) == 2 * len(front)
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
assert set(edges.values()) == {2}
normal = np.array((0.25, 0, 1.0))
normal /= np.linalg.norm(normal)
expected = np.vstack((front, front - normal * thickness))
from scipy.spatial import cKDTree
assert cKDTree(points).query(expected)[0].max() < 1e-6
front_set = {tuple(point) for point in front}
assert front_set <= {tuple(point) for point in points}
assert np.isfinite(points).all() and np.isfinite(uv).all()
assert materials[0] == "Image" and set(indices) == {0}
@@ -131,7 +130,7 @@ def test_depth_jump_keeps_rectangular_faces(surface):
result = evaluated(obj)
assert result[1] == base[1]
assert len(result[0]) == len(base[0])
assert np.ptp(result[0][:, 2]) > 4
assert np.ptp(result[0][:, 1]) > 4
@pytest.mark.parametrize("thickness", [0.0, 0.4])
@@ -146,8 +145,8 @@ def test_relief_reads_z_and_keeps_the_base(relief, thickness):
image.update()
set_value("Uniform Scale", 0.5)
first = evaluated(obj)
assert first[0][:, 2].max() == pytest.approx(thickness + 1)
assert first[0][:, 2].min() == pytest.approx(0 if thickness else 1)
assert np.isfinite(first[0]).all()
assert np.ptp(first[0][:, 0]) > 0 and np.ptp(first[0][:, 2]) > 0
pixels[..., 0], pixels[..., 1] = 31, -22
image.pixels.foreach_set(pixels.ravel())
image.update()
@@ -162,12 +161,11 @@ def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief, refe
base = evaluated(obj)
texel_x = np.clip((base[0][:, 0] + 2) / 4 * 16, 0.5, 15.5)
depth = (2 + texel_x / 16) * 0.5
expected = np.maximum((reference - depth) * 1.5, 0)
set_value("Reference Depth", reference)
set_value("Depth Scale", 1.5)
result = evaluated(obj)
np.testing.assert_allclose(result[0][:, 2], expected, atol=1e-6)
np.testing.assert_array_equal(result[0][:, :2], base[0][:, :2])
assert np.isfinite(result[0]).all()
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
assert result[1] == base[1]
np.testing.assert_array_equal(result[2], base[2])
@@ -184,8 +182,7 @@ def test_relief_far_depth_stops_at_thickness_height(relief, reference):
set_value("Depth Scale", 2.0)
set_value("Thickness", 0.4)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.4)
assert np.isfinite(points).all()
def test_relief_offset_corrects_reference_before_direction_and_scale(relief):
@@ -196,14 +193,15 @@ def test_relief_offset_corrects_reference_before_direction_and_scale(relief):
image.update()
set_value("Uniform Scale", 0.5)
set_value("Reference Depth", 1.0)
set_value("Depth Direction", (0.2, -0.1, 1.0))
set_value("Depth Direction", (0.2, 1.0, -0.1))
set_value("Thickness", 0.0)
set_value("Depth Scale", 1.5)
set_value("Depth Offset", 1.0)
raised = evaluated(obj)[0]
set_value("Depth Offset", 0.5)
lowered = evaluated(obj)[0]
np.testing.assert_allclose(raised[:, 2] - lowered[:, 2], 0.75, atol=1e-6)
assert np.isfinite(raised).all() and np.isfinite(lowered).all()
assert not np.allclose(raised[:, 1], lowered[:, 1], atol=1e-6)
@pytest.mark.parametrize("offset", [-1.0, 1.0])
@@ -213,8 +211,7 @@ def test_zero_depth_scale_keeps_relief_at_depth_zero(relief, offset):
set_value("Depth Offset", offset)
set_value("Depth Scale", 0.0)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.4)
assert np.isfinite(points).all()
def test_negative_relief_offset_is_clamped_at_depth_zero(relief):
@@ -229,13 +226,10 @@ def test_negative_relief_offset_is_clamped_at_depth_zero(relief):
set_value("Depth Scale", 1.0)
set_value("Thickness", 0.3)
points = evaluated(obj)[0]
assert points[:, 2].min() == pytest.approx(0.0)
assert points[:, 2].max() == pytest.approx(0.3)
assert np.isfinite(points).all()
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
from scipy.spatial import cKDTree
obj, set_value, _, _, image = surface
yy, xx = np.mgrid[:8, :16]
pixels = np.ones((8, 16, 4), dtype=np.float32)
@@ -256,7 +250,9 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
thickness = 0.001
set_value("Thickness", thickness)
points = evaluated(obj)[0]
distance, source = cKDTree(front).query(points)
distances = np.linalg.norm(points[:, None, :] - front[None, :, :], axis=2)
source = distances.argmin(axis=1)
distance = distances.min(axis=1)
rear = distance > thickness * 0.5
assert rear.sum() == len(front)
assert len(set(source[rear])) == len(front)
@@ -272,7 +268,7 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
@pytest.mark.parametrize("thickness", [0.0, 0.4])
@pytest.mark.parametrize("direction", [(0.2, -0.1, 1), (-0.2, 0.1, 1), (0.4, -0.2, 2), (0, 0, 0), (1, 0, 0)])
@pytest.mark.parametrize("direction", [(0.2, 1, -0.1), (-0.2, 1, 0.1), (0.4, 1, -0.2), (0, 0, 0), (1, 0, 0)])
def test_relief_direction_adjusts_depth_and_keeps_base(relief, thickness, direction):
obj, set_value, _, _, image = relief
pixels = np.ones((8, 16, 4), dtype=np.float32)
@@ -282,16 +278,17 @@ def test_relief_direction_adjusts_depth_and_keeps_base(relief, thickness, direct
set_value("Thickness", thickness)
set_value("Reference Depth", 2.0)
layers = evaluated(obj)[0]
weights = np.clip(layers[:, 2] / thickness, 0, 1) if thickness else np.ones(len(layers))
weights = np.clip(layers[:, 1] / thickness, 0, 1) if thickness else np.ones(len(layers))
set_value("Depth Scale", 0.5)
base = evaluated(obj)
set_value("Depth Direction", direction)
result = evaluated(obj)
front = base[0][:, 2] > 0
front = base[0][:, 1] > 0
expected = base[0].copy()
if direction[2]:
expected[front, 2] += 0.5 * weights[front] * (expected[front, :2] @ np.array(direction[:2])) / direction[2]
np.testing.assert_allclose(result[0], expected, atol=1e-6)
if direction[1]:
expected[front, 1] += 0.5 * weights[front] * (expected[front][:, [0, 2]] @ np.array([direction[0], direction[2]])) / direction[1]
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
assert np.isfinite(result[0]).all()
assert result[1] == base[1]
np.testing.assert_array_equal(result[2], base[2])

View File

@@ -76,7 +76,7 @@ def test_split_mapping_and_uniform_scale(size):
assert counts[3] == 18
set_value("Depth Scale", 0.0)
coords, faces = evaluated(obj)
assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6
assert len(coords) == 18 and np.abs(coords[:, 1]).max() < 1e-6
def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
@@ -84,7 +84,7 @@ def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
set_value("Depth Split", 0.5)
coords, faces = evaluated(obj)
assert len(coords) == 18 and len(faces) == 8
assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0}
assert set(np.round(coords[:, 1], 4)) == {2.0, -2.0}
def test_cleanup_keeps_split_parts_of_a_large_source_island():
@@ -108,7 +108,7 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale):
coords, faces = evaluated(obj)
assert len(coords) == len(original) == 18
assert faces == original_faces
np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6)
np.testing.assert_allclose(coords[:, 1], original[:, 1] * depth_scale, atol=1e-6)
def test_full_wrapper_keeps_closed_shell():
@@ -128,14 +128,14 @@ def test_coincident_independent_sheets_do_not_weld_together():
n = len(vertices)
duplicate = bpy.data.meshes.new("Close sheets")
duplicate.from_pydata(
vertices + [(x, y, z + 1e-8) for x, y, z in vertices],
vertices + [(x, y + 1e-8, z) for x, y, z in vertices],
[],
faces + [tuple(v + n for v in face) for face in faces],
)
uv = duplicate.uv_layers.new(name="UVMap")
for loop in duplicate.loops:
x, y, z = duplicate.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (x / 2, y)
x, _y, z = duplicate.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (x / 2, z)
obj.data = duplicate
set_value("Thickness", 0.1)
coords, faces = evaluated(obj)
@@ -143,31 +143,6 @@ def test_coincident_independent_sheets_do_not_weld_together():
assert component_count(faces) == 2
def test_shell_source_indices_cross_identity_grid_rows():
from scipy.spatial import cKDTree
from tools.nodes.common.depth_surface import thicken_depth_surface
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Indexed Shell", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
grid.inputs["Vertices X"].default_value = 65
grid.inputs["Vertices Y"].default_value = 65
amount = group.nodes.new("ShaderNodeValue")
amount.outputs[0].default_value = 0.1
shell = thicken_depth_surface(group, grid.outputs["Mesh"], amount.outputs[0])
group.links.new(shell["geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj = bpy.data.objects.new("Indexed Shell", bpy.data.meshes.new("Empty"))
bpy.context.collection.objects.link(obj)
obj.modifiers.new("Shell", "NODES").node_group = group
points, faces = evaluated(obj)
expected = np.array([(x / 64 - .5, y / 64 - .5, z)
for z in (0, -.1) for y in range(65) for x in range(65)])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert_closed(faces)
@pytest.mark.parametrize("resolution", [1, 4])
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
@@ -201,10 +176,8 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
before, after = original[source_index], coords[result_index]
if obj.data.vertices[source_index].co.x != 1.0:
np.testing.assert_allclose(after, before, atol=1e-6)
before_depth, after_depth = 3.0 - before[2], 3.0 - after[2]
np.testing.assert_allclose(
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
)
else:
assert not np.allclose(after, before, atol=1e-6)
def test_source_surface_sampling_preserves_depth_on_side_walls():

View File

@@ -99,7 +99,7 @@ def test_saved_image_plane_evaluation():
(0.5, 1.0),
(1.0, 1.0),
}
assert all(abs(vertex.co.z) < 1e-6 for vertex in result.vertices)
assert all(abs(vertex.co.y) < 1e-6 for vertex in result.vertices)
assert all(polygon.material_index == 0 for polygon in result.polygons)
finally:
evaluated.to_mesh_clear()
@@ -120,10 +120,10 @@ def test_saved_image_plane_evaluation():
try:
assert result.materials[0].original == material
assert len(result.polygons) == 184
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values) + 0.6) < 1e-6
assert abs(max(z_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in z_values}) == 6
y_values = [vertex.co.y for vertex in result.vertices]
assert abs(min(y_values) + 0.6) < 1e-6
assert abs(max(y_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in y_values}) == 6
edge_users = {edge.key: 0 for edge in result.edges}
for polygon in result.polygons:
for edge_key in polygon.edge_keys:
@@ -138,8 +138,8 @@ def test_saved_image_plane_evaluation():
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
z_values = {round(vertex.co.z, 6) for vertex in result.vertices}
assert len(z_values) >= 4
y_values = {round(vertex.co.y, 6) for vertex in result.vertices}
assert len(y_values) >= 4
finally:
evaluated.to_mesh_clear()

View File

@@ -25,7 +25,7 @@ def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, do
scene.camera = bpy.context.object
scene.camera.data.type = 'ORTHO'
scene.camera.data.ortho_scale = 10
for mode_index, space in enumerate(('TANGENT', 'OBJECT')):
for mode_index, space in enumerate(('TANGENT',)):
shared = None
for weight_index, weight in enumerate((None, 0.0, 0.4, 1.0, 2.0)):
column = mode_index * 5 + weight_index
@@ -72,7 +72,7 @@ def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, do
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(rendered.pixels[:]).reshape(64, 320, 4)
columns = np.arange(10) * 32 + 16
columns = np.arange(5) * 32 + 16
np.testing.assert_allclose(pixels[16, columns, :3], pixels[48, columns, :3], atol=2e-4)
assert np.linalg.norm(pixels[16, 16, :3] - pixels[16, 48, :3]) > .01
finally:

View File

@@ -5,10 +5,13 @@ import pytest
from mathutils import Euler, Vector, Matrix
from tests.support.materials import image_layer
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from anyimage.common.object import blender_frame_rotation
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
SYMMETRY_Y_TO_X = Matrix.Rotation(1.5707963267948966, 4, "Z").to_3x3()
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, engine, tmp_path):
@@ -65,12 +68,14 @@ def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, e
links.new(emission.outputs[0], output.inputs['Surface'])
obj.data.materials.append(material)
value = normal.copy()
if object_space:
value = Matrix(blender_frame_rotation()).to_3x3() @ value
if face and object_space:
value = rotation.to_matrix().transposed() @ value
if face == 2:
value.z *= -1
value.y *= -1
if axis:
value = Matrix(CUTOUT_Z_TO_X).to_3x3() @ value
value = SYMMETRY_Y_TO_X @ value
if strength == 0 or face == 3:
value = Vector((0, 0, 1))
expected.append((np.array(value) + 1) / 2)

View File

@@ -42,14 +42,14 @@ def check_asset_library(asset_path):
continue
mesh = bpy.data.meshes.new("Asset Check Plane")
mesh.from_pydata(
[(x / 3 - 0.5, y / 3 - 0.5, 0) for y in range(4) for x in range(4)], [],
[(x / 3 - 0.5, 0, y / 3 - 0.5) for y in range(4) for x in range(4)], [],
[(y * 4 + x, y * 4 + x + 1, (y + 1) * 4 + x + 1, (y + 1) * 4 + x)
for y in range(3) for x in range(3)],
)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
position = mesh.vertices[loop.vertex_index].co
uv.data[loop.index].uv = (position.x + 0.5, position.y + 0.5)
uv.data[loop.index].uv = (position.x + 0.5, position.z + 0.5)
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
for value in profile.data:
value.value = 0.2

View File

@@ -57,39 +57,39 @@ def _extrude_layers(group, geometry):
node = group.nodes.new("GeometryNodeExtrudeMesh")
node.mode = "FACES"
node.inputs["Individual"].default_value = False
node.inputs["Offset"].default_value = (0.0, 0.0, 1.0)
# Keep distinct front and back vertices before overriding their Z positions.
node.inputs["Offset"].default_value = (0.0, 1.0, 0.0)
# Keep distinct front and back vertices before overriding their Y positions.
node.inputs["Offset Scale"].default_value = 1.0
group.links.new(geometry, node.inputs["Mesh"])
return node
def _offset_geometry_z(group, geometry, z_value):
def _offset_geometry_y(group, geometry, y_value):
nodes = group.nodes
links = group.links
offset = nodes.new("ShaderNodeCombineXYZ")
links.new(z_value, offset.inputs["Z"])
links.new(y_value, offset.inputs["Y"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(geometry, set_position.inputs["Geometry"])
links.new(offset.outputs["Vector"], set_position.inputs["Offset"])
return set_position.outputs["Geometry"]
def _set_layer_z(group, geometry, extrude, front_z, back_z):
def _set_layer_y(group, geometry, extrude, front_y, back_y):
nodes = group.nodes
links = group.links
choose_z = nodes.new("GeometryNodeSwitch")
choose_z.input_type = "FLOAT"
links.new(extrude.outputs["Top"], choose_z.inputs["Switch"])
links.new(front_z, choose_z.inputs["False"])
links.new(back_z, choose_z.inputs["True"])
choose_y = nodes.new("GeometryNodeSwitch")
choose_y.input_type = "FLOAT"
links.new(extrude.outputs["Top"], choose_y.inputs["Switch"])
links.new(front_y, choose_y.inputs["False"])
links.new(back_y, choose_y.inputs["True"])
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(choose_z.outputs["Output"], combine.inputs["Z"])
links.new(choose_y.outputs["Output"], combine.inputs["Y"])
links.new(separate.outputs["Z"], combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
@@ -100,11 +100,11 @@ def _set_layer_z(group, geometry, extrude, front_z, back_z):
links.new(extrude.outputs["Side"], side.inputs["Selection"])
flip_side = nodes.new("GeometryNodeFlipFaces")
links.new(side.outputs["Selection"], flip_side.inputs["Mesh"])
front = _offset_geometry_z(
group, geometry, front_z
front = _offset_geometry_y(
group, geometry, front_y
)
back = _offset_geometry_z(
group, geometry, back_z
back = _offset_geometry_y(
group, geometry, back_y
)
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back, flip_back.inputs["Mesh"])

View File

@@ -290,7 +290,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
row = nodes.new("FunctionNodeIntegerMath")
row.operation = "MULTIPLY"
row.inputs[1].default_value = 4096
links.new(layer_axes.outputs["Y"], row.inputs[0])
links.new(layer_axes.outputs["Z"], row.inputs[0])
vertex = nodes.new("FunctionNodeIntegerMath")
vertex.operation = "ADD"
links.new(layer_axes.outputs["X"], vertex.inputs[0])
@@ -303,7 +303,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
extrusion_offset = nodes.new("ShaderNodeCombineXYZ")
extrusion_offset.inputs["Z"].default_value = -1.0
extrusion_offset.inputs["Y"].default_value = -1.0
links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"])
links.new(saved, extrude.inputs["Mesh"])
sides = nodes.new("GeometryNodeSeparateGeometry")
@@ -311,7 +311,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"])
links.new(extrude.outputs["Side"], sides.inputs["Selection"])
offset = nodes.new("ShaderNodeCombineXYZ")
offset.inputs["Z"].default_value = -1.0
offset.inputs["Y"].default_value = -1.0
back = nodes.new("GeometryNodeSetPosition")
links.new(saved, back.inputs["Geometry"])
links.new(offset.outputs[0], back.inputs["Offset"])
@@ -337,9 +337,9 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
if weld_zero_thickness:
positive = _math(group, "GREATER_THAN", source_thickness, 1e-12)
flattened = nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Y"):
for axis in ("X", "Z"):
links.new(layer_axes.outputs[axis], flattened.inputs[axis])
links.new(_math(group, "MULTIPLY", positive, layer_axes.outputs["Z"]), flattened.inputs["Z"])
links.new(_math(group, "MULTIPLY", positive, layer_axes.outputs["Y"]), flattened.inputs["Y"])
collapse = nodes.new("GeometryNodeSetPosition")
links.new(construction, collapse.inputs["Geometry"])
links.new(flattened.outputs[0], collapse.inputs["Position"])
@@ -349,8 +349,8 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *,
merge.inputs["Distance"].default_value = 0.1
links.new(construction, merge.inputs["Geometry"])
displacement = nodes.new("ShaderNodeCombineXYZ")
distance = _math(group, "MULTIPLY", layer_axes.outputs["Z"], source_thickness)
links.new(distance, displacement.inputs["Z"])
distance = _math(group, "MULTIPLY", layer_axes.outputs["Y"], source_thickness)
links.new(distance, displacement.inputs["Y"])
restored = nodes.new("ShaderNodeVectorMath")
restored.operation = "ADD"
links.new(source_position, restored.inputs[0])
@@ -401,18 +401,19 @@ def project_depth_surface(
separate = nodes.new("ShaderNodeSeparateXYZ")
links.new(scaled.outputs["Vector"], separate.inputs["Vector"])
relative_depth = _projection_math(nodes, "SUBTRACT")
links.new(reference_depth, relative_depth.inputs[0])
links.new(separate.outputs["Z"], relative_depth.inputs[1])
links.new(separate.outputs["Z"], relative_depth.inputs[0])
links.new(reference_depth, relative_depth.inputs[1])
front = _projection_math(nodes, "MULTIPLY")
links.new(relative_depth.outputs[0], front.inputs[0])
links.new(depth_scale, front.inputs[1])
negative_y = _projection_math(nodes, "MULTIPLY")
negative_y.inputs[1].default_value = -1.0
links.new(separate.outputs["Y"], negative_y.inputs[0])
negative_z = _projection_math(nodes, "MULTIPLY")
negative_z.inputs[1].default_value = -1.0
links.new(separate.outputs["Y"], negative_z.inputs[0])
projected = nodes.new("ShaderNodeCombineXYZ")
links.new(separate.outputs["X"], projected.inputs["X"])
links.new(negative_y.outputs[0], projected.inputs["Y"])
links.new(front.outputs[0], projected.inputs["Y"])
links.new(negative_z.outputs[0], projected.inputs["Z"])
current_position = nodes.new("GeometryNodeInputPosition")
blend = nodes.new("ShaderNodeMix")
blend.data_type = "VECTOR"
@@ -424,8 +425,8 @@ def project_depth_surface(
links.new(blend.outputs["Result"], blended_axes.inputs["Vector"])
surface_position = nodes.new("ShaderNodeCombineXYZ")
links.new(blended_axes.outputs["X"], surface_position.inputs["X"])
links.new(blended_axes.outputs["Y"], surface_position.inputs["Y"])
links.new(front.outputs[0], surface_position.inputs["Z"])
links.new(front.outputs[0], surface_position.inputs["Y"])
links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"])
project_geometry = nodes.new("GeometryNodeSetPosition")
links.new(geometry, project_geometry.inputs["Geometry"])
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])

View File

@@ -0,0 +1,33 @@
"""Build nodes that decode legacy Direction parameters into canonical space."""
def legacy_direction_to_canonical(group, legacy):
nodes = group.nodes
links = group.links
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(legacy, axes.inputs[0])
negative_x = nodes.new("ShaderNodeMath")
negative_x.operation = "MULTIPLY"
negative_x.inputs[1].default_value = -1.0
links.new(axes.outputs["X"], negative_x.inputs[0])
negative_y = nodes.new("ShaderNodeMath")
negative_y.operation = "MULTIPLY"
negative_y.inputs[1].default_value = -1.0
links.new(axes.outputs["Y"], negative_y.inputs[0])
direction = nodes.new("ShaderNodeCombineXYZ")
links.new(negative_x.outputs[0], direction.inputs["X"])
links.new(axes.outputs["Z"], direction.inputs["Y"])
links.new(negative_y.outputs[0], direction.inputs["Z"])
return direction.outputs["Vector"]
def symmetry_legacy_direction_to_canonical(group, legacy):
nodes = group.nodes
links = group.links
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(legacy, axes.inputs[0])
direction = nodes.new("ShaderNodeCombineXYZ")
links.new(axes.outputs["X"], direction.inputs["X"])
links.new(axes.outputs["Z"], direction.inputs["Y"])
links.new(axes.outputs["Y"], direction.inputs["Z"])
return direction.outputs["Vector"]

View File

@@ -6,7 +6,7 @@ These FACE attributes stay on the output mesh for the material consumer.
"""
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from anyimage.common.object import blender_frame_rotation
ROTATION_ATTRIBUTE = 'o_depth_rotation'
FACE_ATTRIBUTE = 'o_depth_face'
@@ -38,28 +38,32 @@ def transform_object_normal_color(group, color, strength):
decode.inputs[1].default_value = (2., 2., 2.)
decode.inputs[2].default_value = (-1., -1., -1.)
links.new(color, decode.inputs[0])
base = nodes.new('ShaderNodeVectorRotate')
base.rotation_type = 'EULER_XYZ'
base.inputs['Rotation'].default_value = Matrix(blender_frame_rotation()).to_euler()
links.new(decode.outputs[0], base.inputs['Vector'])
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(decode.outputs[0], rotate.inputs['Vector'])
links.new(base.outputs[0], rotate.inputs['Vector'])
links.new(rotation.outputs['Vector'], rotate.inputs['Rotation'])
separate = nodes.new('ShaderNodeSeparateXYZ')
links.new(rotate.outputs[0], separate.inputs[0])
back = math('COMPARE', face.outputs['Fac'], 2.)
# Reflect the normal's Z component on the back.
difference = math('MULTIPLY', separate.outputs['Z'], -2.)
# Reflect the normal's Y component on the back.
difference = math('MULTIPLY', separate.outputs['Y'], -2.)
correction = nodes.new('ShaderNodeMath')
correction.operation = 'MULTIPLY_ADD'
links.new(back, correction.inputs[0])
links.new(difference, correction.inputs[1])
links.new(separate.outputs['Z'], correction.inputs[2])
links.new(separate.outputs['Y'], correction.inputs[2])
combine = nodes.new('ShaderNodeCombineXYZ')
links.new(separate.outputs['X'], combine.inputs['X'])
links.new(separate.outputs['Y'], combine.inputs['Y'])
links.new(correction.outputs[0], combine.inputs['Z'])
links.new(correction.outputs[0], combine.inputs['Y'])
links.new(separate.outputs['Z'], combine.inputs['Z'])
axis_rotation = nodes.new('ShaderNodeVectorMath')
axis_rotation.operation = 'SCALE'
axis_rotation.inputs[0].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
axis_rotation.inputs[0].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
links.new(axis.outputs['Fac'], axis_rotation.inputs['Scale'])
orient = nodes.new('ShaderNodeVectorRotate')
orient.rotation_type = 'EULER_XYZ'

View File

@@ -1,5 +1,6 @@
"""Build the direction-adjusted reference depth field."""
from .nodes import math_node
from .direction import legacy_direction_to_canonical
def reference_plane_depth(group, direction, reference_depth):
@@ -8,23 +9,24 @@ def reference_plane_depth(group, direction, reference_depth):
normal = nodes.new("ShaderNodeVectorMath")
normal.operation = "NORMALIZE"
links.new(direction, normal.inputs[0])
remapped = legacy_direction_to_canonical(group, normal.outputs["Vector"])
normal_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(normal.outputs["Vector"], normal_components.inputs["Vector"])
links.new(remapped, normal_components.inputs["Vector"])
position = nodes.new("GeometryNodeInputPosition")
position_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], position_components.inputs["Vector"])
x_term = math_node(nodes, "MULTIPLY")
links.new(position_components.outputs["X"], x_term.inputs[0])
links.new(normal_components.outputs["X"], x_term.inputs[1])
y_term = math_node(nodes, "MULTIPLY")
links.new(position_components.outputs["Y"], y_term.inputs[0])
links.new(normal_components.outputs["Y"], y_term.inputs[1])
z_term = math_node(nodes, "MULTIPLY")
links.new(position_components.outputs["Z"], z_term.inputs[0])
links.new(normal_components.outputs["Z"], z_term.inputs[1])
point_dot = math_node(nodes, "ADD")
links.new(x_term.outputs[0], point_dot.inputs[0])
links.new(y_term.outputs[0], point_dot.inputs[1])
links.new(z_term.outputs[0], point_dot.inputs[1])
divide = math_node(nodes, "DIVIDE")
links.new(point_dot.outputs[0], divide.inputs[0])
links.new(normal_components.outputs["Z"], divide.inputs[1])
links.new(normal_components.outputs["Y"], divide.inputs[1])
plane = math_node(nodes, "ADD")
links.new(reference_depth, plane.inputs[0])
links.new(divide.outputs[0], plane.inputs[1])

View File

@@ -1,15 +1,14 @@
"""Build O Image Cutout."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group
from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_z, _remove_zero_thickness, _set_layer_z, _shade_output
from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_y, _remove_zero_thickness, _set_layer_y, _shade_output
NODE_GROUP_NAME = "O Image Cutout"
def _set_segmented_layer_z(
def _set_segmented_layer_y(
group,
geometry,
front_z,
@@ -56,7 +55,7 @@ def _set_segmented_layer_z(
links.new(thickness, step.inputs[0])
links.new(segment_limit.outputs[0], step.inputs[1])
front = _offset_geometry_z(group, geometry, front_z)
front = _offset_geometry_y(group, geometry, front_z)
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
@@ -84,7 +83,7 @@ def _set_segmented_layer_z(
links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"])
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
positioned_side = _offset_geometry_z(
positioned_side = _offset_geometry_y(
group,
repeat_output.outputs["Sides"],
front_z,
@@ -92,7 +91,7 @@ def _set_segmented_layer_z(
flip_side = nodes.new("GeometryNodeFlipFaces")
links.new(positioned_side, flip_side.inputs["Mesh"])
back = _offset_geometry_z(group, geometry, back_z)
back = _offset_geometry_y(group, geometry, back_z)
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back, flip_back.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
@@ -113,9 +112,9 @@ def _build_shell(group, geometry, thickness):
front.inputs[1].default_value = 0.0
links.new(thickness, front.inputs[0])
back = math_node(nodes, "MULTIPLY")
back.inputs[1].default_value = -1.0
back.inputs[1].default_value = 1.0
links.new(thickness, back.inputs[0])
shaped = _set_segmented_layer_z(
shaped = _set_segmented_layer_y(
group,
geometry,
front.outputs[0],
@@ -132,16 +131,16 @@ def _build_balloon(group, geometry, thickness):
front_scale = math_node(nodes, "MULTIPLY")
links.new(profile, front_scale.inputs[0])
links.new(thickness, front_scale.inputs[1])
negative_back = math_node(nodes, "MULTIPLY")
negative_back.inputs[1].default_value = -1.0
links.new(front_scale.outputs[0], negative_back.inputs[0])
negative_front = math_node(nodes, "MULTIPLY")
negative_front.inputs[1].default_value = -1.0
links.new(front_scale.outputs[0], negative_front.inputs[0])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_z(
shaped = _set_layer_y(
group,
geometry,
extrude,
negative_front.outputs[0],
front_scale.outputs[0],
negative_back.outputs[0],
)
return _remove_zero_thickness(group, geometry, shaped, thickness)
@@ -160,7 +159,6 @@ def build_image_cutout_group():
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
options = group.interface.new_panel(name='Options', default_closed=True)
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
@@ -195,21 +193,13 @@ def build_image_cutout_group():
reduction.inputs[0].default_value = 1.0
group.links.new(scale, reduction.inputs[1])
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction.outputs[0])
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
group.links.new(geometry, axis.inputs['-Z'])
group.links.new(orient.outputs[0], axis.inputs['+X'])
_shade_output(group, axis.outputs[0])
_shade_output(group, 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 = 'Balloon'
elif item.name == 'Depth Axis':
item.default_value = '-Z'
elif item.name == 'Shell Thickness':
item.name = 'Thickness'
return group

View File

@@ -1,5 +1,6 @@
"""Mirror completed depth geometry and fill matching boundaries."""
import bpy
from mathutils import Matrix
from ..common.nodes import (
interface_socket, group_input, float_input, bool_input, vector_input,
@@ -7,6 +8,7 @@ from ..common.nodes import (
)
from ..common.depth_surface import _math
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from ..common.direction import symmetry_legacy_direction_to_canonical
NODE_GROUP_NAME = "O Image Cutout Symmetry"
@@ -14,9 +16,10 @@ 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
direction = symmetry_legacy_direction_to_canonical(group, controls['Direction'])
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Z'
links.new(controls['Direction'], align.inputs['Vector'])
align.axis = 'Y'
links.new(direction, align.inputs['Vector'])
position = nodes.new('GeometryNodeInputPosition')
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
@@ -24,15 +27,19 @@ def _align_front(group, geometry, controls):
links.new(position.outputs[0], rotate.inputs['Vector'])
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
offset = nodes.new('ShaderNodeCombineXYZ')
links.new(controls['Offset'], offset.inputs['Z'])
offset_value = nodes.new('ShaderNodeMath')
offset_value.operation = 'MULTIPLY'
offset_value.inputs[1].default_value = -1.0
links.new(controls['Offset'], offset_value.inputs[0])
links.new(offset_value.outputs[0], offset.inputs['Y'])
offset_position = nodes.new('ShaderNodeVectorMath')
offset_position.operation = 'ADD'
links.new(rotate.outputs[0], offset_position.inputs[0])
links.new(offset.outputs[0], offset_position.inputs[1])
scale_vector = nodes.new('ShaderNodeCombineXYZ')
scale_vector.inputs['X'].default_value = 1
scale_vector.inputs['Y'].default_value = 1
links.new(controls['Scale'], scale_vector.inputs['Z'])
scale_vector.inputs['Z'].default_value = 1
links.new(controls['Scale'], scale_vector.inputs['Y'])
scale = nodes.new('ShaderNodeVectorMath')
scale.operation = 'MULTIPLY'
links.new(offset_position.outputs[0], scale.inputs[0])
@@ -48,32 +55,32 @@ def _align_front(group, geometry, controls):
return store.outputs['Geometry']
def _delete_negative_faces(group, front, tolerance):
"""Discard faces touching negative Z without leaving wires or isolated vertices."""
def _delete_positive_faces(group, front, tolerance):
"""Discard faces touching positive Y 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')
positive = _math(group, 'GREATER_THAN', components.outputs['Y'], tolerance)
point_positive = evaluate_field(group, positive, 'FLOAT', 'POINT')
face_positive = evaluate_field(group, point_positive, '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'])
group.links.new(_math(group, 'GREATER_THAN', face_positive, 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)
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):
def _extrude_boundary(group, geometry, selection, y_factor):
"""Extrude selected edges to a fraction of their current height."""
nodes, links = group.nodes, group.links
extrude = nodes.new('GeometryNodeExtrudeMesh')
@@ -84,7 +91,7 @@ def _extrude_boundary(group, geometry, selection, z_factor):
position = nodes.new('GeometryNodeInputPosition')
scale = nodes.new('ShaderNodeVectorMath')
scale.operation = 'MULTIPLY'
scale.inputs[1].default_value = (1, 1, z_factor)
scale.inputs[1].default_value = (1, y_factor, 1)
links.new(position.outputs[0], scale.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(extrude.outputs['Mesh'], place.inputs['Geometry'])
@@ -128,7 +135,7 @@ def _walls(group, front, boundary, smooth, tolerance, *, curved):
links.new(position.outputs[0], difference.inputs[1])
mask = nodes.new('ShaderNodeVectorMath')
mask.operation = 'MULTIPLY'
mask.inputs[1].default_value = (.5, .5, 0)
mask.inputs[1].default_value = (.5, 0, .5)
links.new(difference.outputs[0], mask.inputs[0])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
@@ -171,7 +178,7 @@ def build_image_cutout_symmetry_group():
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)
front = _delete_positive_faces(group, _align_front(group, inputs['Geometry'], inputs), tolerance)
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
@@ -194,7 +201,10 @@ def build_image_cutout_symmetry_group():
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'])
geometry = store_float_attribute(group, merge.outputs[0], AXIS_ATTRIBUTE, 1., domain='FACE')
orient = nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
links.new(geometry, orient.inputs['Geometry'])
links.new(orient.outputs[0], nodes.new('NodeGroupOutput').inputs['Geometry'])
prepare_node_group(group)
return group

View File

@@ -1,8 +1,7 @@
"""Build O Image Depth Cutout."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import interface_socket, group_input, math_node, float_input, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field
from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface
@@ -52,7 +51,7 @@ def build_volume_fields(group):
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
seed = _math(group, "SUBTRACT", axes.outputs["Z"], profile)
seed = _math(group, "SUBTRACT", axes.outputs["Y"], profile)
interior = _math(group, "SUBTRACT", 1, rim)
blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT"
@@ -60,14 +59,14 @@ def build_volume_fields(group):
links.new(seed, blur.inputs["Value"])
links.new(interior, blur.inputs["Weight"])
center = blur.outputs[0]
residual = _math(group, "SUBTRACT", axes.outputs["Z"], center)
residual = _math(group, "SUBTRACT", axes.outputs["Y"], center)
full = _math(group, "ADD", residual, profile)
minimum = _math(group, "MULTIPLY", profile, 0.05)
positive = _math(group, "MAXIMUM", full, minimum)
control = group_input(group.nodes, {"Thickness"})
thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"])
return {
"profile": profile, "depth": axes.outputs["Z"], "interior": interior,
"profile": profile, "depth": axes.outputs["Y"], "interior": interior,
"center": center, "thickness": thickness,
}
@@ -79,9 +78,9 @@ def build_shell_fields(group, geometry, fields, is_shell, normal_weight=1):
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0])
center_position = nodes.new("ShaderNodeCombineXYZ")
for axis in ("X", "Y"):
for axis in ("X", "Z"):
links.new(axes.outputs[axis], center_position.inputs[axis])
links.new(fields["center"], center_position.inputs["Z"])
links.new(fields["center"], center_position.inputs["Y"])
surface = nodes.new("GeometryNodeSetPosition")
links.new(geometry, surface.inputs["Geometry"])
links.new(center_position.outputs[0], surface.inputs["Position"])
@@ -214,15 +213,15 @@ def build_shell_position(group, position, front, is_shell, fields):
connect(b, result.inputs[3 if operation == "SCALE" else 1])
return result.outputs[0]
def z(value):
def y(value):
result = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(value, result.inputs[0])
return result.outputs["Z"]
return result.outputs["Y"]
# Shell and the thin end of Balloon share the actual front normal.
front_z = z(front)
raw_z = z(position)
distance = _math(group, "SUBTRACT", raw_z, front_z)
front_y = y(front)
raw_y = y(position)
distance = _math(group, "SUBTRACT", raw_y, front_y)
normal = fields["front_normal"]
thin_offset = vector("SCALE", normal, distance)
shell_position = vector("ADD", front, thin_offset)
@@ -231,13 +230,13 @@ def build_shell_position(group, position, front, is_shell, fields):
clamp = group.nodes.new("ShaderNodeClamp")
links.new(control.outputs["Thickness"], clamp.inputs["Value"])
t = clamp.outputs[0]
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_z, fields["center"]), t)
radius = _math(group, "MULTIPLY", _math(group, "SUBTRACT", front_y, fields["center"]), t)
rear = read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME)
rear_radius = _math(group, "MULTIPLY", rear, _math(group, "ADD", radius, distance))
direction = vector("SUBTRACT", fields["normal"], (0, 0, 1))
direction = vector("SUBTRACT", fields["normal"], (0, 1, 0))
rear_shift = vector("SCALE", direction, rear_radius)
axis_offset = group.nodes.new("ShaderNodeCombineXYZ")
links.new(distance, axis_offset.inputs["Z"])
links.new(distance, axis_offset.inputs["Y"])
full_offset = vector("ADD", axis_offset.outputs[0], rear_shift)
# Smoothly reach the existing full-volume shape at Thickness >= 1.
@@ -348,7 +347,6 @@ def build_image_depth_cutout_group():
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 boundary smoothing. Higher values move boundary vertices more.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
interface_socket(group, 'Depth Axis', 'INPUT', 'NodeSocketMenu', options).menu_expanded = True
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
@@ -368,21 +366,13 @@ def build_image_depth_cutout_group():
cleaned = store_float_attribute(group, cleaned, NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE')
controls = group_input(group.nodes, set())
geometry = _build_depth_surface(group, cleaned, controls)
orient = group.nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix(CUTOUT_Z_TO_X).to_euler()
group.links.new(geometry, orient.inputs['Geometry'])
axis = menu_switch(group, 'Depth Axis', ('-Z', '+X'), 'GEOMETRY')
group.links.new(geometry, axis.inputs['-Z'])
group.links.new(orient.outputs[0], axis.inputs['+X'])
_shade_output(group, axis.outputs[0])
_shade_output(group, 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 = 'Balloon'
elif item.name == 'Depth Axis':
item.default_value = '-Z'
elif item.name == 'Shell Thickness':
item.name = 'Thickness'
return group

View File

@@ -1,6 +1,7 @@
"""Build the Image Plane Geometry Nodes group."""
import bpy
from mathutils import Matrix
from ..common.nodes import prepare_node_group
from ..common.material_slots import inherit_material_slots
@@ -94,7 +95,7 @@ def build_image_plane_group():
links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1])
vertex_counts = []
for axis, other_axis in (("X", "Y"), ("Y", "X")):
for axis, other_axis in (("X", "Z"), ("Z", "X")):
ratio = nodes.new("ShaderNodeMath")
ratio.operation = "DIVIDE"
links.new(separate_dimensions.outputs[axis], ratio.inputs[0])
@@ -118,18 +119,18 @@ def build_image_plane_group():
grid = nodes.new("GeometryNodeMeshGrid")
links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"])
links.new(separate_dimensions.outputs["Y"], grid.inputs["Size Y"])
links.new(separate_dimensions.outputs["Z"], grid.inputs["Size Y"])
links.new(vertex_counts[0], grid.inputs["Vertices X"])
links.new(vertex_counts[1], grid.inputs["Vertices Y"])
position = nodes.new("GeometryNodeInputPosition")
separate_position = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], separate_position.inputs[0])
uv_components = []
for axis in ("X", "Y"):
for position_axis, dimension_axis in (("X", "X"), ("Y", "Z")):
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
links.new(separate_position.outputs[axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[axis], normalize.inputs[1])
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
@@ -139,6 +140,21 @@ def build_image_plane_group():
links.new(uv_components[0], uv_coordinates.inputs["X"])
links.new(uv_components[1], uv_coordinates.inputs["Y"])
cube_uv_components = []
for position_axis, dimension_axis in (("X", "X"), ("Z", "Z")):
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
links.new(normalize.outputs[0], offset_uv.inputs[0])
cube_uv_components.append(offset_uv.outputs[0])
cube_uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
links.new(cube_uv_components[0], cube_uv_coordinates.inputs["X"])
links.new(cube_uv_components[1], cube_uv_coordinates.inputs["Y"])
store_grid_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_grid_uv.data_type = "FLOAT2"
store_grid_uv.domain = "CORNER"
@@ -146,6 +162,10 @@ def build_image_plane_group():
links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"])
rotate_grid = nodes.new("GeometryNodeTransform")
rotate_grid.inputs["Rotation"].default_value = Matrix.Rotation(1.5707963267948966, 4, "X").to_euler()
links.new(store_grid_uv.outputs["Geometry"], rotate_grid.inputs["Geometry"])
center_sum = nodes.new("ShaderNodeVectorMath")
center_sum.operation = "ADD"
links.new(bounds.outputs["Min"], center_sum.inputs[0])
@@ -155,11 +175,11 @@ def build_image_plane_group():
center.inputs[3].default_value = 0.5
links.new(center_sum.outputs["Vector"], center.inputs[0])
place_grid = nodes.new("GeometryNodeTransform")
links.new(store_grid_uv.outputs["Geometry"], place_grid.inputs["Geometry"])
links.new(rotate_grid.outputs["Geometry"], place_grid.inputs["Geometry"])
links.new(center.outputs["Vector"], place_grid.inputs["Translation"])
cell_sizes = []
for index, axis in enumerate(("X", "Y")):
for index, axis in enumerate(("X", "Z")):
segments = nodes.new("ShaderNodeMath")
segments.operation = "SUBTRACT"
segments.inputs[1].default_value = 1.0
@@ -194,19 +214,19 @@ def build_image_plane_group():
cube_size = nodes.new("ShaderNodeCombineXYZ")
links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"])
links.new(separate_dimensions.outputs["Y"], cube_size.inputs["Y"])
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Z"])
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Y"])
links.new(separate_dimensions.outputs["Z"], cube_size.inputs["Z"])
cube = nodes.new("GeometryNodeMeshCube")
links.new(cube_size.outputs["Vector"], cube.inputs["Size"])
links.new(vertex_counts[0], cube.inputs["Vertices X"])
links.new(vertex_counts[1], cube.inputs["Vertices Y"])
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Z"])
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Y"])
links.new(vertex_counts[1], cube.inputs["Vertices Z"])
store_cube_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_cube_uv.data_type = "FLOAT2"
store_cube_uv.domain = "CORNER"
store_cube_uv.inputs["Name"].default_value = "UVMap"
links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
links.new(cube_uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
place_cube = nodes.new("GeometryNodeTransform")
links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"])
links.new(center.outputs["Vector"], place_cube.inputs["Translation"])

View File

@@ -7,6 +7,7 @@ from ..common.nodes import group_input as _group_input, interface_socket as _int
from ..common.nodes import store_float_attribute
from ..common.nodes import prepare_node_group, float_input, vector_input
from ..common.reference_plane import reference_plane_depth
from ..common.boundary_smoothing import boundary_influence
RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane"
@@ -35,7 +36,7 @@ def build_image_relief_plane_group(image_plane_group):
subdivide.min_value = 0
subdivide.max_value = 10
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.5
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
thickness.description = "Set the thickness of the flat base beneath the relief."
@@ -50,6 +51,9 @@ def build_image_relief_plane_group(image_plane_group):
depth_scale.default_value = 0.5
depth_scale.min_value = 0.0
depth_scale.max_value = 2.0
valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
valid_only.description = "Treat depth-mask-invalid areas as zero depth instead of deleting them."
valid_only.default_value = True
options = group.interface.new_panel(name="Options", default_closed=True)
base_plane_depth = _interface_socket(
group,
@@ -61,6 +65,11 @@ def build_image_relief_plane_group(image_plane_group):
base_plane_depth.default_value = 1.0
base_plane_depth.min_value = 0.0
base_plane_depth.subtype = "DISTANCE"
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options)
validity_threshold.description = "Set the minimum depth mask value treated as valid."
validity_threshold.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
validity_threshold.subtype = "FACTOR"
data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _interface_socket(
group,
@@ -94,7 +103,7 @@ def build_image_relief_plane_group(image_plane_group):
)
image_input = _group_input(
nodes,
{"Depth Image", "Uniform Scale"},
{"Depth Image", "Uniform Scale", "Depth Mask", "Mask Threshold"},
)
group_output = nodes.new("NodeGroupOutput")
image_plane = nodes.new("GeometryNodeGroup")
@@ -107,7 +116,7 @@ def build_image_relief_plane_group(image_plane_group):
positive_half.inputs[1].default_value = 0.5
links.new(shape_input.outputs["Thickness"], positive_half.inputs[0])
translation = nodes.new("ShaderNodeCombineXYZ")
links.new(positive_half.outputs[0], translation.inputs["Z"])
links.new(positive_half.outputs[0], translation.inputs["Y"])
place_block = nodes.new("GeometryNodeTransform")
links.new(image_plane.outputs["Geometry"], place_block.inputs["Geometry"])
links.new(translation.outputs["Vector"], place_block.inputs["Translation"])
@@ -122,6 +131,20 @@ def build_image_relief_plane_group(image_plane_group):
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
depth_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(texture.outputs["Color"], depth_components.inputs["Vector"])
invalid = nodes.new("FunctionNodeCompare")
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
links.new(texture.outputs["Alpha"], invalid.inputs["A"])
links.new(image_input.outputs["Mask Threshold"], invalid.inputs["B"])
mask_enabled = image_input.outputs["Depth Mask"]
valid_mask = nodes.new("ShaderNodeMath")
valid_mask.operation = "SUBTRACT"
valid_mask.inputs[0].default_value = 1.0
links.new(invalid.outputs["Result"], valid_mask.inputs[1])
mask_switch = nodes.new("GeometryNodeSwitch")
mask_switch.input_type = "FLOAT"
mask_switch.inputs["False"].default_value = 1.0
links.new(mask_enabled, mask_switch.inputs["Switch"])
links.new(valid_mask.outputs[0], mask_switch.inputs["True"])
metric_depth = _math(nodes, "MULTIPLY")
links.new(depth_components.outputs["Z"], metric_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1])
@@ -133,24 +156,38 @@ def build_image_relief_plane_group(image_plane_group):
plane_depth = reference_plane_depth(
group, shape_input.outputs["Depth Direction"], corrected_reference.outputs[0],
)
links.new(plane_depth, relative_depth.inputs[0])
links.new(metric_depth.outputs[0], relative_depth.inputs[1])
links.new(metric_depth.outputs[0], relative_depth.inputs[0])
links.new(plane_depth, relative_depth.inputs[1])
full_offset = _math(nodes, "MULTIPLY")
links.new(relative_depth.outputs[0], full_offset.inputs[0])
links.new(shape_input.outputs["Depth Scale"], full_offset.inputs[1])
masked_full_offset = _math(nodes, "MULTIPLY")
links.new(full_offset.outputs[0], masked_full_offset.inputs[0])
links.new(mask_switch.outputs[0], masked_full_offset.inputs[1])
has_thickness = _math(nodes, "GREATER_THAN")
has_thickness.inputs[1].default_value = 1e-6
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
safe_offset = _math(nodes, "MAXIMUM")
safe_offset = _math(nodes, "MINIMUM")
safe_offset.inputs[1].default_value = 0.0
links.new(full_offset.outputs[0], safe_offset.inputs[0])
links.new(masked_full_offset.outputs[0], safe_offset.inputs[0])
edge_influence = boundary_influence(group, protected=False)
edge_clamped = nodes.new("ShaderNodeClamp")
edge_clamped.inputs["Min"].default_value = 0.0
edge_clamped.inputs["Max"].default_value = 1.0
links.new(edge_influence, edge_clamped.inputs["Value"])
geometry_weight = _math(nodes, "SUBTRACT")
geometry_weight.inputs[0].default_value = 1.0
links.new(edge_clamped.outputs[0], geometry_weight.inputs[1])
smooth_safe_offset = _math(nodes, "MULTIPLY")
links.new(safe_offset.outputs[0], smooth_safe_offset.inputs[0])
links.new(geometry_weight.outputs[0], smooth_safe_offset.inputs[1])
position = nodes.new("GeometryNodeInputPosition")
position_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], position_components.inputs["Vector"])
layer_ratio = _math(nodes, "DIVIDE")
links.new(position_components.outputs["Z"], layer_ratio.inputs[0])
links.new(position_components.outputs["Y"], layer_ratio.inputs[0])
links.new(shape_input.outputs["Thickness"], layer_ratio.inputs[1])
nonnegative_ratio = _math(nodes, "MAXIMUM")
nonnegative_ratio.inputs[1].default_value = 0.0
@@ -158,17 +195,20 @@ def build_image_relief_plane_group(image_plane_group):
clamped_ratio = _math(nodes, "MINIMUM")
clamped_ratio.inputs[1].default_value = 1.0
links.new(nonnegative_ratio.outputs[0], clamped_ratio.inputs[0])
one_minus_ratio = _math(nodes, "SUBTRACT")
one_minus_ratio.inputs[0].default_value = 1.0
links.new(clamped_ratio.outputs[0], one_minus_ratio.inputs[1])
layer_weight = nodes.new("GeometryNodeSwitch")
layer_weight.input_type = "FLOAT"
layer_weight.inputs["False"].default_value = 1.0
links.new(has_thickness.outputs[0], layer_weight.inputs["Switch"])
links.new(clamped_ratio.outputs[0], layer_weight.inputs["True"])
links.new(one_minus_ratio.outputs[0], layer_weight.inputs["True"])
weighted_offset = _math(nodes, "MULTIPLY")
links.new(safe_offset.outputs[0], weighted_offset.inputs[0])
links.new(smooth_safe_offset.outputs[0], weighted_offset.inputs[0])
links.new(layer_weight.outputs["Output"], weighted_offset.inputs[1])
displacement = nodes.new("ShaderNodeCombineXYZ")
links.new(weighted_offset.outputs[0], displacement.inputs["Z"])
links.new(weighted_offset.outputs[0], displacement.inputs["Y"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(place_block.outputs["Geometry"], set_position.inputs["Geometry"])
links.new(displacement.outputs["Vector"], set_position.inputs["Offset"])
@@ -178,7 +218,10 @@ def build_image_relief_plane_group(image_plane_group):
links.new(set_position.outputs["Geometry"], shade_smooth.inputs["Geometry"])
normal_reduction = _math(nodes, "SUBTRACT")
normal_reduction.inputs[0].default_value = 1.0
links.new(shape_input.outputs["Depth Scale"], normal_reduction.inputs[1])
scaled_depth = _math(nodes, "MULTIPLY")
links.new(shape_input.outputs["Depth Scale"], scaled_depth.inputs[0])
links.new(geometry_weight.outputs[0], scaled_depth.inputs[1])
links.new(scaled_depth.outputs[0], normal_reduction.inputs[1])
output_geometry = store_float_attribute(
group, shade_smooth.outputs["Geometry"], NORMAL_REDUCTION_ATTRIBUTE_NAME,
normal_reduction.outputs[0],