Files
AnyImage/tools/node_assets/validate_cutout.py
nan 7dec3b7ca4 Add depth surface processing and layout tools
- Implemented `cutout_depth_surface.py` for face separation and connected thickness calculations for depth surface assets.
- Created `cutout_depth_surface_layout.py` to arrange nodes for staggered calculation branches in depth surface processing.
- Authored `geometry_layouts.py` to define layouts for various geometry assets, including image cutouts and depth planes.
- Introduced `node_layout.py` for shared presentation rules, facilitating node arrangement and validation in geometry-node layouts.
2026-09-07 19:21:03 +08:00

678 lines
23 KiB
Python

import sys
from collections import Counter
from itertools import pairwise
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
0,
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from node_layout import validate_node_layout
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 validate_depth_surface_layout(group):
"""Check the geometry path, stage spacing and drawn node bounds."""
nodes = [node for node in group.nodes if node.bl_idname != "NodeReroute"]
main = [
group.nodes[name]
for name in (
"Store Named Attribute",
"Store Named Attribute.001",
"Store Named Attribute.002",
"Store Named Attribute.003",
"Split Edges",
"Switch",
"Delete Geometry",
"Store Named Attribute.004",
"Set Position",
"Store Named Attribute.005",
"Set Position.001",
"Store Named Attribute.006",
"Store Named Attribute.007",
"Repeat Input",
"Extrude Mesh",
"Separate Geometry",
"Repeat Output",
"Flip Faces.001",
"Join Geometry.001",
"Set Position.003",
"Merge by Distance",
"Set Position.004",
"Switch.002",
"Remove Named Attribute",
"Set Shade Smooth",
"Group Output",
)
]
assert len({node.location.y for node in main}) == 1
assert all(right.location.x > left.location.x for left, right in pairwise(main))
for left, right in (
("Switch", "Delete Geometry"),
("Delete Geometry", "Store Named Attribute.004"),
("Set Position", "Store Named Attribute.005"),
("Switch.002", "Remove Named Attribute"),
):
a, b = group.nodes[left], group.nodes[right]
assert b.location.x - (a.location.x + a.width) >= 280
assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes)
assert min(n.location.x for n in nodes) < main[0].location.x
def height(node):
if node.dimensions.y:
return node.dimensions.y
if node.hide or node.bl_idname == "NodeReroute":
return 30.0
if node.bl_idname == "GeometryNodeJoinGeometry":
return 60.0
return node.height
for index, node in enumerate(nodes):
width = node.dimensions.x or node.width
for other in nodes[index + 1 :]:
other_width = other.dimensions.x or other.width
overlap = (
node.location.x < other.location.x + other_width
and node.location.x + width > other.location.x
and node.location.y - height(node) < other.location.y
and node.location.y > other.location.y - height(other)
)
assert not overlap, (node.name, other.name)
repeat_input = next(
node for node in nodes if node.bl_idname == "GeometryNodeRepeatInput"
)
repeat_output = repeat_input.paired_output
inside = {repeat_output}
pending = [repeat_input]
while pending:
node = pending.pop()
if node in inside:
continue
inside.add(node)
pending.extend(link.to_node for link in group.links if link.from_node == node)
zone_bottom = min(node.location.y - height(node) for node in inside) - 60.0
zone_top = max(node.location.y for node in inside) + 60.0
for node in nodes:
if repeat_input.location.x <= node.location.x <= repeat_output.location.x:
assert (
node in inside
or node.location.y <= zone_bottom
or node.location.y - height(node) >= zone_top
), node.name
def validate_depth_surface_attributes(group):
"""Check temporary fields are typed, locally read and removed at output."""
expected = {
"o_depth_face_center": ("FLOAT_VECTOR", "FACE"),
"o_depth_face_camera": ("FLOAT_VECTOR", "FACE"),
"o_depth_corner_camera": ("FLOAT_VECTOR", "CORNER"),
"o_depth_cut_vertex": ("BOOLEAN", "CORNER"),
"o_depth_camera": ("FLOAT_VECTOR", "POINT"),
"o_depth_surface_position": ("FLOAT_VECTOR", "POINT"),
"o_depth_source_vertex": ("INT", "POINT"),
"o_depth_wall_layers": ("FLOAT", "POINT"),
}
stores = {
n.inputs["Name"].default_value: (n.data_type, n.domain)
for n in group.nodes
if n.bl_idname == "GeometryNodeStoreNamedAttribute"
}
assert stores == expected
assert not any(n.bl_idname == "GeometryNodeCaptureAttribute" for n in group.nodes)
removes = [n for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute"]
assert len(removes) == 1
assert removes[0].pattern_mode == "WILDCARD"
assert removes[0].inputs["Name"].default_value == "o_depth_*"
for node in group.nodes:
if node.bl_idname == "GeometryNodeInputNamedAttribute":
name = node.inputs["Name"].default_value
assert name == "UVMap" or node.data_type == expected[name][0]
assert all(
abs(link.to_node.location.x - node.location.x) <= 600
for link in group.links
if link.from_node == node
)
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 = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
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.x * 0.5, coordinate.y * 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", 0),
("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()
}
validate_depth_surface_layout(groups["DEPTH_SURFACE"])
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")
)
negative_z_link = next(
link
for link in wrapper.links
if link.to_node == axis_switch and link.to_socket.name == "-Z"
)
positive_x_link = next(
link
for link in wrapper.links
if link.to_node == axis_switch and link.to_socket.name == "+X"
)
assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform"
assert any(
link.from_socket == negative_z_link.from_socket
and link.to_node == positive_x_link.from_node
and link.to_socket.name == "Geometry"
for link in wrapper.links
)
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",
"Split",
"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",
"Split",
"Cleanup",
"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 == {
"Geometry": {"DEPTH_SURFACE"},
"Thickness": set(shapes.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Split": {"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 shape, group in groups.items()
if shape != "DEPTH_SURFACE"
)
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"
split_control = next(
item
for item in wrapper.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Split"
)
child_split = next(
item
for item in groups["DEPTH_SURFACE"].interface.items_tree
if item.item_type == "SOCKET" and item.name == "Split"
)
assert split_control.default_value == 0.0
assert split_control.min_value == 0.0
assert split_control.max_value == 1.0
assert split_control.subtype == "FACTOR"
assert getattr(split_control.parent, "name", "") == "Options"
assert child_split.default_value == 0.0
assert child_split.min_value == 0.0
assert child_split.max_value == 1.0
assert child_split.subtype == "FACTOR"
assert getattr(child_split.parent, "name", "") == "Options"
depth_surface_nodes = groups["DEPTH_SURFACE"].nodes
assert any(
node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes
)
validate_depth_surface_attributes(groups["DEPTH_SURFACE"])
assert [
node.domain
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeDeleteGeometry"
] == ["FACE"]
assert "Stretch Limit" not in [
item.name for item in groups["DEPTH_SURFACE"].interface.items_tree
]
assert all(
socket.is_linked or socket.hide
for node in depth_surface_nodes
for socket in node.outputs
)
for node in depth_surface_nodes:
if node.bl_idname == "GeometryNodeRepeatInput":
assert node.paired_output.location.x > node.location.x
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()):
validate_node_layout(group)
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.z) < 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.z) 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.z) 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:
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values)) < 1e-5
assert abs(max(z_values) - 0.5) < 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:
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values)) < 1e-5
assert abs(max(z_values) - 0.5) < 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 y in range(3):
for x in range(3):
depth = 7.0 if (x, y) == (0, 0) else 2.0 + x * 0.25
pixels.extend((x * 0.5, -y * 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,
(
(
x * 0.5,
y * 0.5,
-5.0 if (x, y) == (0, 0) else -x * 0.25,
)
for y in range(3)
for x in range(3)
),
)
)
finally:
evaluated.to_mesh_clear()
set_modifier_input(
modifier,
modifier_input_identifier(modifier.node_group, "Split"),
0.6,
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
assert 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()
set_modifier_input(
modifier, modifier_input_identifier(modifier.node_group, "Depth Scale"), 1.0
)
set_modifier_input(
modifier, modifier_input_identifier(modifier.node_group, "Thickness"), 0.2
)
depth_surface.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated, result = evaluated_mesh(depth_surface)
try:
edge_uses = Counter(
tuple(sorted((a, b)))
for face in result.polygons
for a, b in zip(face.vertices, (*face.vertices[1:], face.vertices[0]))
)
assert edge_uses and set(edge_uses.values()) == {2}
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()