from types import SimpleNamespace from unittest.mock import patch import bpy import numpy as np import pytest from mathutils import Matrix, Vector from anyimage.common import depth as depth_data from anyimage.common import color_image as colors from tests.support.color_image import color_image as prepared_color_image from anyimage.common import image as image_data from anyimage.common import material as material_data from anyimage.common import object as object_data from anyimage.common.selection import ImageEditWarning, SelectionPath, rasterize_selection_path from anyimage.operators.cutout_tool import geometry as cutout_geometry from anyimage.operators.cutout_tool import operators 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_depth_texture, ) from anyimage.server.models.geometry import GeometryFrame from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node @pytest.mark.parametrize("shape", ("FLAT", "SOLID")) @pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE")) def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True) source = bpy.data.objects.new("Axis Source", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 2.0 source.matrix_world = ( Matrix.Translation((2.0, -3.0, 1.0)) @ Matrix.Rotation(0.35, 4, "Y") @ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0)) ) bpy.context.collection.objects.link(source) bpy.context.view_layer.update() positive_x = _create_cutout_shape( source, shape, 0.1, gesture=gesture, ) negative_z = _create_cutout_shape( source, shape, 0.1, gesture=gesture, ) for result in (positive_x, negative_z): modifier = result.modifiers[0] mode = object_data.modifier_input_identifier(modifier.node_group, "Mode") assert modifier_value(modifier, mode) == (1 if gesture == "POLYLINE" else 0) thickness = object_data.modifier_input_identifier( modifier.node_group, "Thickness", subtype="DISTANCE", ) assert modifier_value(modifier, thickness) == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0) local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices]) assert np.allclose(local[:, 2], 0.0, atol=1e-8) assert np.ptp(local[:, 0]) > 0.0 assert np.ptp(local[:, 1]) > 0.0 assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons) assert np.allclose(positive_x.matrix_world, source.matrix_world) modifier = positive_x.modifiers[0] axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis") assert modifier_value(modifier, axis) == 0 object_data.set_modifier_input(modifier, axis, 1) positive_x.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positive_x.matrix_world @= Matrix(cutout_shape.CUTOUT_Z_TO_X).inverted() def world_positions(obj): local = _evaluated_positions(obj) return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local]) expected = np.asarray(sorted(map(tuple, world_positions(positive_x)))) actual = np.asarray(sorted(map(tuple, world_positions(negative_z)))) from scipy.optimize import linear_sum_assignment distances = np.linalg.norm(actual[:, None] - expected[None], axis=2) rows, columns = linear_sum_assignment(distances) assert distances[rows, columns].max() < 2e-6 assert np.allclose(negative_z.matrix_world, source.matrix_world) 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)) validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32) frame = GeometryFrame( depth=points[..., 2].copy(), validity=validity, intrinsics=np.eye(3, dtype=np.float32), points=points, ) path = tmp_path / "depth.exr" alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32) write_depth_texture(frame, path, alpha=alpha) 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[..., :3], points) assert np.array_equal(packed[..., 3], alpha * validity) camera_depth, valid = depth_data.camera_depth_values(image) np.testing.assert_array_equal(camera_depth, points[..., 2]) np.testing.assert_array_equal(valid, alpha * validity > 0.5) assert image.alpha_mode == "CHANNEL_PACKED" assert image.name == "Source_depth.exr" def test_depth_texture_keeps_points_outside_positive_validity(tmp_path): bpy.ops.wm.read_factory_settings(use_empty=True) source_image = bpy.data.images.new("Source.png", width=2, height=1) source = bpy.data.objects.new("Source", None) source.data = source_image frame = GeometryFrame( depth=np.asarray(((2.0, 7.0),), dtype=np.float32), points=np.asarray((((-1.0, 3.0, 2.0), (4.0, -2.0, 7.0)),), dtype=np.float32), validity=np.asarray(((1.0, 0.0),), dtype=np.float32), intrinsics=np.eye(3, dtype=np.float32), ) path = tmp_path / "depth.exr" write_depth_texture(frame, path, alpha=np.ones_like(frame.validity)) image = depth_data.load_depth_result_image( path, source, _depth_metadata(np.eye(3), (2, 1), 2.0), ) packed = np.empty(8, dtype=np.float32) image.pixels.foreach_get(packed) packed = packed.reshape((1, 2, 4)) assert np.array_equal(packed[..., :3], frame.points) assert np.array_equal(packed[..., 3], frame.validity) 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, "SOLID", 0.1, ) assert all(abs(vertex.co.z) < 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 assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes) image_layer = _group_node(material_data.material_node_group(), material_tree) assert not image_layer.inputs["Normal Scale"].is_linked assert image_layer.inputs["Normal Scale"].default_value == 1.0 assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"] inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.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, "FLAT", 0.1, ) 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 ) @pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE")) def test_depth_surface_protected_smooth_evaluates(gesture): 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_SOLID", 0.25, depth_image=depth_image, depth_metadata=depth_metadata, gesture=gesture, ) assert all(abs(vertex.co.z) < 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_NAMES["DEPTH_SOLID"] layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree) assert layer.inputs["Object Space"].default_value is True assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0) assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == pytest.approx(0.2) assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0 object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 0.0, ) expected_cleanup = cutout_geometry.cutout_cleanup_value( 0.25, cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")), expected_cleanup, ) assert np.isclose(modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")), 1.0) assert ( modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth")) == 0 ) for name, value in (("Mode", 1), ("Front Inflation", 0.0)): object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value) reference_id = object_data.modifier_input_identifier( modifier.node_group, "Reference Depth", ) assert modifier_value(modifier, reference_id) == 8.0 object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"), 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() object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 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.min(), baseline_min - 1.0, atol=1e-5) assert np.isclose(z.max(), baseline_max, atol=1e-5) finally: evaluated.to_mesh_clear() object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"), 4, ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"), 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 test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): 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 validity = np.ones((size, size), dtype=bool) validity[[0, -1], :] = False validity[:, [0, -1]] = False result = _create_cutout_shape( source, "DEPTH_SOLID", 0.1, depth_image=_depth_image(points, validity), depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0), ) modifier = result.modifiers[0] for name, value in (("Thickness", 0.0), ("Smooth", 0)): object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positions = _evaluated_positions(result) assert np.isfinite(positions).all() assert np.ptp(positions[:, 0]) > 1.5 assert np.ptp(positions[:, 1]) > 1.5 assert np.ptp(positions[:, 2]) < 1e-4 def test_depth_surface_removes_boundary_ears_without_loose_geometry(): bpy.ops.wm.read_factory_settings(use_empty=True) obj, modifier = _depth_surface_step_object() split_id = object_data.modifier_input_identifier(modifier.node_group, "Depth Split") # Both outer ears have three boundary vertices, including the raised corner. for value in (0., .5, 1.): object_data.set_modifier_input(modifier, split_id, value) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) assert (vertices, faces) == (7, 6) assert not loose_edges and not loose_points def test_cutout_thickness_keeps_one_connected_shell(): bpy.ops.wm.read_factory_settings(use_empty=True) 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("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, "FLAT") object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"), 0.0, ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 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 z = np.asarray([vertex.co.z for vertex in result.vertices]) assert np.isclose(z.min(), -1.0, atol=1e-6) assert np.isclose(z.max(), 0.0, atol=1e-6) front_faces = [ polygon for polygon in result.polygons if polygon.normal.z > 0.9 ] back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9] side_faces = [ polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1 ] assert len(front_faces) == 8 assert len(back_faces) == 8 assert len(side_faces) == 8 center = Vector((1.0, 1.0, 0.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", subtype="DISTANCE"), 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.z) < 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 = [(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("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, "SOLID") object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"), 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[:, 2].min(), -1.0, atol=1e-6) assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6) assert np.isclose( positions[:, 2].min(), -positions[:, 2].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 z = np.asarray([vertex.co.z for vertex in result.vertices]) assert np.isclose(z.min(), -2.0, atol=1e-6) assert np.isclose(z.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[:, 2], 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 = [(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))) for island in range(12): start = len(vertices) x = 5.0 + island * 0.1 vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0))) faces.append((start, start + 1, start + 2)) data = bpy.data.meshes.new("Cleanup Threshold") data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Cleanup Threshold", data) bpy.context.collection.objects.link(obj) modifier = _add_cutout_modifier(obj, "FLAT") cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold") 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 @pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE")) def test_depth_symmetry_uses_metric_plane_inputs(gesture): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Depth Symmetry", width=100, height=100, alpha=True) source = bpy.data.objects.new("Depth Symmetry", 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) depth_image = _depth_image(points, valid) depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0) selection_values = np.ones((60, 40), dtype=np.float32) selection_values[:, :2] = 0.0 result = _create_cutout_shape( source, "DEPTH_SYMMETRY", 0.1, depth_image=depth_image, depth_metadata=depth_metadata, bounds=crop_bounds, selection_values=selection_values, gesture=gesture, ) assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes assert {obj.name for obj in bpy.context.scene.objects} == { source.name, result.name, } assert len(result.modifiers) == 2 modifier, symmetry = result.modifiers assert modifier.node_group.name == "O Image Depth Cutout" assert symmetry.node_group.name == "O Image Cutout Symmetry" layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree) assert layer.inputs["Object Space"].default_value is True assert not modifier.show_group_selector and not symmetry.show_group_selector def value(modifier, name, subtype=None): return modifier_value(modifier, object_data.modifier_input_identifier( modifier.node_group, name, subtype=subtype)) assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0) for subtype in ("NONE", "DISTANCE"): assert value(modifier, "Thickness", subtype) == 0 initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"} for socket in modifier.node_group.interface.items_tree: if (socket.item_type == "SOCKET" and socket.in_out == "INPUT" and socket.name not in initialized and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}): assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value) assert value(modifier, "Depth Axis") == 0 assert isinstance(value(modifier, "Depth Image"), bpy.types.Image) assert value(modifier, "Uniform Scale") > 0 assert value(modifier, "Reference Depth") > 0 assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1)) crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2, (local_bounds[2] + local_bounds[3]) / 2, 0)) np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5) from scipy.spatial import cKDTree positions = _evaluated_positions(result) assert len(positions) and np.isfinite(positions).all() assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5 # Both modes start from the same front, including after changing the menu. object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode"), 0 if gesture == "POLYLINE" else 1) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() changed = _evaluated_positions(result) np.testing.assert_array_equal(changed, positions) def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True) rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0 rgba[:, :, 3] = np.asarray( ( (0.0, 0.1, 0.2, 0.3, 0.4), (0.5, 0.6, 0.7, 0.8, 0.9), (1.0, 0.9, 0.8, 0.7, 0.6), (0.5, 0.4, 0.3, 0.2, 0.1), ), dtype=np.float32, ) path = colors.prepare_material_color_input(image, (1, 1, 5, 4), rgba) result = None try: result = colors.load_material_color_image(path) loaded = image_data.image_rgba(result) np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0) assert result.packed_file is not None colors.cleanup_material_color_input(path) assert not path.exists() assert result.packed_file is not None finally: colors.cleanup_material_color_input(path) if result is not None: bpy.data.images.remove(result, do_unlink=True) 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 selection_mask = rasterize_selection_path( (4, 4), SelectionPath( points=((0, 0), (4, 0), (4, 4), (0, 4)), ), ) selection = cutout_main._cutout_content_values( top_down[:, :, 3], selection_mask.values, ) assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD assert selection[1, 2] > 0.1 def test_cutout_content_merge_keeps_the_geometric_selection_unchanged(): selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32) original = selection.copy() alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32) content = cutout_main._cutout_content_values( alpha, selection, alpha_threshold=0.5 ) np.testing.assert_array_equal(selection, original) np.testing.assert_allclose(content, alpha * original) with pytest.raises(ImageEditWarning, match="no visible image pixels"): cutout_main._cutout_content_values(np.zeros((2, 2)), selection) 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 selection_path = SelectionPath( points=((-8, 8), (72, 8), (72, 40), (-8, 40)), ) 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) selection_mask = rasterize_selection_path( tuple(image.size), selection_path, antialias=True, ) left, top, right, bottom = selection_mask.bounds content_values = cutout_main._cutout_content_values( rgba[top:bottom, left:right, 3], selection_mask.values, alpha_threshold=0.5, ) color_image = prepared_color_image( source.data, selection_mask.bounds, rgba, ) cutout_main.create_cutout_shape( bpy.context, source, "FLAT", 0.1, content_values, selection_mask.bounds, alpha_threshold=0.5, color_image=color_image, ) 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"] == "FLAT" modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"] expected_cleanup = cutout_geometry.cutout_cleanup_value( 0.1, cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")), expected_cleanup, ) assert ( modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")) == 1.0 ) assert ( modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth")) == 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.z) < 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, 36) copied = np.empty(36 * 64 * 4, dtype=np.float32) color_texture.image.pixels.foreach_get(copied) copied = copied.reshape((36, 64, 4)) assert np.allclose(copied[:, :, :3], 1.0) left, top, right, bottom = selection_mask.bounds assert np.allclose( copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3]) ) def modifier_value(modifier, identifier): """Read modifier values through the runtime's socket API.""" inputs = object_data._modifier_input_slots(modifier) if inputs is None: return modifier[identifier] slot = getattr(inputs, identifier) prop = slot.bl_rna.properties["value"] if prop.type == "ENUM": return list(prop.enum_items.keys()).index(slot.value) return slot.value