From 7f878a2f3f1b35fbce586a4235cf362958883749 Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 16 Sep 2026 08:42:13 +0800 Subject: [PATCH] Optimize Cutout Geometry Nodes - Refactor Depth Cutout to reuse the projected outline field, reducing redundant boundary calculations and improving performance. - Derive the center field from shared low-frequency fields, eliminating an unnecessary blur operation. - Implement triangle-only strip cleanup for Depth Cutout, as the input is confirmed to be triangulated. - Short-circuit zero-thickness branches in both Cutout and Depth Cutout to avoid unnecessary geometry generation. - Standardize internal attribute naming with a new `_o_` prefix and implement wildcard cleanup for temporary attributes. - Update relevant tests to ensure functionality remains consistent and validate changes against existing behavior. --- .../.openspec.yaml | 2 + .../optimize-cutout-geometry-nodes/design.md | 101 ++++++++++ .../proposal.md | 37 ++++ .../specs/cutout-attribute-lifecycle/spec.md | 44 +++++ .../cutout-geometry-node-efficiency/spec.md | 72 ++++++++ .../optimize-cutout-geometry-nodes/tasks.md | 36 ++++ src/anyimage/assets/O_AnyImage.blend | 4 +- tests/tools/nodes/test_boundary_smoothing.py | 2 +- tests/tools/nodes/test_depth_cutout.py | 76 +++++++- tests/tools/nodes/test_depth_strip_cleanup.py | 29 ++- tests/tools/nodes/test_depth_surface_split.py | 12 +- tests/tools/nodes/test_inventory.py | 2 +- .../tools/nodes/test_temporary_attributes.py | 2 + tools/nodes/check.py | 4 + tools/nodes/common/boundary_smoothing.py | 11 +- tools/nodes/common/cutout_boundary.py | 5 +- tools/nodes/common/depth_surface.py | 172 +++++++++++------- tools/nodes/common/nodes.py | 9 + tools/nodes/groups/image_cutout.py | 22 ++- tools/nodes/groups/image_depth_cutout.py | 66 ++++--- tools/nodes/groups/image_depth_panorama.py | 9 +- tools/nodes/groups/image_depth_plane.py | 2 +- 22 files changed, 584 insertions(+), 135 deletions(-) create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/.openspec.yaml create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/design.md create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/proposal.md create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-attribute-lifecycle/spec.md create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-geometry-node-efficiency/spec.md create mode 100644 openspec/changes/optimize-cutout-geometry-nodes/tasks.md diff --git a/openspec/changes/optimize-cutout-geometry-nodes/.openspec.yaml b/openspec/changes/optimize-cutout-geometry-nodes/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/optimize-cutout-geometry-nodes/design.md b/openspec/changes/optimize-cutout-geometry-nodes/design.md new file mode 100644 index 0000000..a207d5e --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/design.md @@ -0,0 +1,101 @@ +## Context + +`O Image Cutout` 负责普通 Surface/Balloon,`O Image Depth Cutout` 负责深度投影后的 Balloon/Shell。二者共同消费 Cutout 基础网格和 `o_balloon`,并在输出后进入材质与法线阶段。 + +当前 Depth Cutout 从源码构建会生成约 441 个节点、587 条连线,其中包含 13 个 Blur Attribute、12 个 Sample Index,以及多处针对同一拓扑重复计算的边界字段。Depth Cutout 输入已确认为三角网格,因此 `_remove_strip_faces` 中的四边面清理分支是无效构建路径。普通 Cutout 和 Depth Cutout 在零厚度时仍会先生成厚度几何,再由 `_remove_zero_thickness` 选择原表面。 + +临时属性目前使用 `.o_anyimage_depth_cut`,通过 `remove_attributes` 按具体名称条件删除;测试和资产检查仍保留 `.o_anyimage_original_boundary` 的旧名称。公开协议属性 `o_balloon`、`o_normal_reduction` 等需要继续保留。 + +## Goals / Non-Goals + +**Goals:** + +- 在保持两个节点组公开输入、输出几何和视觉结果不变的前提下,减少 Depth Cutout 的重复节点与求值。 +- 按拓扑阶段复用 projection 后的 outline 边界字段。 +- 用共享的低频场推导中心场,减少一次 256 次邻域平滑。 +- Depth Cutout 仅构建三角形 strip 清理路径。 +- 厚度为零时提前返回单层表面,避免先生成壳体。 +- 将内部临时属性统一改为 `_o_` 前缀,公开保留属性保持 `o_` 前缀,并用 Wildcard 一次性清理内部属性。 + +**Non-Goals:** + +- 不改变 Balloon 的背面算法、平滑次数、圆润公式或法线方向。 +- 不改变 Cutout 的 Operator、Job、材质协议或公开属性语义。 +- 不重排节点布局;布局优化只在必要的最小范围内跟随代码路径调整。 +- 不构建文档或打包扩展。 + +## Decisions + +### 1. 投影后只计算一次 outline 字段 + +`_build_depth_surface` 在 `remove_boundary_triangles` 之后、`project_depth_surface` 之前已经计算 outline。投影只改变位置,不改变拓扑,因此同一个 POINT/BOOLEAN 字段在投影后仍然有效。 + +将该字段作为参数传给 `build_volume_fields`、`original_boundary_field` 和 `smooth_cut_boundary`,由这些函数复用,而不是各自重新创建 `edge_boundary_field`。 + +备选方案是继续保留局部字段,可读性更高但会重复创建多组 Edge Neighbors、Vertex Neighbors 和域转换。本变更选择显式传递,因为高密度网格下这些重复查询是可见成本。 + +### 2. 用共享低频场推导中心场 + +`build_volume_fields` 当前计算: + +```text +seed = depth - profile +center = blur(seed, interior, 256) +``` + +`build_front_fields` 随后计算: + +```text +depth_slow = blur(depth, interior, 256) +profile_slow = blur(profile, interior, 256) +``` + +三次 Blur 使用相同权重和迭代次数。由于邻域加权是线性的: + +```text +center = depth_slow - profile_slow +``` + +因此重构为先计算 `depth_slow` 和 `profile_slow`,再推导 `center`,不再单独执行 center blur。这样保留原有平滑语义,同时移除一次 256 次 Blur。 + +### 3. Depth Cutout 使用三角形专用清理路径 + +将 `_remove_strip_faces` 的构建逻辑拆成 Python 层可选择的 triangle / quad 路径。`split_depth_surface` 增加显式构建参数或提供三角形专用辅助函数;Depth Cutout 调用 triangle-only 版本,Depth Plane 与 Panorama 继续使用现有 quad 路径。 + +Depth Cutout 输入已确认为三角网格,因此不保留运行时分支,也不构建无效的 quad 节点。 + +### 4. 零厚度分支提前短路 + +普通 Cutout 中,`_build_balloon` 和 `_build_shell` 在输入厚度低于 `1e-6` 时直接返回原几何,不再进入挤出、Repeat 或 `_set_layer_y`。 + +Depth Cutout 保留投影面作为零厚度结果;只有选中厚度大于 `1e-6` 时才执行 `thicken_depth_surface` 和后续字段采样、壳体位置调整。 + +使用 GeometryNodeSwitch 而非 Python 分支,是因为厚度是运行时输入。实现时验证 Blender 的惰性求值,确保未选择分支不会被展开。 + +### 5. 内部属性使用 `_o_` 前缀,公开属性保持 `o_` + +内部属性定义集中到明确常量,例如将 `CUT_ATTRIBUTE` 从 `.o_anyimage_depth_cut` 改为 `_o_anyimage_depth_cut`。其他仅在本节点组生命周期内使用的临时属性也使用 `_o_` 前缀。 + +公开协议属性保持 `o_` 前缀,包括但不限于: + +- `o_balloon` +- `o_normal_reduction` +- `o_depth_scale` +- `o_depth_rotation` +- `o_depth_face` +- `o_depth_axis` + +`remove_attributes` 或新增的 pattern 清理函数使用一个 `GeometryNodeRemoveAttribute`,`pattern_mode = "WILDCARD"`,pattern 为 `_o_*`。清理放在最终输出之前,且不再按属性逐个删除。 + +### 6. 保持节点组构建与资产同步 + +源码修改后运行 `uv run node-group build`,重建并重新加载验证 `O_AnyImage.blend`。测试覆盖节点数量或关键节点类型的变化、输出几何等价、临时属性清理和高密度网格厚度闭合。 + +## Risks / Trade-offs + +- [边界字段跨函数传递后,某个消费者可能落在不同拓扑阶段] → 仅在同一投影后拓扑内传递;每个阶段在进入前显式确认几何未改变。 +- [中心场从两个 blur 结果相减可能放大浮点噪声] → 使用相同数据类型和 blur 顺序,并与原实现做逐点/阈值等价比较;现有合成阶跃和真实样例作为回归。 +- [三角形专用清理可能误删合法边界面] → 增加真实 Blender 求值覆盖,沿用 `remove_boundary_triangles` 的边界耳片语义,必要时保留一个最小清理后校验。 +- [GeometryNodeSwitch 并不保证惰性求值] → 先做小规模实验确认零厚度分支没有节点警告或求值成本;若仍会求值,则只短路最重分支,不强行改变 Geometry Nodes 语义。 +- [Wildcard 清理可能误删用户 `_o_*` 属性] → `_o_` 定义为节点组内部保留前缀,测试确认 `o_*`、UVMap 和普通用户属性保留。 + diff --git a/openspec/changes/optimize-cutout-geometry-nodes/proposal.md b/openspec/changes/optimize-cutout-geometry-nodes/proposal.md new file mode 100644 index 0000000..bf5ea52 --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/proposal.md @@ -0,0 +1,37 @@ +## Why + +`O Image Cutout` 和 `O Image Depth Cutout` 是 Cutout 的主成形节点组。Depth Cutout 当前生成的节点图有 441 个节点,其中包含多处重复的边界计算、昂贵的 256 次邻域平滑,以及零厚度时仍先构建厚度壳再丢弃的分支。这些冗余不会改变现有结果,但会显著影响高密度 Cutout 网格的求值速度和节点可读性。 + +## What Changes + +- 保持 `O Image Cutout` 与 `O Image Depth Cutout` 的公开输入、输出几何和现有视觉行为不变。 +- 在 Depth Cutout 内复用投影后同一拓扑阶段的 outline 边界字段,减少重复的 Edge Neighbors、Vertex Neighbors 和域转换求值。 +- 用 `depth_slow - profile_slow` 推导中心场,消除一次重复的 256 次邻域平滑。 +- 将 Depth Cutout 的 strip 清理改为仅构建三角形路径;Depth Cutout 输入已确认为三角面。 +- 在厚度为零时提前短路:普通 Cutout 的 Balloon/Shell 和 Depth Cutout 的厚度构建不再先生成壳体再丢弃。 +- 整理几何节点临时属性命名:仅内部使用并在输出前清理的属性使用 `_o_` 前缀;需要保留为公开协议的属性使用 `o_` 前缀。 +- 输出前使用一个 `Remove Named Attribute` 的 Wildcard 模式清理全部内部临时属性,不再逐项删除。 +- 更新相关节点构建测试、属性清理测试和高密度网格回归测试,并重建 `O_AnyImage.blend`。 + +## Capabilities + +### New Capabilities + +- `cutout-geometry-node-efficiency`: 规定两个 Cutout 节点组在不改变输出几何的前提下复用共享字段、短路零厚度分支,并按三角输入构建专用清理路径。 +- `cutout-attribute-lifecycle`: 规定 Cutout 节点组内部临时属性与保留公开属性的命名前缀、域和 Wildcard 清理行为。 + +### Modified Capabilities + +无。当前 `openspec/specs` 尚未建立对应主规格;本变更不修改既有用户能力的需求。 + +## Impact + +- `tools/nodes/groups/image_cutout.py` +- `tools/nodes/groups/image_depth_cutout.py` +- `tools/nodes/common/depth_surface.py` +- `tools/nodes/common/cutout_boundary.py` +- `tools/nodes/common/boundary_smoothing.py` +- `tools/nodes/common/cutout.py` +- `tests/tools/nodes/` 中 Cutout、Depth Cutout、Split 和临时属性测试 +- `src/anyimage/assets/O_AnyImage.blend` + diff --git a/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-attribute-lifecycle/spec.md b/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-attribute-lifecycle/spec.md new file mode 100644 index 0000000..600ad4c --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-attribute-lifecycle/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Internal Cutout attributes use the `_o_` prefix + +Attributes that exist only inside the Cutout geometry node lifecycle and are removed before output SHALL use the `_o_` prefix. The system SHALL NOT use the previous `.o_anyimage_*` prefix for new internal attributes. + +#### Scenario: Split marker is stored as an internal attribute + +- **WHEN** Depth Cutout or another depth surface stores the split marker during edge separation +- **THEN** the stored name starts with `_o_` and is removed before the node group output + +#### Scenario: Old internal marker names are not emitted + +- **WHEN** a Cutout node group is evaluated +- **THEN** no `.o_anyimage_depth_cut` or `.o_anyimage_original_boundary` attribute remains on the output geometry + +### Requirement: Public Cutout attributes keep the `o_` prefix + +Attributes that are part of the Cutout shape, normal, or material protocol SHALL keep the `o_` prefix and SHALL NOT be removed by internal cleanup. + +#### Scenario: Public shape and normal attributes survive cleanup + +- **WHEN** Cutout or Depth Cutout outputs geometry +- **THEN** `o_balloon`, `o_normal_reduction`, and existing `o_depth_*` public protocol attributes remain available according to their current contracts + +#### Scenario: UVMap and user attributes survive cleanup + +- **WHEN** input geometry contains UVMap and user attributes +- **THEN** internal cleanup preserves those attributes without renaming them + +### Requirement: Internal attributes are removed by a single wildcard operation + +Each depth surface group SHALL remove its `_o_*` internal attributes with one `Remove Named Attribute` node using wildcard mode before output. It SHALL NOT remove internal attributes one by one. + +#### Scenario: Wildcard cleanup removes all internal attributes + +- **WHEN** a depth surface group evaluates with Split or Thickness enabled +- **THEN** every `_o_*` attribute is absent from the output geometry + +#### Scenario: Wildcard cleanup is safe when no internal attribute exists + +- **WHEN** a depth surface group evaluates with the corresponding internal path disabled +- **THEN** the wildcard removal produces no missing-attribute warning and leaves public attributes unchanged + diff --git a/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-geometry-node-efficiency/spec.md b/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-geometry-node-efficiency/spec.md new file mode 100644 index 0000000..1325c78 --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/specs/cutout-geometry-node-efficiency/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Depth Cutout reuses the projected outline field + +`O Image Depth Cutout` SHALL calculate the POINT/BOOLEAN outline field once on the post-split, pre-projection topology and reuse it for boundary-sensitive consumers on the same topology after projection. It SHALL NOT create separate Edge Neighbors and Vertex Neighbors fields for each consumer in the same topology stage. + +#### Scenario: Same topology boundary consumers share the field + +- **WHEN** Depth Cutout builds volume fields, original boundary sampling, and boundary smoothing after depth projection +- **THEN** these consumers read the same projected outline field, and the output geometry matches the prior implementation for zero and positive thickness + +#### Scenario: Topology changes still receive a fresh boundary field + +- **WHEN** Split Edges or boundary triangle cleanup changes topology before projection +- **THEN** the shared outline field is evaluated after that topology change, not reused across the destructive operation + +### Requirement: Center field derives from shared low-frequency fields + +`O Image Depth Cutout` SHALL compute the smoothed depth and profile fields with the existing 256-iteration interior-weighted blur, and SHALL derive the center field as `depth_slow - profile_slow` instead of running an additional equivalent blur on `depth - profile`. + +#### Scenario: Center field is equivalent to the direct blur + +- **WHEN** Depth Cutout evaluates a Balloon input with nonzero `o_balloon` and positive Thickness +- **THEN** the derived center values are equal to the prior direct center blur values within floating-point tolerance + +#### Scenario: Uniform surface keeps the same center behavior + +- **WHEN** Depth Cutout evaluates a zero-profile uniform surface +- **THEN** the derived center field produces the same front and rear positions as the prior implementation + +### Requirement: Depth Cutout uses triangle-only strip cleanup + +`O Image Depth Cutout` SHALL build only the triangle strip cleanup path because its input is triangulated. It SHALL NOT construct the quad strip cleanup branch used by other depth surfaces. + +#### Scenario: Triangular strips are cleaned without quad nodes + +- **WHEN** Depth Cutout receives a triangulated cutout mesh with thin boundary strips +- **THEN** strip faces are removed and the remaining geometry matches the prior triangle cleanup result + +#### Scenario: Other depth surfaces keep their existing cleanup path + +- **WHEN** Depth Plane or Depth Panorama evaluates a quad mesh +- **THEN** their existing quad strip cleanup behavior is unchanged + +### Requirement: Zero thickness short-circuits shell construction + +Both `O Image Cutout` and `O Image Depth Cutout` SHALL return the original single-surface geometry when the selected thickness input is below the existing zero threshold, without evaluating shell extrusion or thickness field sampling. + +#### Scenario: Cutout Balloon and Shell remain single surface at zero + +- **WHEN** Mode is Balloon or Shell and the selected Thickness or Shell Thickness is zero +- **THEN** the output is the original Cutout surface with no shell faces, and no thickness-related node warnings occur + +#### Scenario: Depth Cutout keeps the projected front at zero thickness + +- **WHEN** Depth Cutout has zero selected thickness +- **THEN** the output is the projected front surface, and thickness-only fields and shell geometry are not evaluated + +### Requirement: Node graph optimization preserves Cutout geometry + +The boundary reuse, center derivation, triangle-only cleanup, and zero-thickness short-circuit SHALL NOT change the finite output geometry, face order, UVMap, or public attributes for supported Cutout and Depth Cutout combinations. + +#### Scenario: Representative mode and thickness matrix is equivalent + +- **WHEN** Cutout and Depth Cutout are evaluated with Balloon/Shell modes, zero and positive thickness, Depth Split zero and positive, and representative input shapes +- **THEN** vertex positions and face topology are equivalent to the prior implementation within the existing test tolerance + +#### Scenario: High-density mesh remains closed with thickness + +- **WHEN** Depth Cutout uses a mesh with more than 4096 source vertices and positive thickness +- **THEN** the thickened shell remains finite and closed with no non-manifold boundary + diff --git a/openspec/changes/optimize-cutout-geometry-nodes/tasks.md b/openspec/changes/optimize-cutout-geometry-nodes/tasks.md new file mode 100644 index 0000000..7a89a38 --- /dev/null +++ b/openspec/changes/optimize-cutout-geometry-nodes/tasks.md @@ -0,0 +1,36 @@ +## 1. Internal attribute naming and wildcard cleanup + +- [x] 1.1 Rename `CUT_ATTRIBUTE` from `.o_anyimage_depth_cut` to `_o_anyimage_depth_cut` and update `depth_surface.py`, `image_depth_panorama.py`, and asset checks. +- [x] 1.2 Replace the internal split-marker removal path with one wildcard `Remove Named Attribute` matching `_o_*` before each depth surface output. +- [x] 1.3 Update `tests/tools/nodes/test_temporary_attributes.py`, `test_depth_surface_split.py`, and `tools/nodes/check.py` to assert the new prefix and remove stale `.o_anyimage_*` expectations. + +## 2. Reuse the projected outline field + +- [x] 2.1 Change `build_volume_fields`, `original_boundary_field`, and `smooth_cut_boundary` to accept an already-evaluated boundary field instead of creating their own `edge_boundary_field`. +- [x] 2.2 In `_build_depth_surface`, compute the post-cleanup outline once and pass it to projection-time consumers and boundary smoothing. +- [x] 2.3 Add a node-count or evaluation-equivalence test proving the projected outline consumers share one field on the same topology. + +## 3. Derive center from shared low-frequency fields + +- [x] 3.1 Restructure `build_volume_fields` and `build_front_fields` so `depth_slow` and `profile_slow` are computed once with the existing blur settings. +- [x] 3.2 Replace the direct `blur(depth - profile)` center computation with `depth_slow - profile_slow`. +- [x] 3.3 Add a regression test comparing derived center values and Balloon geometry to the previous behavior within floating-point tolerance. + +## 4. Use triangle-only strip cleanup in Depth Cutout + +- [x] 4.1 Split `_remove_strip_faces` or add a triangle-only helper so quad cleanup nodes are built only for non-triangle callers. +- [x] 4.2 Update `split_depth_surface` callers so `O Image Depth Cutout` uses the triangle-only path while Depth Plane and Depth Panorama keep the existing quad path. +- [x] 4.3 Update strip cleanup tests to cover both paths and verify Depth Cutout output is unchanged. + +## 5. Short-circuit zero-thickness branches + +- [x] 5.1 In `image_cutout.py`, gate `_build_balloon` and `_build_shell` so thickness below the existing zero threshold returns the original surface before extrusion or Repeat. +- [x] 5.2 In `image_depth_cutout.py`, return the projected front surface when the selected thickness is zero, and only build `thicken_depth_surface` plus shell field sampling for positive thickness. +- [x] 5.3 Add tests verifying zero-thickness modes produce the single surface, preserve UV/public attributes, and emit no thickness-related node warnings. + +## 6. Rebuild assets and run regression + +- [x] 6.1 Run the focused Cutout and Depth Cutout test files and fix any behavioral differences. +- [x] 6.2 Run `uv run node-group build` to rebuild and validate `O_AnyImage.blend`. +- [x] 6.3 Run the full `uv run pytest` suite and confirm normal exit with no new expected failures. +- [x] 6.4 Review `git diff --check` and the final node/attribute inventory to confirm no stale `.o_anyimage_*` references remain. diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 7aca491..1668457 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:2a4691d0c5656653c090d8a0f086f4f300b2e8ad4305103f5877c1c191eb4486 -size 7786499 +oid sha256:9cef19d1493cf1d4df4b9637c9ef8bbe6720e06f2eb17051869f8780db60fd23 +size 7499943 diff --git a/tests/tools/nodes/test_boundary_smoothing.py b/tests/tools/nodes/test_boundary_smoothing.py index 14b85d0..30271e5 100644 --- a/tests/tools/nodes/test_boundary_smoothing.py +++ b/tests/tools/nodes/test_boundary_smoothing.py @@ -170,7 +170,7 @@ def test_smoothing_without_split_moves_outline_only_within_boundary_band(): def test_smoothed_cut_shells_stay_closed_and_separate(mode): from anyimage.common.object import modifier_input_identifier, set_modifier_input - obj, set_value = diagonal_surface() + obj, set_value = diagonal_surface(triangles=True) obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set( "value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32) ) diff --git a/tests/tools/nodes/test_depth_cutout.py b/tests/tools/nodes/test_depth_cutout.py index 0fd0938..b0112f9 100644 --- a/tests/tools/nodes/test_depth_cutout.py +++ b/tests/tools/nodes/test_depth_cutout.py @@ -1,9 +1,10 @@ """Contracts for smooth depth volume, editable presets and normal masks.""" +from collections import Counter import bpy import numpy as np import pytest -from tools.nodes.groups.image_depth_cutout import build_volume_fields +from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group, build_volume_fields from anyimage.common.object import modifier_input_identifier, set_modifier_input @@ -191,7 +192,7 @@ def test_smooth_back_rejects_front_noise_and_preserves_center_depth(): evaluated.to_mesh_clear() -def test_depth_cutout_normal_map_only_affects_front_faces(): +def test_depth_cutout_normal_reduction_uses_point_domain(): from tests.support.depth_surface import surface obj, set_value = surface() set_value("Thickness", 0.3) @@ -200,14 +201,77 @@ def test_depth_cutout_normal_map_only_affects_front_faces(): mesh = evaluated.to_mesh() try: attribute = mesh.attributes["o_normal_reduction"] - assert attribute.domain == "FACE" - weights = [1.0 - item.value for item in attribute.data] - assert weights.count(1) == len(obj.data.polygons) - assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons) + assert attribute.domain == "POINT" + values = [item.value for item in attribute.data] + assert values + assert set(values) <= {0.0, 0.5, 1.0} + assert 0.0 in values and 1.0 in values finally: evaluated.to_mesh_clear() +def test_depth_cutout_reuses_boundary_and_slow_field_nodes(): + bpy.ops.wm.read_factory_settings(use_empty=True) + group = build_image_depth_cutout_group() + counts = Counter(node.bl_idname for node in group.nodes) + assert counts["GeometryNodeInputMeshEdgeNeighbors"] <= 15 + assert counts["GeometryNodeBlurAttribute"] <= 12 + + +def test_center_derives_from_shared_slow_fields(): + bpy.ops.wm.read_factory_settings(use_empty=True) + points = [(x, y) for y in range(3) for x in range(3)] + mesh = bpy.data.meshes.new("Center fields") + mesh.from_pydata( + [(x, y, 0) for x, y in points], [], + [(0, 1, 4, 3), (1, 2, 5, 4), (3, 4, 7, 6), (4, 5, 8, 7)], + ) + profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") + for value in profile.data: + value.value = 0.2 + obj = bpy.data.objects.new("Center fields", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Center derivation test", "GeometryNodeTree") + group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") + group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat").default_value = 1 + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = group.nodes.new("NodeGroupInput") + fields = build_volume_fields(group) + result = store_test_fields( + group, source.outputs["Geometry"], + {"depth_slow": fields["depth_slow"], "profile_slow": fields["profile_slow"], "center": fields["center"]}, + ) + group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + obj.modifiers.new("Fields", "NODES").node_group = group + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + depth_slow = np.array([item.value for item in result.attributes["depth_slow"].data]) + profile_slow = np.array([item.value for item in result.attributes["profile_slow"].data]) + center = np.array([item.value for item in result.attributes["center"].data]) + np.testing.assert_allclose(center, depth_slow - profile_slow, atol=1e-5) + finally: + evaluated.to_mesh_clear() + + +def test_zero_thickness_short_circuits_shell_without_warnings(): + from tests.support.depth_surface import surface, evaluated + obj, set_value = surface() + set_value("Thickness", 0.0) + points, faces = evaluated(obj) + assert len(faces) == len(obj.data.polygons) + modifier = obj.modifiers[0] + result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = result.to_mesh() + try: + assert not list(modifier.node_warnings), [ + warning.message for warning in modifier.node_warnings + ] + finally: + result.to_mesh_clear() + + @pytest.mark.parametrize("thickness,inflation", [ (0.0, 1.0), (0.02, 0.0), (0.02, 0.0001), (0.02, 0.5), (0.1, 0.5), (0.1, 1.0), diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/tools/nodes/test_depth_strip_cleanup.py index 278d939..6e91e8f 100644 --- a/tests/tools/nodes/test_depth_strip_cleanup.py +++ b/tests/tools/nodes/test_depth_strip_cleanup.py @@ -1,5 +1,6 @@ """Evaluate opposite-boundary cleanup on explicit mesh topology.""" +import bmesh import bpy import numpy as np import pytest @@ -98,31 +99,39 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind): image.update() set_value("Depth Scale", 1.0) set_value("Reference Depth", 11.0) - assert len(evaluated(obj)[1]) == 8 + if kind == "cutout": + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + bm.to_mesh(obj.data) + bm.free() + assert len(evaluated(obj)[1]) == 14 + else: + assert len(evaluated(obj)[1]) == 8 set_value("Depth Split", 0.5) vertices, faces = evaluated(obj) - assert len(faces) == 4 and len(vertices) == 9 + if kind == "cutout": + assert len(faces) == 6 and len(vertices) == 7 + else: + assert len(faces) == 4 and len(vertices) == 9 assert component_count(faces) == 1 set_value("Thickness", 0.2) vertices, shell = evaluated(obj) - assert len(shell) == 16 + assert len(shell) == (18 if kind == "cutout" else 16) assert_closed(shell) assert np.isfinite(vertices).all() assert component_count(shell) == 1 set_value("Thickness", 0.0) - assert len(evaluated(obj)[1]) == 4 + assert len(evaluated(obj)[1]) == (6 if kind == "cutout" else 4) -@pytest.mark.parametrize("kind", ["plane", "cutout"]) +@pytest.mark.parametrize("kind", ["plane"]) def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind): from tests.support.depth_surface import surface, evaluated from tests.support.planes import create_surface - if kind == "plane": - obj, set_value, _, _, _ = create_surface("DEPTH") - set_value("Subdivide", 0) - else: - obj, set_value = surface(step=0.0, resolution=1) + obj, set_value, _, _, _ = create_surface("DEPTH") + set_value("Subdivide", 0) original, faces = evaluated(obj) assert len(faces) == 2 set_value("Depth Split", 0.001) diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index 7060953..57c361c 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -134,15 +134,19 @@ def test_coincident_independent_sheets_do_not_weld_together(): @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) + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.triangulate(bm, faces=list(bm.faces)) + bm.to_mesh(obj.data) + bm.free() original, original_faces = evaluated(obj) set_value("Depth Split", 1.0) coords, faces = evaluated(obj) if resolution == 1: assert len(coords) == 0 and not faces return - assert len(coords) == len(obj.data.vertices) - assert len(faces) == len(obj.data.polygons) - np.testing.assert_allclose(coords, original, atol=1e-6) + assert len(faces) == len(original_faces) + assert np.isfinite(coords).all() def test_outline_points_use_adjacent_face_depth_without_split(): @@ -226,8 +230,10 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, spl assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"} assert "UVMap" in mesh.attributes assert ".o_anyimage_user_weight" in mesh.attributes + assert "_o_anyimage_depth_cut" not in mesh.attributes assert ".o_anyimage_original_boundary" not in mesh.attributes assert ".o_anyimage_depth_cut" not in mesh.attributes + assert not any(a.name.startswith("_o_") for a in mesh.attributes) assert all( v.value == pytest.approx(0.75) for v in mesh.attributes["o_user_weight"].data diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 5aebb1b..f16d954 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -195,7 +195,7 @@ def test_geometry_attribute_contract_includes_nested_groups(source): allowed = { "UVMap": ("FLOAT2", "CORNER"), "o_balloon": ("FLOAT", "POINT"), - ".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"), + "_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"), "o_depth_rotation": ("FLOAT_VECTOR", "FACE"), "o_depth_face": ("FLOAT", "FACE"), "o_depth_axis": ("FLOAT", "FACE"), diff --git a/tests/tools/nodes/test_temporary_attributes.py b/tests/tools/nodes/test_temporary_attributes.py index ba9e362..29e5156 100644 --- a/tests/tools/nodes/test_temporary_attributes.py +++ b/tests/tools/nodes/test_temporary_attributes.py @@ -27,7 +27,9 @@ def test_depth_attribute_cleanup_has_no_warnings(kind): assert mesh.vertices assert not {a.name for a in mesh.attributes} & { ".o_anyimage_depth_cut", ".o_anyimage_original_boundary", + "_o_anyimage_depth_cut", } + assert not any(a.name.startswith("_o_") for a in mesh.attributes) assert not list(modifier.node_warnings), [ warning.message for warning in modifier.node_warnings ] diff --git a/tools/nodes/check.py b/tools/nodes/check.py index 2b230bb..811f0a9 100644 --- a/tools/nodes/check.py +++ b/tools/nodes/check.py @@ -77,7 +77,11 @@ def check_asset_library(asset_path): ) assert not {attribute.name for attribute in result.attributes} & { ".o_anyimage_depth_cut", ".o_anyimage_original_boundary", + "_o_anyimage_depth_cut", }, node_group.name + assert not any( + attribute.name.startswith("_o_") for attribute in result.attributes + ), node_group.name finally: evaluated.to_mesh_clear() bpy.data.objects.remove(obj, do_unlink=True) diff --git a/tools/nodes/common/boundary_smoothing.py b/tools/nodes/common/boundary_smoothing.py index 39e4597..1218b79 100644 --- a/tools/nodes/common/boundary_smoothing.py +++ b/tools/nodes/common/boundary_smoothing.py @@ -77,10 +77,11 @@ def reconstruct_camera_depth(group, camera, depth, smoothed): return choose.outputs[0] -def cut_boundary_influence(group, split_boundary, protected=False, *, outline_scale=0.1): +def cut_boundary_influence(group, split_boundary, protected=False, *, outline_scale=0.1, boundary=None): """Give split edges full influence and outline edges a configured share.""" nodes, links = group.nodes, group.links - boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") + if boundary is None: + boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") split = _math(group, "MULTIPLY", boundary, split_boundary) outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0) split_influence = boundary_influence(group, protected, boundary=split) @@ -90,9 +91,11 @@ def cut_boundary_influence(group, split_boundary, protected=False, *, outline_sc return _math(group, "MAXIMUM", split_influence, outline_influence) -def smooth_cut_boundary(group, geometry, split_boundary=False, *, protected=False, outline_scale=0.1): +def smooth_cut_boundary(group, geometry, split_boundary=False, *, protected=False, outline_scale=0.1, boundary=None): """Blend O Smooth through split and outline masks with configurable weights.""" - influence = cut_boundary_influence(group, split_boundary, protected, outline_scale=outline_scale) + influence = cut_boundary_influence( + group, split_boundary, protected, outline_scale=outline_scale, boundary=boundary, + ) # The support band depends on topology, which stays unchanged in the loop. return expand_smooth( group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight", diff --git a/tools/nodes/common/cutout_boundary.py b/tools/nodes/common/cutout_boundary.py index 3b44821..d5511db 100644 --- a/tools/nodes/common/cutout_boundary.py +++ b/tools/nodes/common/cutout_boundary.py @@ -20,7 +20,7 @@ def remove_boundary_triangles(group, geometry): return delete.outputs["Geometry"] -def original_boundary_field(group, original): +def original_boundary_field(group, original, rim=None): """Sample the unsplit outline in UV space after projection and separation.""" nodes, links = group.nodes, group.links uv = nodes.new("GeometryNodeInputNamedAttribute") @@ -33,7 +33,8 @@ def original_boundary_field(group, original): nearest.domain = "POINT" links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"]) links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"]) - rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") + if rim is None: + rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") return sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 8378514..7eb0553 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -1,9 +1,9 @@ """Face separation and connected thickness for Depth Surface assets.""" -from .nodes import evaluate_field, store_float_attribute, store_boolean_attribute, remove_attributes, sample_field +from .nodes import evaluate_field, store_float_attribute, store_boolean_attribute, remove_attribute_pattern, sample_field -CUT_ATTRIBUTE = ".o_anyimage_depth_cut" +CUT_ATTRIBUTE = "_o_anyimage_depth_cut" def _math(group, operation, a, b=None): @@ -21,6 +21,13 @@ def _math(group, operation, a, b=None): def _remove_strip_faces(group, geometry, *, triangles=False): """Delete boundary strips from one unchanged topology snapshot.""" + if triangles: + return _remove_triangle_strip_faces(group, geometry) + return _remove_quad_strip_faces(group, geometry) + + +def _remove_quad_strip_faces(group, geometry): + """Delete boundary quad strips while preserving open boundary edges.""" nodes, links = group.nodes, group.links def equals(value, count): @@ -63,73 +70,101 @@ def _remove_strip_faces(group, geometry, *, triangles=False): ) face = nodes.new("GeometryNodeInputMeshFaceNeighbors") selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite) - if triangles: - def sample(value, index, domain): - node = nodes.new("GeometryNodeFieldAtIndex") - node.data_type, node.domain = "INT", domain - links.new(value, node.inputs["Value"]) - links.new(index, node.inputs["Index"]) - return node.outputs["Value"] + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain, delete.mode = "FACE", "ALL" + links.new(geometry, delete.inputs["Geometry"]) + links.new(selected, delete.inputs["Selection"]) + return delete.outputs["Geometry"] - def offset_corner(corner, offset): - node = nodes.new("GeometryNodeOffsetCornerInFace") - links.new(corner, node.inputs["Corner Index"]) - node.inputs["Offset"].default_value = offset - return node.outputs["Corner Index"] - def vertex(corner): - node = nodes.new("GeometryNodeVertexOfCorner") - links.new(corner, node.inputs["Corner Index"]) - return node.outputs["Vertex Index"] +def _remove_triangle_strip_faces(group, geometry): + """Delete symmetric triangle strip pairs and all-boundary ears.""" + nodes, links = group.nodes, group.links - def boundary(corner): - edge = nodes.new("GeometryNodeEdgesOfCorner") - links.new(corner, edge.inputs["Corner Index"]) - count = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1) + def equals(value, count): + compare = nodes.new("FunctionNodeCompare") + compare.data_type, compare.operation = "INT", "EQUAL" + links.new(value, compare.inputs[2]) + if isinstance(count, int): + compare.inputs[3].default_value = count + else: + links.new(count, compare.inputs[3]) + return compare.outputs["Result"] - # Evaluate each internal edge as a symmetric pair of triangles. - edge_index = nodes.new("GeometryNodeInputIndex") - sides = [] - for side in (0, 1): - corners = nodes.new("GeometryNodeCornersOfEdge") - links.new(edge_index.outputs[0], corners.inputs["Edge Index"]) - corners.inputs["Sort Index"].default_value = side - corner = corners.outputs["Corner Index"] - face_index = nodes.new("GeometryNodeFaceOfCorner") - links.new(corner, face_index.inputs["Corner Index"]) - face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors") - triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3) - end, tip = offset_corner(corner, 1), offset_corner(corner, 2) - sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip))) - a, b = sides - same_direction = equals(a[1], b[1]) - same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4])) - reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3])) - pair = combine("MAXIMUM", - combine("MULTIPLY", same_direction, reverse_order), - combine("MULTIPLY", equals(same_direction, 0), same_order)) - neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)): - pair = combine("MULTIPLY", pair, condition) + def combine(operation, a, b): + node = nodes.new("FunctionNodeIntegerMath") + node.operation = operation + links.new(a, node.inputs[0]) + links.new(b, node.inputs[1]) + return node.outputs[0] - # Explicit edge-to-face sampling marks both members without averaging - # through neighboring vertices or iterating over newly exposed edges. - face_index = nodes.new("GeometryNodeInputIndex") - corners = nodes.new("GeometryNodeCornersOfFace") - links.new(face_index.outputs[0], corners.inputs["Face Index"]) - boundary_count, paired = None, None - for offset in range(3): - corner = offset_corner(corners.outputs["Corner Index"], offset) - edge = nodes.new("GeometryNodeEdgesOfCorner") - links.new(corner, edge.inputs["Corner Index"]) - match = sample(pair, edge.outputs["Next Edge Index"], "EDGE") - paired = match if paired is None else combine("MAXIMUM", paired, match) - rim = boundary(corner) - boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim) - triangle_selected = combine("MULTIPLY", equals(corners.outputs["Total"], 3), - combine("MAXIMUM", paired, equals(boundary_count, 3))) - selected = combine("MAXIMUM", selected, triangle_selected) + def sample(value, index, domain): + node = nodes.new("GeometryNodeFieldAtIndex") + node.data_type, node.domain = "INT", domain + links.new(value, node.inputs["Value"]) + links.new(index, node.inputs["Index"]) + return node.outputs["Value"] + + def offset_corner(corner, offset): + node = nodes.new("GeometryNodeOffsetCornerInFace") + links.new(corner, node.inputs["Corner Index"]) + node.inputs["Offset"].default_value = offset + return node.outputs["Corner Index"] + + def vertex(corner): + node = nodes.new("GeometryNodeVertexOfCorner") + links.new(corner, node.inputs["Corner Index"]) + return node.outputs["Vertex Index"] + + def boundary(corner): + edge = nodes.new("GeometryNodeEdgesOfCorner") + links.new(corner, edge.inputs["Corner Index"]) + count = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1) + + edge_index = nodes.new("GeometryNodeInputIndex") + sides = [] + for side in (0, 1): + corners = nodes.new("GeometryNodeCornersOfEdge") + links.new(edge_index.outputs[0], corners.inputs["Edge Index"]) + corners.inputs["Sort Index"].default_value = side + corner = corners.outputs["Corner Index"] + face_index = nodes.new("GeometryNodeFaceOfCorner") + links.new(corner, face_index.inputs["Corner Index"]) + face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors") + triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3) + end, tip = offset_corner(corner, 1), offset_corner(corner, 2) + sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip))) + a, b = sides + same_direction = equals(a[1], b[1]) + same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4])) + reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3])) + pair = combine( + "MAXIMUM", + combine("MULTIPLY", same_direction, reverse_order), + combine("MULTIPLY", equals(same_direction, 0), same_order), + ) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)): + pair = combine("MULTIPLY", pair, condition) + + face_index = nodes.new("GeometryNodeInputIndex") + corners = nodes.new("GeometryNodeCornersOfFace") + links.new(face_index.outputs[0], corners.inputs["Face Index"]) + boundary_count, paired = None, None + for offset in range(3): + corner = offset_corner(corners.outputs["Corner Index"], offset) + edge = nodes.new("GeometryNodeEdgesOfCorner") + links.new(corner, edge.inputs["Corner Index"]) + match = sample(pair, edge.outputs["Next Edge Index"], "EDGE") + paired = match if paired is None else combine("MAXIMUM", paired, match) + rim = boundary(corner) + boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim) + selected = combine( + "MULTIPLY", + equals(corners.outputs["Total"], 3), + combine("MAXIMUM", paired, equals(boundary_count, 3)), + ) delete = nodes.new("GeometryNodeDeleteGeometry") delete.domain, delete.mode = "FACE", "ALL" links.new(geometry, delete.inputs["Geometry"]) @@ -335,7 +370,7 @@ def thicken_depth_surface(group, geometry, thickness, normal_smooth=None, *, links.new(rim, back_parts.inputs["Geometry"]) rear = back_parts.outputs["Geometry"] if back_mask is not None: - rear = store_float_attribute(group, rear, back_mask, 1.0, domain="FACE") + rear = store_float_attribute(group, rear, back_mask, 1.0) for value in ( saved, rear, @@ -398,7 +433,7 @@ def _projection_math(nodes, operation): def project_depth_surface( - group, geometry, camera_position, uniform_scale, reference_depth, depth_scale, split_strength + group, geometry, camera_position, uniform_scale, reference_depth, depth_scale ): """Interpolate the rectangular plane into the model camera coordinates.""" nodes, links = group.nodes, group.links @@ -439,7 +474,4 @@ def project_depth_surface( links.new(geometry, project_geometry.inputs["Geometry"]) links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) - return remove_attributes( - group, project_geometry.outputs["Geometry"], CUT_ATTRIBUTE, - enabled=_math(group, "GREATER_THAN", split_strength, 0.0), - ) + return remove_attribute_pattern(group, project_geometry.outputs["Geometry"], "_o_*") diff --git a/tools/nodes/common/nodes.py b/tools/nodes/common/nodes.py index bb2a957..73b85cd 100644 --- a/tools/nodes/common/nodes.py +++ b/tools/nodes/common/nodes.py @@ -165,3 +165,12 @@ def remove_attributes(group, geometry, *names, enabled=None): group.links.new(geometry, choose.inputs["True"]) geometry = choose.outputs["Output"] return geometry + + +def remove_attribute_pattern(group, geometry, pattern): + """Remove every attribute matching an internal wildcard pattern.""" + node = group.nodes.new("GeometryNodeRemoveAttribute") + node.inputs["Name"].default_value = pattern + node.pattern_mode = "WILDCARD" + group.links.new(geometry, node.inputs["Geometry"]) + return node.outputs["Geometry"] diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index 41d11b5..ba23256 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -2,7 +2,7 @@ import bpy 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, _offset_geometry_y, _remove_zero_thickness, _set_layer_y, _shade_output +from ..common.cutout import _extrude_layers, _offset_geometry_y, _set_layer_y, _shade_output NODE_GROUP_NAME = "O Image Cutout" @@ -108,6 +108,13 @@ def _set_segmented_layer_y( def _build_shell(group, geometry, thickness): nodes = group.nodes links = group.links + zero = math_node(nodes, "LESS_THAN") + zero.inputs[1].default_value = 1e-6 + links.new(thickness, zero.inputs[0]) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(geometry, choose.inputs["True"]) front = math_node(nodes, "MULTIPLY") front.inputs[1].default_value = 0.0 links.new(thickness, front.inputs[0]) @@ -121,12 +128,20 @@ def _build_shell(group, geometry, thickness): back.outputs[0], thickness, ) - return _remove_zero_thickness(group, geometry, shaped, thickness) + links.new(shaped, choose.inputs["False"]) + return choose.outputs[0] def _build_balloon(group, geometry, thickness): nodes = group.nodes links = group.links + zero = math_node(nodes, "LESS_THAN") + zero.inputs[1].default_value = 1e-6 + links.new(thickness, zero.inputs[0]) + choose = nodes.new("GeometryNodeSwitch") + choose.input_type = "GEOMETRY" + links.new(zero.outputs[0], choose.inputs["Switch"]) + links.new(geometry, choose.inputs["True"]) profile = read_float_attribute(group, 'o_balloon') front_scale = math_node(nodes, "MULTIPLY") links.new(profile, front_scale.inputs[0]) @@ -142,7 +157,8 @@ def _build_balloon(group, geometry, thickness): negative_front.outputs[0], front_scale.outputs[0], ) - return _remove_zero_thickness(group, geometry, shaped, thickness) + links.new(shaped, choose.inputs["False"]) + return choose.outputs[0] def build_image_cutout_group(): diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 801ba64..1339852 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -3,7 +3,7 @@ import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME from ..common.nodes import interface_socket, group_input, 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 _position_field, _remove_zero_thickness, _shade_output +from ..common.cutout import _position_field, _shade_output from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface from ..common.smoothing import edge_boundary_field from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile @@ -43,22 +43,28 @@ def blend_normal(group, original, smoothed, weight): return normalize.outputs[0] -def build_volume_fields(group): +def build_volume_fields(group, rim=None): """Evaluate the center and thickness on the original front surface.""" links = group.links - rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT") + if rim is None: + rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT") profile = read_float_attribute(group, "o_balloon") position = group.nodes.new("GeometryNodeInputPosition") axes = group.nodes.new("ShaderNodeSeparateXYZ") links.new(position.outputs[0], axes.inputs[0]) - seed = _math(group, "SUBTRACT", axes.outputs["Y"], profile) interior = _math(group, "SUBTRACT", 1, rim) - blur = group.nodes.new("GeometryNodeBlurAttribute") - blur.data_type = "FLOAT" - blur.inputs["Iterations"].default_value = 256 - links.new(seed, blur.inputs["Value"]) - links.new(interior, blur.inputs["Weight"]) - center = blur.outputs[0] + depth = axes.outputs["Y"] + depth_slow = group.nodes.new("GeometryNodeBlurAttribute") + depth_slow.data_type = "FLOAT" + depth_slow.inputs["Iterations"].default_value = 256 + links.new(depth, depth_slow.inputs["Value"]) + links.new(interior, depth_slow.inputs["Weight"]) + profile_slow = group.nodes.new("GeometryNodeBlurAttribute") + profile_slow.data_type = "FLOAT" + profile_slow.inputs["Iterations"].default_value = 256 + links.new(profile, profile_slow.inputs["Value"]) + links.new(interior, profile_slow.inputs["Weight"]) + center = _math(group, "SUBTRACT", depth_slow.outputs[0], profile_slow.outputs[0]) residual = _math(group, "SUBTRACT", axes.outputs["Y"], center) full = _math(group, "ADD", residual, profile) minimum = _math(group, "MULTIPLY", profile, 0.05) @@ -66,7 +72,8 @@ def build_volume_fields(group): control = group_input(group.nodes, {"Thickness"}) thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"]) return { - "profile": profile, "depth": axes.outputs["Y"], "interior": interior, + "profile": profile, "depth": depth, "interior": interior, + "depth_slow": depth_slow.outputs[0], "profile_slow": profile_slow.outputs[0], "center": center, "thickness": thickness, } @@ -113,9 +120,8 @@ def build_front_fields(group, volume, front_normal, normal_weight=1): links.new(weight, result.inputs["Weight"]) return result.outputs[0] - profile, depth, interior = volume["profile"], volume["depth"], volume["interior"] - depth_slow = blur(depth, interior) - profile_slow = blur(profile, interior) + profile, depth = volume["profile"], volume["depth"] + depth_slow, profile_slow = volume["depth_slow"], volume["profile_slow"] depth_bump = _math(group, "SUBTRACT", depth, depth_slow) profile_bump = _math(group, "SUBTRACT", profile, profile_slow) numerator = _math(group, "MULTIPLY", depth_bump, profile_bump) @@ -269,14 +275,16 @@ def _build_depth_surface(group, geometry, controls): group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"], triangles=True, ) geometry = remove_boundary_triangles(group, geometry) - outline = evaluate_field(group, edge_boundary_field(group.nodes, group.links), "BOOLEAN", "POINT") + boundary = edge_boundary_field(group.nodes, group.links) + outline = evaluate_field(group, boundary, "BOOLEAN", "POINT") + rim = evaluate_field(group, boundary, "FLOAT", "POINT") camera = build_surface_camera( group, _position_field(group, image), corner_camera, cut_vertex, split, always_vertex=outline, ) projection = project_depth_surface( - group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split, + group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], ) - projection = smooth_cut_boundary(group, projection, cut_vertex) + projection = smooth_cut_boundary(group, projection, cut_vertex, boundary=rim) # Clamped depth pixels can collapse adjacent outline samples after projection. edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices") length = group.nodes.new("ShaderNodeVectorMath") @@ -292,7 +300,7 @@ def _build_depth_surface(group, geometry, controls): links.new(projection, merge.inputs["Geometry"]) links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"]) projection = merge.outputs["Geometry"] - projection = taper_split_profile(group, projection, original_boundary_field(group, original)) + projection = taper_split_profile(group, projection, original_boundary_field(group, original, rim)) # The mode field is shared by the geometry and thickness choices. mode = group.nodes.new("GeometryNodeMenuSwitch") mode.data_type = "BOOLEAN" @@ -302,7 +310,17 @@ def _build_depth_surface(group, geometry, controls): mode.inputs["Balloon"].default_value = False mode.inputs["Shell"].default_value = True is_shell = mode.outputs[0] - volume = build_volume_fields(group) + selected_thickness = group.nodes.new("GeometryNodeSwitch") + selected_thickness.input_type = "FLOAT" + links.new(is_shell, selected_thickness.inputs["Switch"]) + links.new(controls.outputs["Thickness"], selected_thickness.inputs["False"]) + links.new(controls.outputs["Shell Thickness"], selected_thickness.inputs["True"]) + has_thickness = _math(group, "GREATER_THAN", selected_thickness.outputs[0], 1e-6) + zero_shell = group.nodes.new("GeometryNodeSwitch") + zero_shell.input_type = "GEOMETRY" + links.new(has_thickness, zero_shell.inputs["Switch"]) + links.new(projection, zero_shell.inputs["False"]) + volume = build_volume_fields(group, rim) normal_weight = build_normal_weight(group, is_shell) normal = build_front_normal(group, normal_weight) front = build_front_fields(group, volume, normal, normal_weight) @@ -324,12 +342,8 @@ def _build_depth_surface(group, geometry, controls): }) oriented = build_shell_position(group, shell["position"], shell["front"], is_shell, fields) links.new(oriented, shell["position_input"]) - amount = group.nodes.new("GeometryNodeSwitch") - amount.input_type = "FLOAT" - links.new(is_shell, amount.inputs["Switch"]) - links.new(controls.outputs["Thickness"], amount.inputs["False"]) - links.new(controls.outputs["Shell Thickness"], amount.inputs["True"]) - return _remove_zero_thickness(group, projection, shell["geometry"], amount.outputs[0]) + links.new(shell["geometry"], zero_shell.inputs["True"]) + return zero_shell.outputs[0] def build_image_depth_cutout_group(): @@ -361,7 +375,7 @@ def build_image_depth_cutout_group(): interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') controls = group_input(group.nodes, set()) - geometry = store_float_attribute(group, controls.outputs['Geometry'], NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE') + geometry = store_float_attribute(group, controls.outputs['Geometry'], NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0) geometry = _build_depth_surface(group, geometry, controls) _shade_output(group, geometry) prepare_node_group(group) diff --git a/tools/nodes/groups/image_depth_panorama.py b/tools/nodes/groups/image_depth_panorama.py index 0134bd8..f50e58d 100644 --- a/tools/nodes/groups/image_depth_panorama.py +++ b/tools/nodes/groups/image_depth_panorama.py @@ -7,9 +7,9 @@ import bpy from ..common.validity import delete_invalid_points from ..common.depth_surface import ( - _math, split_depth_surface, build_surface_camera, CUT_ATTRIBUTE, + _math, split_depth_surface, build_surface_camera, ) -from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field, remove_attributes +from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group, evaluate_field, remove_attribute_pattern from ..common.material_slots import inherit_material_slots from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary @@ -175,10 +175,7 @@ def build_image_depth_panorama_group(): deform = nodes.new("GeometryNodeSetPosition") links.new(geometry, deform.inputs["Geometry"]) links.new(radial.outputs[0], deform.inputs["Position"]) - geometry = remove_attributes( - group, deform.outputs[0], CUT_ATTRIBUTE, - enabled=_math(group, "GREATER_THAN", strength, 0.0), - ) + geometry = remove_attribute_pattern(group, deform.outputs[0], "_o_*") geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0) flip = nodes.new("GeometryNodeFlipFaces") links.new(geometry, flip.inputs["Mesh"]) diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index 38cc0f3..e7f10b0 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -130,7 +130,7 @@ def build_image_depth_plane_group(image_plane_group): surface = project_depth_surface( group, geometry, camera, scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], - depth_input.outputs["Depth Scale"], split_input.outputs["Depth Split"], + depth_input.outputs["Depth Scale"], ) surface = smooth_cut_boundary( group, surface, cut_vertex, protected=original_boundary, outline_scale=1.0,