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.
This commit is contained in:
2026-09-16 08:42:13 +08:00
parent cf512dc013
commit 7f878a2f3f
22 changed files with 584 additions and 135 deletions

View File

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

View File

@@ -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 和普通用户属性保留。

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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.