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

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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