diff --git a/openspec/changes/standardize-blender-object-coordinates/.openspec.yaml b/openspec/changes/standardize-blender-object-coordinates/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/standardize-blender-object-coordinates/design.md b/openspec/changes/standardize-blender-object-coordinates/design.md new file mode 100644 index 0000000..dd89bf2 --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/design.md @@ -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 转换放在材质节点内还是图片加载阶段:设计倾向保留材质属性契约,实现时按节点复杂度和测试成本选择。 diff --git a/openspec/changes/standardize-blender-object-coordinates/proposal.md b/openspec/changes/standardize-blender-object-coordinates/proposal.md new file mode 100644 index 0000000..be97e16 --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/proposal.md @@ -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`。不新增依赖。 diff --git a/openspec/changes/standardize-blender-object-coordinates/specs/canonical-object-frame/spec.md b/openspec/changes/standardize-blender-object-coordinates/specs/canonical-object-frame/spec.md new file mode 100644 index 0000000..c07233f --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/specs/canonical-object-frame/spec.md @@ -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` 最终旋转 diff --git a/openspec/changes/standardize-blender-object-coordinates/specs/depth-symmetry-orientation/spec.md b/openspec/changes/standardize-blender-object-coordinates/specs/depth-symmetry-orientation/spec.md new file mode 100644 index 0000000..bb6d5d5 --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/specs/depth-symmetry-orientation/spec.md @@ -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 朝向 diff --git a/openspec/changes/standardize-blender-object-coordinates/specs/object-space-normal-alignment/spec.md b/openspec/changes/standardize-blender-object-coordinates/specs/object-space-normal-alignment/spec.md new file mode 100644 index 0000000..3e4410f --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/specs/object-space-normal-alignment/spec.md @@ -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 旋转 diff --git a/openspec/changes/standardize-blender-object-coordinates/tasks.md b/openspec/changes/standardize-blender-object-coordinates/tasks.md new file mode 100644 index 0000000..ec8fa80 --- /dev/null +++ b/openspec/changes/standardize-blender-object-coordinates/tasks.md @@ -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 运行全量测试并确认正常退出,检查旧引用与最终差异;不构建文档或打包扩展。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 2e104eb..f1fa3cf 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fdc7ffcf5d32ae46a4301480ba3d853242e09b9865f6b36090f98b4475a1755e -size 7600072 +oid sha256:359f47ac74756ffb875f126b79b34c58d4035b64302d6865eecd7ecb6b5daa2a +size 7802158 diff --git a/src/anyimage/common/coordinate.py b/src/anyimage/common/coordinate.py new file mode 100644 index 0000000..f54943f --- /dev/null +++ b/src/anyimage/common/coordinate.py @@ -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) diff --git a/src/anyimage/common/depth.py b/src/anyimage/common/depth.py index 438dd60..ed4d9af 100644 --- a/src/anyimage/common/depth.py +++ b/src/anyimage/common/depth.py @@ -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(), diff --git a/src/anyimage/common/object.py b/src/anyimage/common/object.py index b557706..ca9dee9 100644 --- a/src/anyimage/common/object.py +++ b/src/anyimage/common/object.py @@ -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": diff --git a/src/anyimage/operators/convert_to_plane/image_plane.py b/src/anyimage/operators/convert_to_plane/image_plane.py index 86c473f..a398134 100644 --- a/src/anyimage/operators/convert_to_plane/image_plane.py +++ b/src/anyimage/operators/convert_to_plane/image_plane.py @@ -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),), diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index dd7e684..f2d781d 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -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, diff --git a/src/anyimage/operators/cutout_tool/depth_calibration.py b/src/anyimage/operators/cutout_tool/depth_calibration.py index 520b938..bd398fa 100644 --- a/src/anyimage/operators/cutout_tool/depth_calibration.py +++ b/src/anyimage/operators/cutout_tool/depth_calibration.py @@ -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) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 306ec1d..6b16921 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -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 diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 9dffbd5..d5b0b27 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -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") diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 24ca9bf..0eed28a 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -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", diff --git a/tests/anyimage/operators/test_cutout_calibration.py b/tests/anyimage/operators/test_cutout_calibration.py index 322e1f2..e0b3209 100644 --- a/tests/anyimage/operators/test_cutout_calibration.py +++ b/tests/anyimage/operators/test_cutout_calibration.py @@ -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( diff --git a/tests/anyimage/operators/test_cutout_edge_length.py b/tests/anyimage/operators/test_cutout_edge_length.py index e89802d..e7c33bc 100644 --- a/tests/anyimage/operators/test_cutout_edge_length.py +++ b/tests/anyimage/operators/test_cutout_edge_length.py @@ -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 diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py index 83e4377..795f172 100644 --- a/tests/anyimage/operators/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -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) diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index c096204..709e9fe 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -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 = [ diff --git a/tests/anyimage/operators/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py index a34a2aa..b9edafb 100644 --- a/tests/anyimage/operators/test_plane_objects.py +++ b/tests/anyimage/operators/test_plane_objects.py @@ -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) diff --git a/tests/support/depth_surface.py b/tests/support/depth_surface.py index 79095a8..ed492f9 100644 --- a/tests/support/depth_surface.py +++ b/tests/support/depth_surface.py @@ -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 diff --git a/tests/support/image_objects.py b/tests/support/image_objects.py index f576c44..7a9bf91 100644 --- a/tests/support/image_objects.py +++ b/tests/support/image_objects.py @@ -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"), diff --git a/tests/support/planes.py b/tests/support/planes.py index 2ca5254..113b01b 100644 --- a/tests/support/planes.py +++ b/tests/support/planes.py @@ -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) diff --git a/tests/tools/nodes/test_boundary_depth.py b/tests/tools/nodes/test_boundary_depth.py index 7f9f74d..de5ed9f 100644 --- a/tests/tools/nodes/test_boundary_depth.py +++ b/tests/tools/nodes/test_boundary_depth.py @@ -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) diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index cdd65a0..14b85d0 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -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))]) diff --git a/tests/tools/nodes/test_cutout_boundary.py b/tests/tools/nodes/test_cutout_boundary.py index 5841b6c..969e22f 100644 --- a/tests/tools/nodes/test_cutout_boundary.py +++ b/tests/tools/nodes/test_cutout_boundary.py @@ -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] diff --git a/tests/tools/nodes/test_cutout_symmetry.py b/tests/tools/nodes/test_cutout_symmetry.py index b644d8d..4aa7e3f 100644 --- a/tests/tools/nodes/test_cutout_symmetry.py +++ b/tests/tools/nodes/test_cutout_symmetry.py @@ -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() diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index 7a9f395..e7234a3 100644 --- a/tests/tools/nodes/test_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -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: diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 4c2731b..0fd0938 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -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 diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py index 847f563..1deb535 100644 --- a/tests/tools/nodes/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -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]) diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index 32d19ac..1a875b2 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -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(): diff --git a/tests/tools/nodes/test_image_plane.py b/tests/tools/nodes/test_image_plane.py index 23834c2..762f335 100644 --- a/tests/tools/nodes/test_image_plane.py +++ b/tests/tools/nodes/test_image_plane.py @@ -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() diff --git a/tests/tools/nodes/test_normal_scale.py b/tests/tools/nodes/test_normal_scale.py index 8a88eb2..8e74595 100644 --- a/tests/tools/nodes/test_normal_scale.py +++ b/tests/tools/nodes/test_normal_scale.py @@ -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: diff --git a/tests/tools/nodes/test_object_normal_rotation.py b/tests/tools/nodes/test_object_normal_rotation.py index 5f32029..7e93653 100644 --- a/tests/tools/nodes/test_object_normal_rotation.py +++ b/tests/tools/nodes/test_object_normal_rotation.py @@ -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) diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 78fa064..2b230bb 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -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 diff --git a/tools/nodes/common/cutout.py b/tools/nodes/common/cutout.py index c01ffce..46e4715 100644 --- a/tools/nodes/common/cutout.py +++ b/tools/nodes/common/cutout.py @@ -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"]) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index f22b1b8..141327b 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -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"]) diff --git a/tools/nodes/common/direction.py b/tools/nodes/common/direction.py new file mode 100644 index 0000000..9779342 --- /dev/null +++ b/tools/nodes/common/direction.py @@ -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"] diff --git a/tools/nodes/common/normal_map.py b/tools/nodes/common/normal_map.py index 59b27db..dac0ce8 100644 --- a/tools/nodes/common/normal_map.py +++ b/tools/nodes/common/normal_map.py @@ -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' diff --git a/tools/nodes/common/reference_plane.py b/tools/nodes/common/reference_plane.py index 7c7a3fc..879721c 100644 --- a/tools/nodes/common/reference_plane.py +++ b/tools/nodes/common/reference_plane.py @@ -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]) diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index c4577a5..c1dcbe9 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -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 diff --git a/tools/nodes/groups/image_cutout_symmetry.py b/tools/nodes/groups/image_cutout_symmetry.py index 743a465..ccf5d23 100644 --- a/tools/nodes/groups/image_cutout_symmetry.py +++ b/tools/nodes/groups/image_cutout_symmetry.py @@ -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 diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 8c48d7e..d5af7fa 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -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 diff --git a/tools/nodes/groups/image_plane.py b/tools/nodes/groups/image_plane.py index 0b20f54..943e7d5 100644 --- a/tools/nodes/groups/image_plane.py +++ b/tools/nodes/groups/image_plane.py @@ -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"]) diff --git a/tools/nodes/groups/image_relief_plane.py b/tools/nodes/groups/image_relief_plane.py index 46153cf..30fbe0b 100644 --- a/tools/nodes/groups/image_relief_plane.py +++ b/tools/nodes/groups/image_relief_plane.py @@ -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],