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.

Binary file not shown.

View File

@@ -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): def test_smoothed_cut_shells_stay_closed_and_separate(mode):
from anyimage.common.object import modifier_input_identifier, set_modifier_input 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( obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32) "value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32)
) )

View File

@@ -1,9 +1,10 @@
"""Contracts for smooth depth volume, editable presets and normal masks.""" """Contracts for smooth depth volume, editable presets and normal masks."""
from collections import Counter
import bpy import bpy
import numpy as np import numpy as np
import pytest 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 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() 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 from tests.support.depth_surface import surface
obj, set_value = surface() obj, set_value = surface()
set_value("Thickness", 0.3) set_value("Thickness", 0.3)
@@ -200,14 +201,77 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
mesh = evaluated.to_mesh() mesh = evaluated.to_mesh()
try: try:
attribute = mesh.attributes["o_normal_reduction"] attribute = mesh.attributes["o_normal_reduction"]
assert attribute.domain == "FACE" assert attribute.domain == "POINT"
weights = [1.0 - item.value for item in attribute.data] values = [item.value for item in attribute.data]
assert weights.count(1) == len(obj.data.polygons) assert values
assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons) assert set(values) <= {0.0, 0.5, 1.0}
assert 0.0 in values and 1.0 in values
finally: finally:
evaluated.to_mesh_clear() 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", [ @pytest.mark.parametrize("thickness,inflation", [
(0.0, 1.0), (0.02, 0.0), (0.02, 0.0001), (0.0, 1.0), (0.02, 0.0), (0.02, 0.0001),
(0.02, 0.5), (0.1, 0.5), (0.1, 1.0), (0.02, 0.5), (0.1, 0.5), (0.1, 1.0),

View File

@@ -1,5 +1,6 @@
"""Evaluate opposite-boundary cleanup on explicit mesh topology.""" """Evaluate opposite-boundary cleanup on explicit mesh topology."""
import bmesh
import bpy import bpy
import numpy as np import numpy as np
import pytest import pytest
@@ -98,31 +99,39 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind):
image.update() image.update()
set_value("Depth Scale", 1.0) set_value("Depth Scale", 1.0)
set_value("Reference Depth", 11.0) set_value("Reference Depth", 11.0)
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 assert len(evaluated(obj)[1]) == 8
set_value("Depth Split", 0.5) set_value("Depth Split", 0.5)
vertices, faces = evaluated(obj) vertices, faces = evaluated(obj)
if kind == "cutout":
assert len(faces) == 6 and len(vertices) == 7
else:
assert len(faces) == 4 and len(vertices) == 9 assert len(faces) == 4 and len(vertices) == 9
assert component_count(faces) == 1 assert component_count(faces) == 1
set_value("Thickness", 0.2) set_value("Thickness", 0.2)
vertices, shell = evaluated(obj) vertices, shell = evaluated(obj)
assert len(shell) == 16 assert len(shell) == (18 if kind == "cutout" else 16)
assert_closed(shell) assert_closed(shell)
assert np.isfinite(vertices).all() assert np.isfinite(vertices).all()
assert component_count(shell) == 1 assert component_count(shell) == 1
set_value("Thickness", 0.0) 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): def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
from tests.support.depth_surface import surface, evaluated from tests.support.depth_surface import surface, evaluated
from tests.support.planes import create_surface from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, _ = create_surface("DEPTH") obj, set_value, _, _, _ = create_surface("DEPTH")
set_value("Subdivide", 0) set_value("Subdivide", 0)
else:
obj, set_value = surface(step=0.0, resolution=1)
original, faces = evaluated(obj) original, faces = evaluated(obj)
assert len(faces) == 2 assert len(faces) == 2
set_value("Depth Split", 0.001) set_value("Depth Split", 0.001)

View File

@@ -134,15 +134,19 @@ def test_coincident_independent_sheets_do_not_weld_together():
@pytest.mark.parametrize("resolution", [1, 4]) @pytest.mark.parametrize("resolution", [1, 4])
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution): def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=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) original, original_faces = evaluated(obj)
set_value("Depth Split", 1.0) set_value("Depth Split", 1.0)
coords, faces = evaluated(obj) coords, faces = evaluated(obj)
if resolution == 1: if resolution == 1:
assert len(coords) == 0 and not faces assert len(coords) == 0 and not faces
return return
assert len(coords) == len(obj.data.vertices) assert len(faces) == len(original_faces)
assert len(faces) == len(obj.data.polygons) assert np.isfinite(coords).all()
np.testing.assert_allclose(coords, original, atol=1e-6)
def test_outline_points_use_adjacent_face_depth_without_split(): 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 {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 "UVMap" in mesh.attributes
assert ".o_anyimage_user_weight" 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_original_boundary" not in mesh.attributes
assert ".o_anyimage_depth_cut" 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( assert all(
v.value == pytest.approx(0.75) v.value == pytest.approx(0.75)
for v in mesh.attributes["o_user_weight"].data for v in mesh.attributes["o_user_weight"].data

View File

@@ -195,7 +195,7 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
allowed = { allowed = {
"UVMap": ("FLOAT2", "CORNER"), "UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"), "o_balloon": ("FLOAT", "POINT"),
".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"), "_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"), "o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
"o_depth_face": ("FLOAT", "FACE"), "o_depth_face": ("FLOAT", "FACE"),
"o_depth_axis": ("FLOAT", "FACE"), "o_depth_axis": ("FLOAT", "FACE"),

View File

@@ -27,7 +27,9 @@ def test_depth_attribute_cleanup_has_no_warnings(kind):
assert mesh.vertices assert mesh.vertices
assert not {a.name for a in mesh.attributes} & { assert not {a.name for a in mesh.attributes} & {
".o_anyimage_depth_cut", ".o_anyimage_original_boundary", ".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), [ assert not list(modifier.node_warnings), [
warning.message for warning in modifier.node_warnings warning.message for warning in modifier.node_warnings
] ]

View File

@@ -77,7 +77,11 @@ def check_asset_library(asset_path):
) )
assert not {attribute.name for attribute in result.attributes} & { assert not {attribute.name for attribute in result.attributes} & {
".o_anyimage_depth_cut", ".o_anyimage_original_boundary", ".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
"_o_anyimage_depth_cut",
}, node_group.name }, node_group.name
assert not any(
attribute.name.startswith("_o_") for attribute in result.attributes
), node_group.name
finally: finally:
evaluated.to_mesh_clear() evaluated.to_mesh_clear()
bpy.data.objects.remove(obj, do_unlink=True) bpy.data.objects.remove(obj, do_unlink=True)

View File

@@ -77,9 +77,10 @@ def reconstruct_camera_depth(group, camera, depth, smoothed):
return choose.outputs[0] 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.""" """Give split edges full influence and outline edges a configured share."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
if boundary is None:
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
split = _math(group, "MULTIPLY", boundary, split_boundary) split = _math(group, "MULTIPLY", boundary, split_boundary)
outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0) outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0)
@@ -90,9 +91,11 @@ def cut_boundary_influence(group, split_boundary, protected=False, *, outline_sc
return _math(group, "MAXIMUM", split_influence, outline_influence) 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.""" """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. # The support band depends on topology, which stays unchanged in the loop.
return expand_smooth( return expand_smooth(
group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight", group, geometry, iterations_name="Boundary Smooth", weight_name="Smooth Weight",

View File

@@ -20,7 +20,7 @@ def remove_boundary_triangles(group, geometry):
return delete.outputs["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.""" """Sample the unsplit outline in UV space after projection and separation."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute") uv = nodes.new("GeometryNodeInputNamedAttribute")
@@ -33,6 +33,7 @@ def original_boundary_field(group, original):
nearest.domain = "POINT" nearest.domain = "POINT"
links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"]) links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"])
links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"]) links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"])
if rim is None:
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT") rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
return sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"]) return sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])

View File

@@ -1,9 +1,9 @@
"""Face separation and connected thickness for Depth Surface assets.""" """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): 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): def _remove_strip_faces(group, geometry, *, triangles=False):
"""Delete boundary strips from one unchanged topology snapshot.""" """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 nodes, links = group.nodes, group.links
def equals(value, count): def equals(value, count):
@@ -63,7 +70,34 @@ def _remove_strip_faces(group, geometry, *, triangles=False):
) )
face = nodes.new("GeometryNodeInputMeshFaceNeighbors") face = nodes.new("GeometryNodeInputMeshFaceNeighbors")
selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite) selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite)
if triangles: 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 _remove_triangle_strip_faces(group, geometry):
"""Delete symmetric triangle strip pairs and all-boundary ears."""
nodes, links = group.nodes, group.links
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"]
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]
def sample(value, index, domain): def sample(value, index, domain):
node = nodes.new("GeometryNodeFieldAtIndex") node = nodes.new("GeometryNodeFieldAtIndex")
node.data_type, node.domain = "INT", domain node.data_type, node.domain = "INT", domain
@@ -88,7 +122,6 @@ def _remove_strip_faces(group, geometry, *, triangles=False):
count = nodes.new("GeometryNodeInputMeshEdgeNeighbors") count = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1) return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1)
# Evaluate each internal edge as a symmetric pair of triangles.
edge_index = nodes.new("GeometryNodeInputIndex") edge_index = nodes.new("GeometryNodeInputIndex")
sides = [] sides = []
for side in (0, 1): for side in (0, 1):
@@ -106,15 +139,15 @@ def _remove_strip_faces(group, geometry, *, triangles=False):
same_direction = equals(a[1], b[1]) same_direction = equals(a[1], b[1])
same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4])) 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])) reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3]))
pair = combine("MAXIMUM", pair = combine(
"MAXIMUM",
combine("MULTIPLY", same_direction, reverse_order), combine("MULTIPLY", same_direction, reverse_order),
combine("MULTIPLY", equals(same_direction, 0), same_order)) combine("MULTIPLY", equals(same_direction, 0), same_order),
)
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)): 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) pair = combine("MULTIPLY", pair, condition)
# Explicit edge-to-face sampling marks both members without averaging
# through neighboring vertices or iterating over newly exposed edges.
face_index = nodes.new("GeometryNodeInputIndex") face_index = nodes.new("GeometryNodeInputIndex")
corners = nodes.new("GeometryNodeCornersOfFace") corners = nodes.new("GeometryNodeCornersOfFace")
links.new(face_index.outputs[0], corners.inputs["Face Index"]) links.new(face_index.outputs[0], corners.inputs["Face Index"])
@@ -127,9 +160,11 @@ def _remove_strip_faces(group, geometry, *, triangles=False):
paired = match if paired is None else combine("MAXIMUM", paired, match) paired = match if paired is None else combine("MAXIMUM", paired, match)
rim = boundary(corner) rim = boundary(corner)
boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim) boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim)
triangle_selected = combine("MULTIPLY", equals(corners.outputs["Total"], 3), selected = combine(
combine("MAXIMUM", paired, equals(boundary_count, 3))) "MULTIPLY",
selected = combine("MAXIMUM", selected, triangle_selected) equals(corners.outputs["Total"], 3),
combine("MAXIMUM", paired, equals(boundary_count, 3)),
)
delete = nodes.new("GeometryNodeDeleteGeometry") delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL" delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"]) 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"]) links.new(rim, back_parts.inputs["Geometry"])
rear = back_parts.outputs["Geometry"] rear = back_parts.outputs["Geometry"]
if back_mask is not None: 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 ( for value in (
saved, saved,
rear, rear,
@@ -398,7 +433,7 @@ def _projection_math(nodes, operation):
def project_depth_surface( 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.""" """Interpolate the rectangular plane into the model camera coordinates."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
@@ -439,7 +474,4 @@ def project_depth_surface(
links.new(geometry, project_geometry.inputs["Geometry"]) links.new(geometry, project_geometry.inputs["Geometry"])
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"]) links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])
return remove_attributes( return remove_attribute_pattern(group, project_geometry.outputs["Geometry"], "_o_*")
group, project_geometry.outputs["Geometry"], CUT_ATTRIBUTE,
enabled=_math(group, "GREATER_THAN", split_strength, 0.0),
)

View File

@@ -165,3 +165,12 @@ def remove_attributes(group, geometry, *names, enabled=None):
group.links.new(geometry, choose.inputs["True"]) group.links.new(geometry, choose.inputs["True"])
geometry = choose.outputs["Output"] geometry = choose.outputs["Output"]
return geometry 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"]

View File

@@ -2,7 +2,7 @@
import bpy import bpy
from anyimage.operators.cutout_tool.shape import 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.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" NODE_GROUP_NAME = "O Image Cutout"
@@ -108,6 +108,13 @@ def _set_segmented_layer_y(
def _build_shell(group, geometry, thickness): def _build_shell(group, geometry, thickness):
nodes = group.nodes nodes = group.nodes
links = group.links 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 = math_node(nodes, "MULTIPLY")
front.inputs[1].default_value = 0.0 front.inputs[1].default_value = 0.0
links.new(thickness, front.inputs[0]) links.new(thickness, front.inputs[0])
@@ -121,12 +128,20 @@ def _build_shell(group, geometry, thickness):
back.outputs[0], back.outputs[0],
thickness, 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): def _build_balloon(group, geometry, thickness):
nodes = group.nodes nodes = group.nodes
links = group.links 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') profile = read_float_attribute(group, 'o_balloon')
front_scale = math_node(nodes, "MULTIPLY") front_scale = math_node(nodes, "MULTIPLY")
links.new(profile, front_scale.inputs[0]) links.new(profile, front_scale.inputs[0])
@@ -142,7 +157,8 @@ def _build_balloon(group, geometry, thickness):
negative_front.outputs[0], negative_front.outputs[0],
front_scale.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(): def build_image_cutout_group():

View File

@@ -3,7 +3,7 @@ import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME 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.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.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.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.smoothing import edge_boundary_field
from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile 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] 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.""" """Evaluate the center and thickness on the original front surface."""
links = group.links links = group.links
if rim is None:
rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT") rim = evaluate_field(group, edge_boundary_field(group.nodes, links), "FLOAT", "POINT")
profile = read_float_attribute(group, "o_balloon") profile = read_float_attribute(group, "o_balloon")
position = group.nodes.new("GeometryNodeInputPosition") position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ") axes = group.nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], axes.inputs[0]) links.new(position.outputs[0], axes.inputs[0])
seed = _math(group, "SUBTRACT", axes.outputs["Y"], profile)
interior = _math(group, "SUBTRACT", 1, rim) interior = _math(group, "SUBTRACT", 1, rim)
blur = group.nodes.new("GeometryNodeBlurAttribute") depth = axes.outputs["Y"]
blur.data_type = "FLOAT" depth_slow = group.nodes.new("GeometryNodeBlurAttribute")
blur.inputs["Iterations"].default_value = 256 depth_slow.data_type = "FLOAT"
links.new(seed, blur.inputs["Value"]) depth_slow.inputs["Iterations"].default_value = 256
links.new(interior, blur.inputs["Weight"]) links.new(depth, depth_slow.inputs["Value"])
center = blur.outputs[0] 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) residual = _math(group, "SUBTRACT", axes.outputs["Y"], center)
full = _math(group, "ADD", residual, profile) full = _math(group, "ADD", residual, profile)
minimum = _math(group, "MULTIPLY", profile, 0.05) minimum = _math(group, "MULTIPLY", profile, 0.05)
@@ -66,7 +72,8 @@ def build_volume_fields(group):
control = group_input(group.nodes, {"Thickness"}) control = group_input(group.nodes, {"Thickness"})
thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"]) thickness = _math(group, "MULTIPLY", positive, control.outputs["Thickness"])
return { 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, "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"]) links.new(weight, result.inputs["Weight"])
return result.outputs[0] return result.outputs[0]
profile, depth, interior = volume["profile"], volume["depth"], volume["interior"] profile, depth = volume["profile"], volume["depth"]
depth_slow = blur(depth, interior) depth_slow, profile_slow = volume["depth_slow"], volume["profile_slow"]
profile_slow = blur(profile, interior)
depth_bump = _math(group, "SUBTRACT", depth, depth_slow) depth_bump = _math(group, "SUBTRACT", depth, depth_slow)
profile_bump = _math(group, "SUBTRACT", profile, profile_slow) profile_bump = _math(group, "SUBTRACT", profile, profile_slow)
numerator = _math(group, "MULTIPLY", depth_bump, profile_bump) 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, group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"], triangles=True,
) )
geometry = remove_boundary_triangles(group, geometry) 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( camera = build_surface_camera(
group, _position_field(group, image), corner_camera, cut_vertex, split, always_vertex=outline, group, _position_field(group, image), corner_camera, cut_vertex, split, always_vertex=outline,
) )
projection = project_depth_surface( 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. # Clamped depth pixels can collapse adjacent outline samples after projection.
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices") edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
length = group.nodes.new("ShaderNodeVectorMath") length = group.nodes.new("ShaderNodeVectorMath")
@@ -292,7 +300,7 @@ def _build_depth_surface(group, geometry, controls):
links.new(projection, merge.inputs["Geometry"]) links.new(projection, merge.inputs["Geometry"])
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"]) links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
projection = merge.outputs["Geometry"] 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. # The mode field is shared by the geometry and thickness choices.
mode = group.nodes.new("GeometryNodeMenuSwitch") mode = group.nodes.new("GeometryNodeMenuSwitch")
mode.data_type = "BOOLEAN" mode.data_type = "BOOLEAN"
@@ -302,7 +310,17 @@ def _build_depth_surface(group, geometry, controls):
mode.inputs["Balloon"].default_value = False mode.inputs["Balloon"].default_value = False
mode.inputs["Shell"].default_value = True mode.inputs["Shell"].default_value = True
is_shell = mode.outputs[0] 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_weight = build_normal_weight(group, is_shell)
normal = build_front_normal(group, normal_weight) normal = build_front_normal(group, normal_weight)
front = build_front_fields(group, volume, normal, 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) oriented = build_shell_position(group, shell["position"], shell["front"], is_shell, fields)
links.new(oriented, shell["position_input"]) links.new(oriented, shell["position_input"])
amount = group.nodes.new("GeometryNodeSwitch") links.new(shell["geometry"], zero_shell.inputs["True"])
amount.input_type = "FLOAT" return zero_shell.outputs[0]
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])
def build_image_depth_cutout_group(): 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, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set()) 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) geometry = _build_depth_surface(group, geometry, controls)
_shade_output(group, geometry) _shade_output(group, geometry)
prepare_node_group(group) prepare_node_group(group)

View File

@@ -7,9 +7,9 @@ import bpy
from ..common.validity import delete_invalid_points from ..common.validity import delete_invalid_points
from ..common.depth_surface import ( 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.material_slots import inherit_material_slots
from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary 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") deform = nodes.new("GeometryNodeSetPosition")
links.new(geometry, deform.inputs["Geometry"]) links.new(geometry, deform.inputs["Geometry"])
links.new(radial.outputs[0], deform.inputs["Position"]) links.new(radial.outputs[0], deform.inputs["Position"])
geometry = remove_attributes( geometry = remove_attribute_pattern(group, deform.outputs[0], "_o_*")
group, deform.outputs[0], CUT_ATTRIBUTE,
enabled=_math(group, "GREATER_THAN", strength, 0.0),
)
geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0) geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0)
flip = nodes.new("GeometryNodeFlipFaces") flip = nodes.new("GeometryNodeFlipFaces")
links.new(geometry, flip.inputs["Mesh"]) links.new(geometry, flip.inputs["Mesh"])

View File

@@ -130,7 +130,7 @@ def build_image_depth_plane_group(image_plane_group):
surface = project_depth_surface( surface = project_depth_surface(
group, geometry, camera, group, geometry, camera,
scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"], 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( surface = smooth_cut_boundary(
group, surface, cut_vertex, protected=original_boundary, outline_scale=1.0, group, surface, cut_vertex, protected=original_boundary, outline_scale=1.0,