import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy import numpy as np from mathutils import Matrix SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.operators import material, mesh from anyimage.operators.normal_map import bake_tangent_normal_image def _inputs(group): return [ item.name for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" ] def _group_node(group, node_tree): return next( node for node in node_tree.nodes if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group ) def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn(): bpy.ops.wm.read_factory_settings(use_empty=True) source = bpy.data.objects.new("Source", None) bpy.context.collection.objects.link(source) target_mesh = bpy.data.meshes.new("Target") target_mesh.from_pydata( ((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), (), ((0, 1, 2),), ) target_mesh.polygons[0].use_smooth = True mesh._assign_uv( target_mesh, np.asarray(((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))), ) target = bpy.data.objects.new("Target", target_mesh) bpy.context.collection.objects.link(target) target.matrix_world = Matrix.Rotation(np.radians(-90.0), 4, "Y") image = bpy.data.images.new("Object Normal", width=3, height=3, alpha=True) pixels = np.ones((3, 3, 4), dtype=np.float32) pixels[..., :3] = (1.0, 0.5, 0.5) image.pixels.foreach_set(pixels.ravel()) bake_tangent_normal_image(bpy.context, source, target, image) baked = np.empty(3 * 3 * 4, dtype=np.float32) image.pixels.foreach_get(baked) baked = baked.reshape((3, 3, 4)) assert not np.allclose(baked[0, 0, :3], pixels[0, 0, :3]) def test_shape_node_groups_expose_only_shape_parameters(): bpy.ops.wm.read_factory_settings(use_empty=True) plane = mesh._mesh_node_group(mesh.PLANE) depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE) cutout = mesh._mesh_node_group(mesh.CUTOUT) depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE) assert plane.name == "O Mesh Plane" assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"] assert _inputs(cutout) == ["Geometry", "Thickness"] assert _inputs(depth_plane) == [ "Geometry", "Subdivide", "Thickness", "Depth Amount", "Depth Image", "Material", ] assert _inputs(depth_surface) == [ "Geometry", "Thickness", "Depth Scale", "Iterations", "Smooth", "Depth Continuity", ] for group in (depth_plane, cutout, depth_surface): thickness = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.name == "Thickness" ) assert thickness.subtype == "DISTANCE" for group, name in ( (depth_plane, "Depth Amount"), (depth_surface, "Depth Scale"), ): depth_amount = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.name == name ) assert depth_amount.subtype == "NONE" assert depth_amount.min_value == 0.0 assert depth_amount.max_value > 1e20 assert any( node.bl_idname == "ShaderNodeMath" and node.operation == "MAXIMUM" for node in depth_plane.nodes ) assert any(node.bl_idname == "GeometryNodeMeshGrid" for node in plane.nodes) assert any( node.bl_idname == "GeometryNodeGroup" and node.node_tree == plane for node in depth_plane.nodes ) assert any( node.bl_idname == "GeometryNodeDeleteGeometry" for node in depth_plane.nodes ) smooth = next( item for item in depth_surface.interface.items_tree if item.item_type == "SOCKET" and item.name == "Smooth" ) assert smooth.default_value == 1.0 iterations = next( item for item in depth_surface.interface.items_tree if item.item_type == "SOCKET" and item.name == "Iterations" ) assert iterations.default_value == 1 assert iterations.min_value == 0 continuity = next( item for item in depth_surface.interface.items_tree if item.item_type == "SOCKET" and item.name == "Depth Continuity" ) assert continuity.default_value == 0.0 assert not any(node.bl_idname == "GeometryNodeGroup" for node in depth_surface.nodes) assert any( node.bl_idname == "GeometryNodeRepeatInput" for node in depth_surface.nodes ) assert any( node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes ) assert any( node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes ) assert any( node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" for node in depth_surface.nodes ) assert sum( node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors" for node in depth_surface.nodes ) == 2 assert sum( node.bl_idname == "GeometryNodeInputMeshVertexNeighbors" for node in depth_surface.nodes ) == 2 boundary_compares = [ node for node in depth_surface.nodes if node.bl_idname == "FunctionNodeCompare" and node.data_type == "INT" and node.operation == "EQUAL" ] assert len(boundary_compares) == 4 for compare in boundary_compares: compare_a = next( socket for socket in compare.inputs if socket.identifier == "A_INT" ) compare_b = next( socket for socket in compare.inputs if socket.identifier == "B_INT" ) assert compare_a.is_linked assert compare_b.default_value == 1 fixed_pin_switches = [ node for node in depth_surface.nodes if node.bl_idname == "GeometryNodeSwitch" and not node.inputs["Switch"].is_linked ] assert len(fixed_pin_switches) == 2 assert all(node.inputs["Switch"].default_value for node in fixed_pin_switches) assert any( node.bl_idname == "ShaderNodeMix" and not node.inputs[0].is_linked and node.inputs[0].default_value == 1.0 for node in depth_surface.nodes ) assert any( node.bl_idname == "GeometryNodeInputNamedAttribute" and node.inputs["Name"].default_value == mesh.DEPTH_DISCONTINUITY_ATTRIBUTE for node in depth_surface.nodes ) for group in (depth_plane, cutout, depth_surface): input_sockets = [ item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" ] assert all(item.structure_type == "SINGLE" for item in input_sockets) 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 ) geometry_links = [ link for link in group.links if link.from_socket.type == "GEOMETRY" and link.to_socket.type == "GEOMETRY" ] assert all( link.from_node.location.x <= link.to_node.location.x for link in geometry_links ) assert all(not node.label for node in group.nodes) node_locations = {tuple(node.location) for node in depth_surface.nodes} assert len(node_locations) == len(depth_surface.nodes) depth_plane_locations = {tuple(node.location) for node in depth_plane.nodes} assert len(depth_plane_locations) == len(depth_plane.nodes) assert any( node.bl_idname == "GeometryNodeSetShadeSmooth" and node.inputs["Shade Smooth"].default_value for node in depth_surface.nodes ) def test_image_layer_switches_between_tangent_and_object_space_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) assert material.SURFACE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" assert ( material.MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME == material.BALLOON_MATERIAL_NODE_GROUP_NAME == material.IMAGE_MATERIAL_NODE_GROUP_NAME ) group = material.material_node_group( surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME ) normal = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Normal" ) assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0) bump_scale = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Bump Scale" ) assert bump_scale.default_value == 0.0 normal_scale = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Normal Scale" ) assert normal_scale.default_value == 1.0 object_space = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Object Space" ) assert object_space.default_value is False normal_maps = { node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap" } assert normal_maps == {"TANGENT", "OBJECT"} assert all( not node.label for node in group.nodes if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"} ) color = bpy.data.images.new("Color", width=2, height=2) normal_image = bpy.data.images.new("Normal", width=2, height=2) object_material = material.create_material( "Object Normal", color, 1.0, surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME, normal_image=normal_image, normal_space="OBJECT", ) layer = _group_node(group, object_material.node_tree) assert layer.inputs["Object Space"].default_value is True tangent_material = material.create_material( "Tangent Normal", color, 1.0, surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME, normal_image=normal_image, ) layer = _group_node(group, tangent_material.node_tree) assert layer.inputs["Object Space"].default_value is False reused = material.material_node_group( surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME ) assert { node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap" } == {"TANGENT", "OBJECT"} def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Source", width=4096, height=2048) source = bpy.data.objects.new("Source", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) material = bpy.data.materials.new("Source.material") result = mesh._build_mesh_object( bpy.context, source, mesh.PLANE, 2, material, keep_original=True, ) assert result["anyimage_mesh_shape"] == mesh.PLANE assert len(result.data.vertices) == 4 assert len(result.data.polygons) == 1 assert all(polygon.use_smooth for polygon in result.data.polygons) assert mesh.PROFILE_ATTRIBUTE not in result.data.attributes assert mesh.DEPTH_ATTRIBUTE not in result.data.attributes assert mesh.FRONT_FACE_ATTRIBUTE not in result.data.attributes assert result.modifiers[0].node_group.name == "O Mesh Plane" evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) evaluated_mesh = evaluated.to_mesh() try: assert len(evaluated_mesh.polygons) > 0 finally: evaluated.to_mesh_clear() def test_depth_surface_protected_smooth_evaluates(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Surface", width=64, height=64) source = bpy.data.objects.new("Surface", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) surface_material = bpy.data.materials.new("Surface.material") size = 8 depth = np.ones((size, size), dtype=np.float32) x, y = np.meshgrid(np.arange(size), np.arange(size)) intrinsics = np.asarray( ((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)), dtype=np.float32, ) points = np.empty((size, size, 3), dtype=np.float32) points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth points[..., 2] = depth field = ( points, intrinsics, np.ones((size, size), dtype=bool), (size, size), 1.0, ) result = mesh._build_mesh_object( bpy.context, source, mesh.DEPTH_SURFACE, 8, surface_material, field=field, regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),), image_size=(64, 64), keep_original=True, ) assert mesh.BOUNDARY_ATTRIBUTE in result.data.attributes assert mesh.DEPTH_PROTECTION_ATTRIBUTE in result.data.attributes modifier = result.modifiers[0] mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Smooth"), 0.0, ) evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) baseline_mesh = evaluated.to_mesh() try: baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices]) baseline_min = baseline_z.min() baseline_max = baseline_z.max() finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) evaluated_mesh = evaluated.to_mesh() try: assert len(evaluated_mesh.polygons) > 0 z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices]) assert np.isclose(z.max(), baseline_max, atol=1e-5) assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5) finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Iterations"), 4, ) mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Smooth"), 1.0, ) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) internal_mesh = evaluated.to_mesh() try: internal_positions = np.asarray( [tuple(vertex.co) for vertex in internal_mesh.vertices] ) finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Iterations"), 0, ) external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree") external_group.is_modifier = True mesh._interface_socket( external_group, "Geometry", "OUTPUT", "NodeSocketGeometry" ) mesh._interface_socket( external_group, "Geometry", "INPUT", "NodeSocketGeometry" ) external_iterations = mesh._interface_socket( external_group, "Iterations", "INPUT", "NodeSocketInt" ) external_iterations.default_value = 4 external_iterations.min_value = 0 external_smooth = mesh._interface_socket( external_group, "Smooth", "INPUT", "NodeSocketFloat" ) external_smooth.default_value = 1.0 external_input = external_group.nodes.new("NodeGroupInput") external_output = external_group.nodes.new("NodeGroupOutput") external_geometry = mesh._expand_smooth( external_group, external_input.outputs["Geometry"] ) external_group.links.new( external_geometry, external_output.inputs["Geometry"] ) external_modifier = result.modifiers.new("External O Smooth", "NODES") external_modifier.node_group = external_group result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) external_mesh = evaluated.to_mesh() try: external_positions = np.asarray( [tuple(vertex.co) for vertex in external_mesh.vertices] ) finally: evaluated.to_mesh_clear() assert internal_positions.shape == external_positions.shape assert np.allclose(internal_positions, external_positions, atol=1e-6) def test_loaded_normal_image_uses_normal_texture_name(tmp_path): from PIL import Image path = tmp_path / "normal.png" Image.new("RGB", (1, 1), (128, 128, 255)).save(path) source = SimpleNamespace(data=SimpleNamespace(name="Poster.png")) image = mesh._load_normal_image(path, source) assert image.name == "Poster_normal.png" assert image.colorspace_settings.name == "Non-Color" assert image.packed_file is not None def test_converted_color_images_are_new_and_use_blender_duplicate_suffix(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Poster", width=16, height=16) source = bpy.data.objects.new("Poster", None) source.empty_display_type = "IMAGE" source.data = image bpy.context.collection.objects.link(source) first = mesh._copy_color_image(source) second = mesh._copy_color_image(source) assert first is not image assert second is not image assert first is not second assert first.name == "Poster_color.png" assert second.name == "Poster_color.png.001" def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True) pixels = np.ones((48, 64, 4), dtype=np.float32) image.pixels.foreach_set(pixels.ravel()) source = bpy.data.objects.new("Local Cutout", None) source.empty_display_type = "IMAGE" source.data = image bpy.context.collection.objects.link(source) source.select_set(True) bpy.context.view_layer.objects.active = source selection = "[[-8,8],[72,8],[72,40],[-8,40]]" source_bounds = mesh.plane_bounds(source) with patch.object(mesh, "push_undo") as undo: mesh._build_local_cutout( SimpleNamespace(mesh_detail=16), bpy.context, source, mesh.CUTOUT, selection, ) result = bpy.context.view_layer.objects.active assert result is not source assert result.type == "MESH" assert result["anyimage_mesh_shape"] == mesh.CUTOUT assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert all(polygon.use_smooth for polygon in result.data.polygons) vertex_x = [vertex.co.x for vertex in result.data.vertices] assert min(vertex_x) < source_bounds[0] assert max(vertex_x) > source_bounds[1] assert bpy.data.objects.get(source.name) is source assert result.data.materials[0].node_tree is not None color_texture = next( node for node in result.data.materials[0].node_tree.nodes if node.bl_idname == "ShaderNodeTexImage" ) assert color_texture.image is not image assert tuple(color_texture.image.size) == (80, 32) copied = np.empty(32 * 80 * 4, dtype=np.float32) color_texture.image.pixels.foreach_get(copied) copied = copied.reshape((32, 80, 4)) assert np.allclose(copied[:, 8:72], 1.0) assert np.allclose(copied[:, :8], 0.0) assert np.allclose(copied[:, 72:], 0.0) undo.assert_called_once_with("Convert to Cutout")