import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy import numpy as np from mathutils import Vector SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common import image_data from anyimage.common import depth_data from anyimage.common import node as node_asset from anyimage.common import object as object_data from anyimage.common.image_region import ( RegionMask, RegionPath, rasterize_region_path, ) from anyimage.operators.convert_to_plane import object as plane_object from anyimage.operators.cutout_tool import geometry as cutout_geometry from anyimage.operators.cutout_tool import main as cutout_main from anyimage.operators.cutout_tool import object as cutout_object from anyimage.operators.cutout_tool import shape as cutout_shape from anyimage.server.geometry.depth_texture import write_vector_depth_texture 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 _create_cutout_shape( source, shape, detail, *, bounds=None, depth_image=None, depth_metadata=None, region_values=None, ): if bounds is None: bounds = (0, 0, *tuple(int(value) for value in source.data.size)) left, top, right, bottom = bounds if region_values is None: region_values = np.ones((bottom - top, right - left), dtype=np.float32) region_mask = RegionMask(region_values, bounds) base_shape = cutout_geometry.build_base_shape( bpy.context, source, detail, region_mask, ) return cutout_object.create_shape_object( bpy.context, source, shape, detail, region_mask, base_shape, depth_image=depth_image, depth_metadata=depth_metadata, ) def _depth_image(points, valid, name="Depth"): points = np.asarray(points, dtype=np.float32) valid = np.asarray(valid, dtype=bool) height, width = valid.shape pixels = np.zeros((height, width, 4), dtype=np.float32) pixels[valid, :3] = points[valid] pixels[..., 3] = valid image = bpy.data.images.new( name, width=width, height=height, alpha=True, float_buffer=True, ) image.colorspace_settings.name = "Non-Color" image.alpha_mode = "CHANNEL_PACKED" image.pixels.foreach_set(np.flipud(pixels).ravel()) image.pack() return image def _depth_metadata(intrinsics, image_size, reference_depth): return { "intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics), "image_size": tuple(int(value) for value in image_size), "reference_depth": float(reference_depth), } def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path): bpy.ops.wm.read_factory_settings(use_empty=True) source_image = bpy.data.images.new("Source.png", width=2, height=2) source = bpy.data.objects.new("Source", None) source.data = source_image points = np.arange(12, dtype=np.float32).reshape((2, 2, 3)) valid = np.asarray(((True, False), (True, True))) path = tmp_path / "vector-depth.exr" write_vector_depth_texture(points, valid, path) metadata = _depth_metadata(np.eye(3), (2, 2), 1.0) image = depth_data.load_depth_result_image(path, source, metadata) packed = np.empty(16, dtype=np.float32) image.pixels.foreach_get(packed) packed = np.flipud(packed.reshape((2, 2, 4))) assert np.array_equal(packed[valid, :3], points[valid]) assert np.array_equal(packed[..., 3] > 0.5, valid) assert image.alpha_mode == "CHANNEL_PACKED" assert image.name == "Source_depth.exr" def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): bpy.ops.wm.read_factory_settings(use_empty=True) plane = node_asset.load_node_group("O Mesh Plane") depth_plane = node_asset.load_node_group("O Image Depth Plane") cutout = cutout_object.load_cutout_node_group() assert plane.name == "O Mesh Plane" assert cutout.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert all( cutout_object.load_cutout_node_group() == cutout for shape in cutout_shape.CUTOUT_SHAPES ) assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"] assert _inputs(depth_plane) == [ "Geometry", "Subdivide", "Thickness", "Depth Amount", "Reference Depth", "Uniform Scale", "Depth Image", "Material", ] assert _inputs(cutout) == [ "Geometry", "Shape", "Thickness", "Depth Scale", "Double Sided", "Smooth Iterations", "Cleanup", "Smooth", "Base Plane Depth", "Depth Direction", "Uniform Scale", "Depth Image", ] panels = [item for item in cutout.interface.items_tree if item.item_type == "PANEL"] assert [panel.name for panel in panels] == ["Options", "Data"] assert [item.name for item in panels[0].interface_items] == [ "Cleanup", "Smooth", "Base Plane Depth", "Depth Direction", ] assert [item.name for item in panels[1].interface_items] == [ "Uniform Scale", "Depth Image", ] assert all(panel.default_closed for panel in panels) shape_socket = next( item for item in cutout.interface.items_tree if item.item_type == "SOCKET" and item.name == "Shape" ) assert shape_socket.socket_type == "NodeSocketMenu" assert not shape_socket.menu_expanded menu_switch = next( node for node in cutout.nodes if node.bl_idname == "GeometryNodeMenuSwitch" ) labels = { shape: shape.replace("_", " ").title() for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES } assert [item.name for item in menu_switch.enum_items] == list(labels.values()) assert any( link.from_socket.name == "Shape" and link.to_node == menu_switch and link.to_socket.name == "Menu" for link in cutout.links ) child_groups = { shape: bpy.data.node_groups[name] for shape, name in cutout_shape.CUTOUT_NODE_GROUP_NAMES.items() } assert cutout.is_modifier assert all(not group.is_modifier for group in child_groups.values()) child_nodes = { node.node_tree: node for node in cutout.nodes if node.bl_idname == "GeometryNodeGroup" } assert set(child_nodes) == set(child_groups.values()) for shape, group in child_groups.items(): label = labels[shape] assert any( link.from_node == child_nodes[group] and link.to_node == menu_switch and link.to_socket.name == label for link in cutout.links ) assert _inputs(child_groups["SURFACE"]) == ["Geometry", "Thickness"] assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"] assert _inputs(child_groups["DEPTH_BALLOON"]) == [ "Geometry", "Thickness", "Depth Scale", "Double Sided", "Base Plane Depth", "Depth Direction", "Uniform Scale", "Depth Image", ] assert _inputs(child_groups["DEPTH_SURFACE"]) == [ "Geometry", "Thickness", "Depth Scale", "Base Plane Depth", "Uniform Scale", "Depth Image", ] dependencies = {} for link in cutout.links: if link.from_node.bl_idname != "NodeGroupInput": continue if link.to_node.bl_idname != "GeometryNodeGroup": continue shape = next( shape for shape, group in child_groups.items() if group == link.to_node.node_tree ) dependencies.setdefault(link.from_socket.name, set()).add(shape) assert dependencies == { "Thickness": set(cutout_shape.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, "Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, } assert ( sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in cutout.nodes) == 1 ) assert not any( node.bl_idname == "GeometryNodeAttributeStatistic" for node in cutout.nodes ) assert all( not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes) for group in child_groups.values() ) assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes) assert all( not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes) for group in child_groups.values() ) repeat_counts = { shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes) for shape, group in child_groups.items() } assert repeat_counts == { "SURFACE": 1, "BALLOON": 0, "DEPTH_BALLOON": 0, "DEPTH_SURFACE": 1, } assert ( sum(node.bl_idname == "GeometryNodeRepeatInput" for node in cutout.nodes) == 1 ) smooth = next( item for item in cutout.interface.items_tree if item.item_type == "SOCKET" and item.name == "Smooth" ) iterations = next( item for item in cutout.interface.items_tree if item.item_type == "SOCKET" and item.name == "Smooth Iterations" ) cleanup = next( item for item in cutout.interface.items_tree if item.item_type == "SOCKET" and item.name == "Cleanup" ) assert cleanup.default_value == 0.0 assert cleanup.subtype == "NONE" cleanup_scale = next( link.to_node for link in cutout.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 np.isclose(cleanup_scale.inputs[1].default_value, 0.01) assert smooth.default_value == 1.0 assert smooth.subtype == "FACTOR" assert iterations.default_value == 0 assert iterations.min_value == 0 assert getattr(iterations.parent, "name", "") == "" thickness = next( item for item in cutout.interface.items_tree if item.item_type == "SOCKET" and item.name == "Thickness" ) assert thickness.subtype == "NONE" assert thickness.default_value == 0.0 for group in child_groups.values(): 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 ( child_groups["SURFACE"], child_groups["DEPTH_SURFACE"], depth_plane, ): 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 ( child_groups["BALLOON"], child_groups["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 def test_image_layer_switches_between_tangent_and_object_space_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) assert object_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer" group = object_data.material_node_group() 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 = object_data.create_image_material( SimpleNamespace(data=SimpleNamespace(name="Object Normal")), color, 1.0, 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 = object_data.create_image_material( SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")), color, 1.0, normal_image=normal_image, ) layer = _group_node(group, tangent_material.node_tree) assert layer.inputs["Object Space"].default_value is False reused = object_data.material_node_group() 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 = plane_object.create_plane_object( bpy.context, source, 2, material, ) assert result["anyimage_mesh_shape"] == "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 cutout_shape.BALLOON_ATTRIBUTE_NAME 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) result = _create_cutout_shape( source, "BALLOON", 8, ) 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 cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME] 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 material_tree = result.data.materials[0].node_tree normal_scale_attribute = next( node for node in material_tree.nodes if node.bl_idname == "ShaderNodeAttribute" ) assert normal_scale_attribute.attribute_type == "GEOMETRY" assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME image_layer = _group_node(object_data.material_node_group(), material_tree) normal_scale_link = next( link for link in material_tree.links if link.to_node == image_layer and link.to_socket == image_layer.inputs["Normal Scale"] ) assert normal_scale_link.from_node == normal_scale_attribute assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"] assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME 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) with patch.object( cutout_geometry, "addon_preferences", return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"), ): result = _create_cutout_shape( source, "SURFACE", 8, ) attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME] 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 assert not any( node.bl_idname == "ShaderNodeAttribute" for node in result.data.materials[0].node_tree.nodes ) def test_cutout_material_uses_ior_1_2(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True) source = bpy.data.objects.new("Cutout", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) result = _create_cutout_shape(source, "SURFACE", 8) shader = next( node for node in result.data.materials[0].node_tree.nodes if node.bl_idname == "ShaderNodeBsdfPrincipled" ) assert np.isclose(shader.inputs["IOR"].default_value, 1.2) def test_cutout_material_uses_flat_tangent_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) source = bpy.data.objects.new("Source", None) source.empty_display_type = "IMAGE" source.data = bpy.data.images.new("Source", width=2, height=2) color = bpy.data.images.new("Color", width=2, height=2) normal = bpy.data.images.new("Normal", width=1, height=1) result = object_data.create_image_material( source, color, 0.0, normal_image=normal, scene=bpy.context.scene, ) layer = _group_node(object_data.material_node_group(), result.node_tree) assert layer.inputs["Object Space"].default_value is False 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) 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 valid = np.ones((size, size), dtype=bool) depth_image = _depth_image(points, valid) depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0) result = _create_cutout_shape( source, "DEPTH_SURFACE", 8, depth_image=depth_image, depth_metadata=depth_metadata, ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert ( modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] == cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"] ) expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_point_spacing(source, 8), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], expected_cleanup, ) assert ( modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] == 1.0 ) assert ( modifier[ object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") ] == 0 ) reference_id = object_data.modifier_input_identifier( modifier.node_group, "Base Plane Depth", ) assert modifier[reference_id] == 8.0 object_data.set_modifier_input( modifier, object_data.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() object_data.set_modifier_input( modifier, object_data.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() object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations"), 4, ) object_data.set_modifier_input( modifier, object_data.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() assert internal_positions.ndim == 2 assert internal_positions.shape[1] == 3 assert np.isfinite(internal_positions).all() 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 _add_cutout_modifier(obj, shape): group = cutout_object.load_cutout_node_group() modifier = obj.modifiers.new(group.name, "NODES") modifier.node_group = group object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Shape"), cutout_shape.CUTOUT_SHAPE_INDICES[shape], ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Cleanup"), 0.0, ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Smooth"), 0.0, ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Smooth Iterations"), 1, ) return modifier 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 = _add_cutout_modifier(obj, "SURFACE") object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) object_data.set_modifier_input( modifier, object_data.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() object_data.set_modifier_input( modifier, object_data.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) cutout_object._assign_float_point_attribute( data, cutout_shape.BALLOON_ATTRIBUTE_NAME, (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 = _add_cutout_modifier(obj, "BALLOON") object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), 0.0, ) object_data.set_modifier_input( modifier, object_data.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() object_data.set_modifier_input( modifier, object_data.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() object_data.set_modifier_input( modifier, object_data.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_uses_absolute_area_with_many_small_islands(): 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))) for island in range(12): start = len(vertices) y = 5.0 + island * 0.1 vertices.extend(((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01))) faces.append((start, start + 1, start + 2)) 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 = _add_cutout_modifier(obj, "SURFACE") cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup") object_data.set_modifier_input(modifier, cleanup_id, 0.0) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 45 object_data.set_modifier_input(modifier, cleanup_id, 0.1) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() assert len(_evaluated_positions(obj)) == 9 def test_depth_balloon_uses_metric_plane_inputs_without_controller(): 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) crop_bounds = (50, 20, 90, 80) local_bounds = cutout_geometry.crop_plane_bounds( source, crop_bounds, (100, 100), ) y, x = np.indices((61, 41), dtype=np.float32) normalized_x = x / 40.0 * 2.0 - 1.0 normalized_y = y / 60.0 * 2.0 - 1.0 points = np.zeros((61, 41, 3), dtype=np.float32) points[..., 0] = normalized_x points[..., 1] = normalized_y points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1 points[27:34, 17:24, 2] -= 0.25 intrinsics = np.asarray( ((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)), dtype=np.float32, ) valid = np.ones((61, 41), dtype=bool) z_depth = np.repeat(points[..., 2, None], 3, axis=2) depth_image = _depth_image(z_depth, valid) depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0) region_values = np.ones((60, 40), dtype=np.float32) region_values[:, :2] = 0.0 result = _create_cutout_shape( source, "DEPTH_BALLOON", 8, depth_image=depth_image, depth_metadata=depth_metadata, bounds=crop_bounds, region_values=region_values, ) assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes assert "o_balloon_depth" not in result.data.attributes assert "o_depth_offset" not in result.data.attributes assert not result.children assert {obj.name for obj in bpy.context.scene.objects} == { source.name, result.name, } modifier = result.modifiers[0] depth_image_id = object_data.modifier_input_identifier( modifier.node_group, "Depth Image", ) assert isinstance(modifier[depth_image_id], bpy.types.Image) expected_center = np.zeros(2, dtype=np.float64) 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, ) depth_reference_id = object_data.modifier_input_identifier( modifier.node_group, "Base Plane Depth", ) assert not np.isclose(modifier[depth_reference_id], 8.0) depth_direction_id = object_data.modifier_input_identifier( modifier.node_group, "Depth Direction", ) direction = np.asarray(modifier[depth_direction_id], dtype=np.float64) assert not np.allclose(direction, (0.0, 0.0, 1.0)) uniform_scale_id = object_data.modifier_input_identifier( modifier.node_group, "Uniform Scale", ) assert modifier[uniform_scale_id] > 0.0 thickness_id = object_data.modifier_input_identifier( modifier.node_group, "Thickness", ) smooth_id = object_data.modifier_input_identifier( modifier.node_group, "Smooth", ) assert modifier[smooth_id] == 1.0 object_data.set_modifier_input(modifier, smooth_id, 0.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_one = positions[nearest_center, 0].min() object_data.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, ) object_data.set_modifier_input(modifier, thickness_id, 1.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() before_positions = _evaluated_positions(result) object_data.set_modifier_input( modifier, depth_reference_id, modifier[depth_reference_id] + 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 = object_data.modifier_input_identifier( modifier.node_group, "Depth Scale", ) object_data.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) object_data.set_modifier_input( modifier, depth_reference_id, modifier[depth_reference_id] + 0.15, ) object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.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) object_data.set_modifier_input(modifier, depth_scale_id, 1.0) double_sided_id = object_data.modifier_input_identifier( modifier.node_group, "Double Sided", ) object_data.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 = image_data.load_normal_result_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 = plane_object.create_color_image(source) second = plane_object.create_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_selection_matches_uint8_tenth_alpha_boundary(): top_down = np.ones((4, 4, 4), dtype=np.float32) top_down[1, 1, 3] = 25.0 / 255.0 top_down[1, 2, 3] = 26.0 / 255.0 region_mask = rasterize_region_path( (4, 4), RegionPath( points=((0, 0), (4, 0), (4, 4), (0, 4)), fill_rule="NONZERO", ), source_alpha=top_down[:, :, 3], alpha_threshold=0.1, ) selection = region_mask.full_values((4, 4)) assert selection[1, 1] == 0.0 assert selection[1, 2] > 0.1 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) source_rgba = np.ones((48, 64, 4), dtype=np.float32) source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None] image.pixels.foreach_set(np.flipud(source_rgba).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 region_path = RegionPath( points=((-8, 8), (72, 8), (72, 40), (-8, 40)), fill_rule="NONZERO", ) source_bounds = image_data.image_empty_bounds(source) with patch.object( image_data, "image_rgba", wraps=image_data.image_rgba, ) as read_rgba: rgba = image_data.image_rgba(image) region_mask = rasterize_region_path( tuple(image.size), region_path, source_alpha=rgba[:, :, 3], alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD, antialias=True, ) cutout_main.create_cutout_shape( bpy.context, source, "SURFACE", 16, region_mask, source_rgba=rgba, ) read_rgba.assert_called_once_with(image) result = bpy.context.view_layer.objects.active assert result is not source assert result.type == "MESH" assert result["anyimage_mesh_shape"] == "SURFACE" modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME assert ( modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] == cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"] ) expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_point_spacing(source, 16), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], expected_cleanup, ) assert ( modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] == 1.0 ) assert ( modifier[ object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") ] == 0 ) 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 cutout_shape.BALLOON_ATTRIBUTE_NAME 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) == (64, 34) copied = np.empty(34 * 64 * 4, dtype=np.float32) color_texture.image.pixels.foreach_get(copied) copied = copied.reshape((34, 64, 4)) assert np.allclose(copied[:, :, :3], 1.0) left, top, right, bottom = region_mask.bounds assert np.allclose( copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3]) ) def test_balloon_normal_scale_uses_existing_image_layer_input(): bpy.ops.wm.read_factory_settings(use_empty=True) assert cutout_shape.BALLOON_CUTOUT_SHAPES == frozenset( {"BALLOON", "DEPTH_BALLOON"} ) group = bpy.data.node_groups.new( object_data.IMAGE_MATERIAL_NODE_GROUP_NAME, "ShaderNodeTree", ) group.interface.new_socket( name="Normal Scale", in_out="INPUT", socket_type="NodeSocketFloat", ) image_material = bpy.data.materials.new("Balloon") image_material.use_nodes = True image_material.node_tree.nodes.clear() image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup") image_layer.node_tree = group cutout_object._connect_balloon_normal_scale(image_material) assert _inputs(group) == ["Normal Scale"] attribute = next( node for node in image_material.node_tree.nodes if node != image_layer ) assert attribute.bl_idname == "ShaderNodeAttribute" assert attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME link = next(iter(image_material.node_tree.links)) assert link.from_socket == attribute.outputs["Fac"] assert link.to_socket == image_layer.inputs["Normal Scale"]