import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import bpy import numpy as np import pytest from mathutils import Matrix, Vector SOURCE_ROOT = Path(__file__).parents[1] / "src" sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common import depth as depth_data 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 image_object_test_support import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node, _inputs def test_object_normal_image_follows_cutout_inward_axis(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new( "Object Normal", width=1, height=1, alpha=True, float_buffer=True, ) decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32) encoded = decoded * 0.5 + 0.5 image.pixels.foreach_set((*encoded, 0.3)) cutout_object.orient_object_normal_image( image, cutout_object.CutoutInwardAxis.NEGATIVE_Z, ) actual = np.empty(4, dtype=np.float32) image.pixels.foreach_get(actual) assert np.allclose(actual[:3], encoded, atol=1e-6) assert np.isclose(actual[3], 0.3) cutout_object.orient_object_normal_image( image, cutout_object.CutoutInwardAxis.POSITIVE_X, ) image.pixels.foreach_get(actual) expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5 assert np.allclose(actual[:3], expected, atol=1e-6) assert np.isclose(actual[3], 0.3) @pytest.mark.parametrize("shape", ("FLAT", "SOLID")) def test_cutout_axes_produce_the_same_world_geometry(shape): 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) positive_x = _create_cutout_shape( source, shape, 8, inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X, ) negative_z = _create_cutout_shape( source, shape, 8, inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z, ) for result in (positive_x, negative_z): 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) 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_cutout_presets_load_three_direct_modifier_assets(): bpy.ops.wm.read_factory_settings(use_empty=True) groups = {shape: cutout_object.load_cutout_node_group(shape) for shape in cutout_shape.CUTOUT_SHAPES} assert groups["FLAT"] == groups["SOLID"] assert len(set(groups.values())) == 3 for shape, group in groups.items(): assert group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape] assert group.is_modifier assert "Shape" not in _inputs(group) assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) assert all(socket.is_linked or socket.hide for node in group.nodes for socket in node.outputs) for shape in ("SOLID", "DEPTH_SOLID"): group = groups[shape] inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1 thickness = [item for item in group.interface.items_tree if item.name == "Thickness"] assert [item.subtype for item in thickness] == ["NONE", "DISTANCE"] assert inputs["Thickness"].subtype == "DISTANCE" assert inputs["Mode"].socket_type == "NodeSocketMenu" assert inputs["Mode"].default_value == "Balloon" assert inputs["Smooth"].default_value == 0 assert inputs["Smooth Weight"].default_value == 1 assert "Edge Smooth" not in inputs assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes) assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value 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", 8, ) 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 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.NORMAL_SCALE_ATTRIBUTE_NAME image_layer = _group_node(material_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_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", 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 any( node.bl_idname == "ShaderNodeAttribute" for node in result.data.materials[0].node_tree.nodes ) 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_SOLID", 8, depth_image=depth_image, depth_metadata=depth_metadata, ) 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"] expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_sampling_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 Threshold")], expected_cleanup, ) assert np.isclose(modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")], 1.0) assert ( 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[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", 8, 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_split_preserves_original_faces(): 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, "Split Threshold") 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 >= 9 and faces == 8 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(), -0.5, atol=1e-6) assert np.isclose(z.max(), 0.5, 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 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) 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_BALLOON", 8, depth_image=depth_image, depth_metadata=depth_metadata, bounds=crop_bounds, selection_values=selection_values, ) 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 "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, "Reference 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 Weight", ) 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[:, :2] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 depth_at_thickness_one = positions[nearest_center, 2].max() 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[:, :2] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 depth_at_thickness_two = positions[nearest_center, 2].max() 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[:, 2] - before_positions[:, 2]).max(), 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(x), 6), round(float(y), 6)), []).append(z) assert layers for z_values in layers.values(): if len(z_values) == 1: assert np.isclose(z_values[0], 0.0, atol=1e-5) continue assert np.isclose( min(z_values) + max(z_values), 0.0, atol=1e-5, ) 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 = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4)) result = None try: result = image_data.load_packed_rgba_image( path, image, "Bounds Result", ) 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 image_data.cleanup_image_input(path, True) assert not path.exists() assert result.packed_file is not None finally: image_data.cleanup_image_input(path, True) 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 = cutout_object.create_cropped_color_image( source, selection_mask.bounds, rgba, ) cutout_main.create_cutout_shape( bpy.context, source, "FLAT", 16, 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( cutout_geometry.cutout_sampling_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 Threshold")], expected_cleanup, ) assert ( modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")] == 1.0 ) assert ( 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]) )