Files
AnyImage/tools/node_assets/validate_cutout.py
nan bbaa4bff46 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.
2026-09-03 12:31:30 +08:00

560 lines
18 KiB
Python

import sys
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.common.object import (
modifier_input_identifier,
set_modifier_input,
)
from anyimage.operators.cutout_tool import object as cutout_objects
from anyimage.operators.cutout_tool import shape as shapes
def evaluated_mesh(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
return evaluated, result
def input_names(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def grid_object(name):
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(name)
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(name, data)
bpy.context.scene.collection.objects.link(obj)
return obj
def add_modifier(obj, shape, thickness):
group = cutout_objects.load_cutout_node_group()
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
for name, value in (
("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]),
("Inward Axis", 1),
("Cleanup", 0.0),
("Smooth", 0.0),
("Smooth Iterations", 0),
("Thickness", thickness),
):
set_modifier_input(
modifier,
modifier_input_identifier(group, name),
value,
)
return modifier
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
wrapper = cutout_objects.load_cutout_node_group()
assert all(
cutout_objects.load_cutout_node_group() == wrapper
for shape in shapes.CUTOUT_SHAPES
)
assert wrapper.name == shapes.CUTOUT_NODE_GROUP_NAME
assert wrapper.is_modifier
groups = {
shape: bpy.data.node_groups[name]
for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items()
}
assert all(not group.is_modifier for group in groups.values())
menu_switch = next(
node for node in wrapper.nodes if node.bl_idname == "GeometryNodeMenuSwitch"
)
assert [item.name for item in menu_switch.enum_items] == list(
shapes.CUTOUT_SHAPE_LABELS.values()
)
assert any(
link.from_socket.name == "Shape"
and link.to_node == menu_switch
and link.to_socket.name == "Menu"
for link in wrapper.links
)
axis_switch = next(
node
for node in wrapper.nodes
if node.bl_idname == "GeometryNodeMenuSwitch"
and [item.name for item in node.enum_items] == ["-Z", "+X"]
)
assert any(
link.from_socket.name == "Inward Axis"
and link.to_node == axis_switch
and link.to_socket.name == "Menu"
for link in wrapper.links
)
assert all(
any(
link.to_node == axis_switch and link.to_socket.name == axis
for link in wrapper.links
)
for axis in ("-Z", "+X")
)
child_nodes = {
node.node_tree: node
for node in wrapper.nodes
if node.bl_idname == "GeometryNodeGroup"
}
assert set(child_nodes) == set(groups.values())
for shape, group in groups.items():
assert any(
link.from_node == child_nodes[group]
and link.to_node == menu_switch
and link.to_socket.name == shapes.CUTOUT_SHAPE_LABELS[shape]
for link in wrapper.links
)
assert input_names(wrapper) == [
"Geometry",
"Shape",
"Inward Axis",
"Thickness",
"Depth Scale",
"Double Sided",
"Smooth Iterations",
"Cleanup",
"Smooth",
"Stretch Limit",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
expected_inputs = {
"SURFACE": ["Geometry", "Thickness"],
"BALLOON": ["Geometry", "Thickness"],
"DEPTH_BALLOON": [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
],
"DEPTH_SURFACE": [
"Geometry",
"Thickness",
"Depth Scale",
"Stretch Limit",
"Reference Depth",
"Uniform Scale",
"Depth Image",
],
}
for shape, group in groups.items():
assert input_names(group) == expected_inputs[shape]
dependencies = {}
for link in wrapper.links:
if link.from_node.bl_idname != "NodeGroupInput":
continue
if link.to_node.bl_idname != "GeometryNodeGroup":
continue
shape = next(
shape for shape, group in groups.items() if group == link.to_node.node_tree
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
"Thickness": set(shapes.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"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
}
repeat_counts = {
shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes)
for shape, group in groups.items()
}
assert repeat_counts == {
"SURFACE": 1,
"BALLOON": 0,
"DEPTH_BALLOON": 0,
"DEPTH_SURFACE": 1,
}
assert (
sum(node.bl_idname == "GeometryNodeRepeatInput" for node in wrapper.nodes) == 1
)
assert (
sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in wrapper.nodes)
== 1
)
assert not any(
node.bl_idname == "GeometryNodeAttributeStatistic" for node in wrapper.nodes
)
assert all(
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
for group in groups.values()
)
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in wrapper.nodes)
smooth = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth"
)
iterations = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth Iterations"
)
cleanup = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Cleanup"
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
stretch_limit = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
)
child_stretch_limit = next(
item
for item in 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 = 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 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 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 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 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
cleanup_scale = next(
link.to_node
for link in wrapper.links
if link.from_socket.name == "Cleanup"
and link.from_node.bl_idname == "NodeGroupInput"
)
assert cleanup_scale.bl_idname == "ShaderNodeMath"
assert cleanup_scale.operation == "MULTIPLY"
assert abs(cleanup_scale.inputs[1].default_value - 0.01) < 1e-7
assert smooth.default_value == 1.0
assert iterations.default_value == 0
assert iterations.min_value == 0
assert getattr(iterations.parent, "name", "") == ""
assert all(
not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes)
for group in groups.values()
)
for group in (wrapper, *groups.values()):
assert all(not node.label for node in group.nodes)
assert all(
link.from_node.location.x <= link.to_node.location.x for link in group.links
)
group_nodes = list(group.nodes)
assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes)
for index, node in enumerate(group_nodes):
for other in group_nodes[index + 1 :]:
assert not (
abs(node.location.x - other.location.x) < 150.0
and abs(node.location.y - other.location.y) < 100.0
), (group.name, node.name, other.name)
for group_input in (
node for node in group_nodes if node.bl_idname == "NodeGroupInput"
):
assert all(
output.is_linked or output.hide
for output in group_input.outputs
if output.name
)
for item in group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT":
assert item.structure_type == "SINGLE"
surface = grid_object("Surface Validation")
add_modifier(surface, "SURFACE", 2.4)
evaluated, result = evaluated_mesh(surface)
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
]
assert len(side_faces) == 24
finally:
evaluated.to_mesh_clear()
balloon = grid_object("Balloon Validation")
profile = balloon.data.attributes.new(
shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT"
)
profile.data.foreach_set(
"value",
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
modifier = add_modifier(balloon, "BALLOON", 1.0)
evaluated, result = evaluated_mesh(balloon)
try:
initial_faces = len(result.polygons)
initial_extent = max(abs(vertex.co.x) for vertex in result.vertices)
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(balloon)
try:
assert len(result.polygons) == initial_faces
extent = max(abs(vertex.co.x) for vertex in result.vertices)
assert abs(extent - initial_extent * 2.0) < 1e-5
finally:
evaluated.to_mesh_clear()
depth_balloon = grid_object("Depth Balloon Metric Validation")
profile = depth_balloon.data.attributes.new(
shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT"
)
profile.data.foreach_set("value", (1.0,) * 9)
modifier = add_modifier(depth_balloon, "DEPTH_BALLOON", 0.0)
point_image = bpy.data.images.new(
"Depth Balloon Points",
width=3,
height=3,
alpha=True,
float_buffer=True,
)
point_image.colorspace_settings.name = "Non-Color"
point_image.alpha_mode = "CHANNEL_PACKED"
pixels = []
for z in range(3):
for y in range(3):
if (y, z) == (1, 1):
depth = 1.5
elif (y, z) == (0, 0):
depth = 2.5
else:
depth = 2.0
pixels.extend((0.0, 0.0, depth, 0.0))
point_image.pixels.foreach_set(pixels)
for name, value in (
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 2.0),
("Depth Direction", (0.0, 0.0, 1.0)),
):
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, name),
value,
)
depth_balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_balloon)
try:
x_values = [vertex.co.x for vertex in result.vertices]
assert abs(min(x_values) + 0.5) < 1e-5
assert abs(max(x_values)) < 1e-5
finally:
evaluated.to_mesh_clear()
pixels[(1 * 3 + 1) * 4 + 3] = -1.0
point_image.pixels.foreach_set(pixels)
point_image.update()
depth_balloon.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_balloon)
try:
x_values = [vertex.co.x for vertex in result.vertices]
assert abs(min(x_values) + 0.5) < 1e-5
assert abs(max(x_values)) < 1e-5
finally:
evaluated.to_mesh_clear()
depth_surface = grid_object("Depth Surface Projection Validation")
modifier = add_modifier(depth_surface, "DEPTH_SURFACE", 0.0)
point_image = bpy.data.images.new(
"Depth Surface Points",
width=3,
height=3,
alpha=True,
float_buffer=True,
)
point_image.colorspace_settings.name = "Non-Color"
point_image.alpha_mode = "CHANNEL_PACKED"
pixels = []
for z in range(3):
for y in range(3):
depth = 7.0 if (y, z) == (0, 0) else 2.0 + y * 0.25
pixels.extend((-y * 0.5, -z * 0.5, depth, 0.0))
point_image.pixels.foreach_set(pixels)
for name, value in (
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 2.0),
("Smooth Iterations", 0),
):
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, name),
value,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
coordinates = [tuple(vertex.co) for vertex in result.vertices]
assert all(
all(
abs(actual - expected) < 1e-5 for actual, expected in zip(point, target)
)
for point, target in zip(
coordinates,
(
(
5.0 if (y, z) == (0, 0) else y * 0.25,
y * 0.5,
z * 0.5,
)
for z in range(3)
for y in range(3)
),
)
)
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Stretch Limit"),
0.6,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
assert 0 < len(result.polygons) < 8
face_edges = {
tuple(sorted((start, end)))
for polygon in result.polygons
for start, end in zip(
polygon.vertices,
(*polygon.vertices[1:], polygon.vertices[0]),
)
}
assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges)
face_vertices = {
vertex
for polygon in result.polygons
for vertex in polygon.vertices
}
assert face_vertices == set(range(len(result.vertices)))
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Depth Scale"),
0.0,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
assert len(result.vertices) == 9
assert len(result.polygons) == 8
finally:
evaluated.to_mesh_clear()
print(
f"Validated Blender {bpy.app.version_string}: "
f"{wrapper.name} wrapping "
f"{', '.join(group.name for group in groups.values())}"
)
if __name__ == "__main__":
main()