feat: Implement Depth Surface Stretch Limit for improved edge culling

- Added a new "Stretch Limit" parameter to the Depth Surface node, allowing for dynamic edge culling based on the ratio of original to projected edge lengths.
- Updated the geometry processing to capture original edge lengths before projection and calculate current lengths after projection.
- Implemented logic to delete edges and faces that exceed the stretch limit, ensuring cleaner topology.
- Enhanced tests to validate the new functionality, including scenarios for edge retention and deletion based on stretch limits.
- Updated documentation and node assets to reflect the new parameter and its implications on geometry processing.
This commit is contained in:
2026-09-03 12:31:30 +08:00
parent bdbde1a10d
commit bbaa4bff46
9 changed files with 677 additions and 13 deletions

View File

@@ -351,6 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Stretch Limit",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
@@ -361,6 +362,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert [item.name for item in panels[0].interface_items] == [
"Cleanup",
"Smooth",
"Stretch Limit",
"Reference Depth",
"Depth Direction",
]
@@ -429,6 +431,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Geometry",
"Thickness",
"Depth Scale",
"Stretch Limit",
"Reference Depth",
"Uniform Scale",
"Depth Image",
@@ -449,6 +452,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert dependencies == {
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Stretch Limit": {"DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
@@ -503,6 +507,112 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
stretch_limit = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
)
child_stretch_limit = next(
item
for item in child_groups["DEPTH_SURFACE"].interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
)
assert stretch_limit.default_value == 0.0
assert stretch_limit.min_value == 0.0
assert stretch_limit.max_value == 1.0
assert stretch_limit.subtype == "FACTOR"
assert getattr(stretch_limit.parent, "name", "") == "Options"
assert child_stretch_limit.default_value == 0.0
assert child_stretch_limit.min_value == 0.0
assert child_stretch_limit.max_value == 1.0
assert child_stretch_limit.subtype == "FACTOR"
assert getattr(child_stretch_limit.parent, "name", "") == "Options"
depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes
captures = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeCaptureAttribute"
]
assert len(captures) == 1
assert captures[0].domain == "POINT"
assert [item.name for item in captures[0].capture_items] == [
"Original Edge Length"
]
captured_distance = next(
link.from_node
for link in child_groups["DEPTH_SURFACE"].links
if link.to_node == captures[0]
and link.to_socket.name == "Original Edge Length"
)
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
assert captured_distance.operation == "DISTANCE"
ratio = next(
link.to_node
for link in child_groups["DEPTH_SURFACE"].links
if link.from_node == captured_distance
and link.to_node.bl_idname == "ShaderNodeMath"
and link.to_node.operation == "DIVIDE"
)
assert any(
link.from_node == captures[0]
and link.to_node == ratio
and link.to_socket == ratio.inputs[0]
for link in child_groups["DEPTH_SURFACE"].links
)
assert any(
link.from_node == captured_distance
and link.to_node == ratio
and link.to_socket == ratio.inputs[1]
for link in child_groups["DEPTH_SURFACE"].links
)
captured_distance = next(
link.from_node
for link in child_groups["DEPTH_SURFACE"].links
if link.to_node == captures[0]
and link.to_socket.name == "Original Edge Length"
)
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
assert captured_distance.operation == "DISTANCE"
ratio = next(
link.to_node
for link in child_groups["DEPTH_SURFACE"].links
if link.from_node == captured_distance
and link.to_node.bl_idname == "ShaderNodeMath"
and link.to_node.operation == "DIVIDE"
)
assert any(
link.from_node == captures[0]
and link.to_node == ratio
and link.to_socket == ratio.inputs[0]
for link in child_groups["DEPTH_SURFACE"].links
)
assert any(
link.from_node == captured_distance
and link.to_node == ratio
and link.to_socket == ratio.inputs[1]
for link in child_groups["DEPTH_SURFACE"].links
)
samples = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeSampleIndex"
]
assert not samples
delete_domains = [
node.domain
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeDeleteGeometry"
]
assert delete_domains.count("EDGE") == 1
assert delete_domains.count("POINT") == 2
assert any(
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
for node in depth_surface_nodes
)
assert any(
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
for node in depth_surface_nodes
)
cleanup_scale = next(
link.to_node
for link in cutout.links
@@ -1234,6 +1344,112 @@ def _add_cutout_modifier(obj, shape):
return modifier
def _depth_surface_stretch_object():
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
faces = []
for z in range(2):
for y in range(2):
a = z * 3 + y
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
data = bpy.data.meshes.new("Depth Surface Stretch")
data.from_pydata(vertices, (), faces)
uv_layer = data.uv_layers.new(name="UVMap")
for loop in data.loops:
coordinate = data.vertices[loop.vertex_index].co
uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5)
obj = bpy.data.objects.new("Depth Surface Stretch", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE")
points = np.empty((3, 3, 3), dtype=np.float32)
for z in range(3):
for y in range(3):
points[z, y] = (-float(y), -float(z), 1.0)
points[0, 0, 2] = 6.0
depth_image = _depth_image(points, np.ones((3, 3), dtype=bool))
for name, value in (
("Depth Image", depth_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 1.0),
("Thickness", 0.0),
("Smooth Iterations", 0),
):
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, name),
value,
)
return obj, modifier
def _evaluated_topology(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
face_edges = {
tuple(sorted((start, end)))
for polygon in result.polygons
for start, end in zip(
polygon.vertices,
(*polygon.vertices[1:], polygon.vertices[0]),
)
}
mesh_edges = {
tuple(sorted(edge.vertices))
for edge in result.edges
}
face_vertices = {
vertex
for polygon in result.polygons
for vertex in polygon.vertices
}
return (
len(result.vertices),
len(result.polygons),
mesh_edges - face_edges,
set(range(len(result.vertices))) - face_vertices,
)
finally:
evaluated.to_mesh_clear()
def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_stretch_object()
stretch_id = object_data.modifier_input_identifier(
modifier.node_group,
"Stretch Limit",
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.set_modifier_input(modifier, stretch_id, 0.0)
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
object_data.set_modifier_input(modifier, stretch_id, 0.5)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
object_data.set_modifier_input(modifier, stretch_id, 0.6)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert 0 < faces < 8
assert vertices < 9
assert not loose_edges
assert not loose_points
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
def test_cutout_thickness_keeps_one_connected_shell():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]