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, Vector 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) legacy_surface = bpy.data.node_groups.new( "O Image Cutout", "GeometryNodeTree", ) cutout = mesh._mesh_node_group(mesh.CUTOUT) balloon = mesh._mesh_node_group(mesh.BALLOON) depth_balloon = mesh._mesh_node_group(mesh.DEPTH_BALLOON) depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE) assert plane.name == "O Mesh Plane" assert cutout.name == "O Image Surface" assert cutout == legacy_surface assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"] assert _inputs(cutout) == ["Geometry", "Thickness", "Cleanup"] assert _inputs(balloon) == ["Geometry", "Thickness", "Cleanup"] assert _inputs(depth_balloon) == [ "Geometry", "Thickness", "Depth Scale", "Double Sided", "Depth Reference", "Depth Image", "Cleanup", "Base Plane", ] assert _inputs(depth_plane) == [ "Geometry", "Subdivide", "Thickness", "Depth Amount", "Depth Image", "Material", ] assert _inputs(depth_surface) == [ "Geometry", "Thickness", "Depth Scale", "Iterations", "Depth Image", "Cleanup", "Depth Continuity", "Smooth", ] assert len({group.as_pointer() for group in ( cutout, balloon, depth_balloon, depth_surface, )}) == 4 expected_options = { cutout: ["Cleanup"], balloon: ["Cleanup"], depth_balloon: ["Cleanup", "Base Plane"], depth_surface: ["Cleanup", "Depth Continuity", "Smooth"], } for group, expected in expected_options.items(): panels = [ item for item in group.interface.items_tree if item.item_type == "PANEL" ] assert [panel.name for panel in panels] == ["Options"] assert panels[0].default_closed assert [item.name for item in panels[0].interface_items] == expected for group in (cutout, balloon, depth_balloon, depth_surface): extrudes = [ node for node in group.nodes if node.bl_idname == "GeometryNodeExtrudeMesh" ] assert extrudes assert all( not node.inputs["Individual"].default_value for node in extrudes ) assert all( node.inputs["Offset Scale"].default_value == 1.0 for node in extrudes ) 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 in (balloon, depth_balloon): thickness = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.name == "Thickness" ) assert thickness.subtype == "NONE" assert thickness.default_value == 1.0 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 ) repeat_counts = { group: sum( node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes ) for group in (cutout, balloon, depth_balloon, depth_surface) } assert repeat_counts == { cutout: 1, balloon: 0, depth_balloon: 0, depth_surface: 2, } 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 ) == 3 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 ) depth_reference = next( item for item in depth_balloon.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Depth Reference" ) assert depth_reference.hide_in_modifier assert depth_reference.subtype == "DISTANCE" for group in (depth_balloon, depth_surface): depth_image = next( item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Depth Image" ) assert depth_image.hide_in_modifier assert any( node.bl_idname == "GeometryNodeImageTexture" for node in group.nodes ) for group in (cutout, balloon, depth_balloon, depth_surface): assert not any( node.bl_idname == "GeometryNodeInputShortestEdgePaths" for node in group.nodes ) assert not any( node.bl_idname == "GeometryNodeInputNamedAttribute" and node.inputs["Name"].default_value == "o_boundary" for node in group.nodes ) for group in (balloon, depth_balloon): profile = next( node for node in group.nodes if node.bl_idname == "GeometryNodeInputNamedAttribute" and node.inputs["Name"].default_value == mesh.BALLOON_ATTRIBUTE ) assert profile.data_type == "FLOAT" assert any( node.bl_idname == "ShaderNodeMapRange" and node.interpolation_type == "SMOOTHERSTEP" for node in group.nodes ) for group in (depth_plane, cutout, balloon, depth_balloon, 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 ) assert all( link.from_node.location.x <= link.to_node.location.x for link in group.links ) assert all(not node.label for node in group.nodes) node_locations = {tuple(node.location) for node in group.nodes} assert len(node_locations) == len(group.nodes) if group in (cutout, balloon, depth_balloon, depth_surface): group_nodes = list(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 ) 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.BALLOON_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_balloon_build_starts_from_the_unified_single_sheet(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True) source = bpy.data.objects.new("Balloon Sheet", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) balloon_material = bpy.data.materials.new("Balloon Sheet.material") result = mesh._build_mesh_object( bpy.context, source, mesh.BALLOON, 8, balloon_material, regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),), image_size=(64, 64), keep_original=True, ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert mesh.BALLOON_ATTRIBUTE in result.data.attributes profile_attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE] assert profile_attribute.data_type == "FLOAT" assert profile_attribute.domain == "POINT" profile = np.empty(len(result.data.vertices), dtype=np.float32) profile_attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 assert result.modifiers[0].node_group.name == "O Image Balloon" inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0)) assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6) def test_teddy_preference_writes_the_shared_balloon_attribute(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True) source = bpy.data.objects.new("Teddy Sheet", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) teddy_material = bpy.data.materials.new("Teddy Sheet.material") with patch.object( mesh, "addon_preferences", return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"), ): result = mesh._build_mesh_object( bpy.context, source, mesh.CUTOUT, 8, teddy_material, regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),), image_size=(64, 64), keep_original=True, ) attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE] profile = np.empty(len(result.data.vertices), dtype=np.float32) attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 def test_depth_surface_object_normal_is_remapped_to_cutout_axes(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new( "Surface Normal", width=1, height=1, alpha=True, float_buffer=True, ) image.pixels.foreach_set((0.5, 0.5, 1.0, 1.0)) mesh._orient_cutout_object_normal(image) pixels = np.empty(4, dtype=np.float32) image.pixels.foreach_get(pixels) assert np.allclose(pixels, (0.0, 0.5, 0.5, 1.0), atol=1e-6) def test_internal_mesh_depth_images_use_standard_data_name(): bpy.ops.wm.read_factory_settings(use_empty=True) cutout_source = bpy.data.objects.new("Cutout Source", None) cutout_source.data = bpy.data.images.new( "Cutout Source.png", width=2, height=2, alpha=True ) cutout_depth = mesh._cutout_depth_image( cutout_source, np.ones((2, 2), dtype=np.float32), ) assert cutout_depth.name == "Cutout Source_depth.exr" plane_source = bpy.data.objects.new("Plane Source", None) plane_source.data = bpy.data.images.new( "Plane Source.png", width=2, height=2, alpha=True ) anchored = np.zeros((2, 2, 3), dtype=np.float32) plane_depth = mesh._depth_field_image(plane_source, anchored) assert plane_depth.name == "Plane Source_depth.exr" 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 all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert mesh.BALLOON_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_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices]) baseline_min = baseline_x.min() baseline_max = baseline_x.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 x = np.asarray([vertex.co.x for vertex in evaluated_mesh.vertices]) assert np.isclose(x.min(), baseline_min, atol=1e-5) assert np.isclose(x.max(), baseline_max + 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 _evaluated_positions(obj): evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: return np.asarray([tuple(vertex.co) for vertex in result.vertices]) finally: evaluated.to_mesh_clear() 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) ] 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("Connected Cutout") data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Connected Cutout", data) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("O Image Surface", "NODES") modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT) mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: assert len(result.vertices) == 18 assert len(result.polygons) == 24 x = np.asarray([vertex.co.x for vertex in result.vertices]) assert np.isclose(x.min(), -0.5, atol=1e-6) assert np.isclose(x.max(), 0.5, atol=1e-6) front_faces = [ polygon for polygon in result.polygons if polygon.normal.x < -0.9 ] back_faces = [ polygon for polygon in result.polygons if polygon.normal.x > 0.9 ] side_faces = [ polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1 ] assert len(front_faces) == 8 assert len(back_faces) == 8 assert len(side_faces) == 8 center = Vector((0.0, 1.0, 1.0)) for polygon in side_faces: radial = polygon.center - center assert polygon.normal.dot(radial) > 0.0 finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 2.4, ) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() 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 for polygon in side_faces: coordinates = [result.vertices[index].co for index in polygon.vertices] edge_lengths = [ (coordinates[(index + 1) % len(coordinates)] - coordinate).length for index, coordinate in enumerate(coordinates) ] assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6 finally: evaluated.to_mesh_clear() def test_balloon_uses_injected_profile_at_default_height(): 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) ] faces = [] for z in range(2): for y in range(2): a = z * 3 + y faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) data = bpy.data.meshes.new("Balloon Profile") data.from_pydata(vertices, (), faces) mesh._assign_float_point_attribute( data, mesh.BALLOON_ATTRIBUTE, (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0), ) obj = bpy.data.objects.new("Balloon Profile", data) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("O Image Balloon", "NODES") modifier.node_group = mesh._mesh_node_group(mesh.BALLOON) mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 1.0, ) bpy.context.view_layer.update() positions = _evaluated_positions(obj) assert np.isclose(positions[:, 0].min(), -1.0, atol=1e-6) assert np.isclose(positions[:, 0].max(), 1.0, atol=1e-6) assert np.isclose( positions[:, 0].min(), -positions[:, 0].max(), atol=1e-5, ) evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: baseline_face_count = len(result.polygons) finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 2.0, ) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: assert len(result.polygons) == baseline_face_count x = np.asarray([vertex.co.x for vertex in result.vertices]) assert np.isclose(x.min(), -2.0, atol=1e-6) assert np.isclose(x.max(), 2.0, atol=1e-6) finally: evaluated.to_mesh_clear() mesh.set_modifier_input( modifier, mesh.modifier_input_identifier(modifier.node_group, "Thickness"), 0.0, ) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_positions = _evaluated_positions(obj) assert len(zero_positions) == len(vertices) assert np.allclose(zero_positions[:, 0], 0.0, atol=1e-6) def test_cutout_cleanup_removes_only_small_mesh_island(): 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) ] vertices.extend( ((0.0, 5.0, 5.0), (0.0, 5.01, 5.0), (0.0, 5.0, 5.01)) ) faces = [] for z in range(2): for y in range(2): a = z * 3 + y faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) faces.append((9, 10, 11)) data = bpy.data.meshes.new("Cleanup") data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Cleanup", data) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("O Image Surface", "NODES") modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT) cleanup_id = mesh.modifier_input_identifier(modifier.node_group, "Cleanup") mesh.set_modifier_input(modifier, cleanup_id, 0.0) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 12 mesh.set_modifier_input(modifier, cleanup_id, 1.0) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 9 def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True) source = bpy.data.objects.new("Depth Balloon", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) balloon_material = bpy.data.materials.new("Depth Balloon.material") crop_bounds = (50, 20, 90, 80) crop = ((0, 0), (40, 0), (40, 60), (0, 60)) local_bounds = mesh._crop_plane_bounds( source, crop_bounds, (100, 100), ) depth_data = { "depth_signal": np.ones((61, 41), dtype=np.float32), "baseline_vector": np.zeros(3, dtype=np.float32), "valid": np.ones((61, 41), dtype=bool), "origin": (50, 20), "reference_depth": 1.0, "crop_size": (40, 60), "source_size": (100, 100), } result = mesh._build_mesh_object( bpy.context, source, mesh.DEPTH_BALLOON, 8, balloon_material, depth_data=depth_data, regions=((crop,),), image_size=(40, 60), bounds=local_bounds, keep_original=True, ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert mesh.BALLOON_ATTRIBUTE in result.data.attributes assert "o_balloon_depth" not in result.data.attributes assert "o_depth_offset" not in result.data.attributes modifier = result.modifiers[0] base_plane_id = mesh.modifier_input_identifier( modifier.node_group, "Base Plane", ) depth_image_id = mesh.modifier_input_identifier( modifier.node_group, "Depth Image", ) base_plane = modifier[base_plane_id] assert isinstance(modifier[depth_image_id], bpy.types.Image) expected_center = np.zeros(2, dtype=np.float64) assert np.allclose(base_plane.location[1:], expected_center, atol=1e-5) crop_center = Vector( ( (local_bounds[0] + local_bounds[1]) * 0.5, (local_bounds[2] + local_bounds[3]) * 0.5, 0.0, ) ) expected_origin = source.matrix_world @ crop_center assert np.allclose( tuple(result.matrix_world.translation), tuple(expected_origin), atol=1e-5, ) arrow = base_plane.rotation_euler.to_matrix() @ Vector((0.0, 0.0, 1.0)) assert np.allclose(tuple(arrow), (-1.0, 0.0, 0.0), atol=1e-5) depth_reference_id = mesh.modifier_input_identifier( modifier.node_group, "Depth Reference", ) assert modifier[depth_reference_id] > 0.0 thickness_id = mesh.modifier_input_identifier( modifier.node_group, "Thickness", ) positions = _evaluated_positions(result) center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 depth_at_thickness_one = positions[nearest_center, 0].min() mesh.set_modifier_input(modifier, thickness_id, 2.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positions = _evaluated_positions(result) center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 depth_at_thickness_two = positions[nearest_center, 0].min() assert np.isclose( depth_at_thickness_one, depth_at_thickness_two, atol=1e-5, ) mesh.set_modifier_input(modifier, thickness_id, 1.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() before_positions = _evaluated_positions(result) base_plane.location += arrow * 0.2 result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() after_positions = _evaluated_positions(result) assert np.isclose( (after_positions[:, 0] - before_positions[:, 0]).min(), -0.2, atol=1e-5, ) depth_scale_id = mesh.modifier_input_identifier( modifier.node_group, "Depth Scale", ) mesh.set_modifier_input(modifier, depth_scale_id, 0.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_scale_before = _evaluated_positions(result) base_plane.location += arrow * 0.15 base_plane.rotation_euler.rotate_axis("Y", np.radians(12.0)) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() zero_scale_after = _evaluated_positions(result) assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5) mesh.set_modifier_input(modifier, depth_scale_id, 1.0) double_sided_id = mesh.modifier_input_identifier( modifier.node_group, "Double Sided", ) mesh.set_modifier_input(modifier, double_sided_id, True) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() mirrored = _evaluated_positions(result) layers = {} for x, y, z in mirrored: layers.setdefault((round(float(y), 6), round(float(z), 6)), []).append(x) assert layers for x_values in layers.values(): if len(x_values) == 1: assert np.isclose(x_values[0], 0.0, atol=1e-5) continue assert np.isclose( min(x_values) + max(x_values), 0.0, atol=1e-5, ) 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) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert mesh.BALLOON_ATTRIBUTE in result.data.attributes source_inverse = source.matrix_world.inverted() source_x = [ (source_inverse @ (result.matrix_world @ vertex.co)).x for vertex in result.data.vertices ] assert min(source_x) < source_bounds[0] assert max(source_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")