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