"""Evaluate complete-image projection and rectangular surface topology.""" import sys from collections import Counter from pathlib import Path import bpy import numpy as np import pytest ROOT = Path(__file__).parents[1] sys.path[:0] = [str(ROOT / "src"), str(ROOT / "tools" / "node_assets")] from image_plane import build_image_plane_group from depth_plane import build_depth_plane_group from node_layout import validate_node_layout @pytest.fixture(params=("definition", "asset")) def surface(request): bpy.ops.wm.read_factory_settings(use_empty=True) if request.param == "asset": with bpy.data.libraries.load(str(ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target): target.node_groups = ["O Image Depth Plane"] group = target.node_groups[0] plane = group.nodes["Group"].node_tree else: plane = build_image_plane_group() group = build_depth_plane_group(plane) mesh = bpy.data.meshes.new("Rectangle") mesh.from_pydata([(-2, -1, 0), (2, -1, 0), (2, 1, 0), (-2, 1, 0)], [], [(0, 1, 2, 3)]) material = bpy.data.materials.new("Image") mesh.materials.append(material) obj = bpy.data.objects.new("Surface", mesh) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("Surface", "NODES") modifier.node_group = group image = bpy.data.images.new("Camera", width=16, height=8, float_buffer=True) image.colorspace_settings.name = "Non-Color" image.alpha_mode = "CHANNEL_PACKED" yy, xx = np.mgrid[:8, :16] # Camera principal point is deliberately off center; Y points down. pixels = np.ones((8, 16, 4), dtype=np.float32) pixels[..., 0] = ((xx + 0.5) - 5) / 4 pixels[..., 1] = (3 - (yy + 0.5)) / 4 pixels[..., 2] = 2 + (xx + 0.5) / 16 pixels[..., 3] = xx >= 8 image.pixels.foreach_set(pixels.ravel()) inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} thickness_inputs = [item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Thickness"] inputs["Relief Thickness"] = thickness_inputs[0] def set_value(name, value): modifier[inputs[name].identifier] = value obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() for name, value in (("Mode", 1), ("Subdivide", 2), ("Depth Image", image), ("Uniform Scale", 0.5), ("Reference Depth", 1.2), ("Depth Scale", 0.0), ("Thickness", 0.0)): set_value(name, value) return obj, set_value, plane, inputs, image def evaluated(obj): result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = result.to_mesh() try: return ( np.array([v.co[:] for v in mesh.vertices]), [tuple(face.vertices) for face in mesh.polygons], np.array([value.uv[:] for value in mesh.uv_layers["UVMap"].data]), [material.name if material else None for material in mesh.materials], [face.material_index for face in mesh.polygons], [attr.name for attr in mesh.attributes], ) finally: result.to_mesh_clear() @pytest.mark.parametrize("subdivide", [0, 1, 3]) def test_zero_depth_matches_plane_geometry_and_uv(surface, subdivide): obj, set_value, plane, inputs, _ = surface set_value("Subdivide", subdivide) projected = evaluated(obj) modifier = obj.modifiers[0] modifier.node_group = plane for item in plane.interface.items_tree: if item.item_type == "SOCKET" and item.in_out == "INPUT": if item.name == "Subdivide": modifier[item.identifier] = subdivide elif item.name == "Thickness": modifier[item.identifier] = 0.0 obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() regular = evaluated(obj) np.testing.assert_allclose(projected[0], regular[0], atol=1e-6) assert projected[1] == regular[1] np.testing.assert_allclose(projected[2], regular[2], atol=1e-6) assert projected[3:5] == regular[3:5] @pytest.mark.parametrize("strength", [0, 0.5, 1, 2]) def test_camera_projection_and_reference_depth(surface, strength): obj, set_value, _, _, _ = surface base = evaluated(obj) uv = (base[0][:, :2] + (2, 1)) / (4, 2) texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5)) camera = np.column_stack(((texel[:, 0] - 5) / 8, (texel[:, 1] - 3) / 8, 1.2 - (2 + texel[:, 0] / 16) * 0.5)) expected = base[0] * (1 - strength) + camera * strength set_value("Depth Scale", float(strength)) projected = evaluated(obj) np.testing.assert_allclose(projected[0], expected, atol=1e-6) assert projected[1] == base[1] np.testing.assert_allclose(projected[2], base[2]) set_value("Reference Depth", 1.7) shifted = evaluated(obj)[0] np.testing.assert_allclose(shifted - projected[0], np.tile((0, 0, 0.5 * strength), (len(shifted), 1)), atol=1e-6) @pytest.mark.parametrize("thickness", [0.000001, 0.2, 2.0]) def test_shell_keeps_front_and_closes_rectangular_boundary(surface, thickness): obj, set_value, _, _, _ = surface set_value("Depth Scale", 1.0) front = evaluated(obj)[0] set_value("Thickness", thickness) points, faces, uv, materials, indices, attributes = evaluated(obj) assert len(points) == 2 * len(front) edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))) assert set(edges.values()) == {2} normal = np.array((0.25, 0, 1.0)) normal /= np.linalg.norm(normal) expected = np.vstack((front, front - normal * thickness)) from scipy.spatial import cKDTree assert cKDTree(points).query(expected)[0].max() < 1e-6 assert np.isfinite(points).all() and np.isfinite(uv).all() assert materials[0] == "Image" and set(indices) == {0} assert not any(name.startswith("o_depth_") for name in attributes) def test_depth_jump_keeps_rectangular_faces(surface): obj, set_value, _, _, image = surface base = evaluated(obj) pixels = np.ones((8, 16, 4), dtype=np.float32) pixels[:, 8:, 2] = 10 image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Depth Scale", 1.0) result = evaluated(obj) assert result[1] == base[1] assert len(result[0]) == len(base[0]) assert np.ptp(result[0][:, 2]) > 4 @pytest.mark.parametrize("thickness", [0.0, 0.4]) def test_relief_reads_z_and_keeps_the_base(surface, thickness): obj, set_value, _, inputs, image = surface set_value("Mode", 0) set_value("Relief Thickness", thickness) set_value("Depth Scale", 1.0) set_value("Reference Depth", 2.0) pixels = np.ones((8, 16, 4), dtype=np.float32) pixels[..., 0], pixels[..., 1], pixels[..., 2] = -9, 13, 2 image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Uniform Scale", 0.5) first = evaluated(obj) assert first[0][:, 2].max() == pytest.approx(thickness + 1) assert first[0][:, 2].min() == pytest.approx(0 if thickness else 1) pixels[..., 0], pixels[..., 1] = 31, -22 image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Normal Smooth", 0) np.testing.assert_allclose(evaluated(obj)[0], first[0]) original_images = set(bpy.data.images) set_value("Mode", 1) camera_points = evaluated(obj)[0] assert camera_points.shape != first[0].shape or not np.allclose(camera_points, first[0]) set_value("Mode", 0) np.testing.assert_allclose(evaluated(obj)[0], first[0]) assert set(bpy.data.images) == original_images assert obj.modifiers[0][inputs["Relief Thickness"].identifier] == pytest.approx(thickness) @pytest.mark.parametrize("normal_smooth", [0, 2, 50]) def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth): from scipy.spatial import cKDTree obj, set_value, _, _, image = surface yy, xx = np.mgrid[:8, :16] pixels = np.ones((8, 16, 4), dtype=np.float32) pixels[..., 0] = (xx + 0.5 - 8) / 4 pixels[..., 1] = (4 - yy - 0.5) / 4 pixels[..., 2] = 2 + ((xx + 0.5 - 8) / 8) ** 2 + 0.12 * (xx % 2) image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Subdivide", 3) set_value("Depth Scale", 1.0) front, faces, *_ = evaluated(obj) result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = result.to_mesh() try: raw_normals = np.array([v.normal[:] for v in mesh.vertices]) finally: result.to_mesh_clear() set_value("Normal Smooth", normal_smooth) thickness = 0.001 set_value("Thickness", thickness) points = evaluated(obj)[0] distance, source = cKDTree(front).query(points) rear = distance > thickness * 0.5 assert rear.sum() == len(front) assert len(set(source[rear])) == len(front) np.testing.assert_allclose(distance[rear], thickness, atol=1e-6) normals = np.empty_like(front) normals[source[rear]] = (front[source[rear]] - points[rear]) / thickness assert np.all(np.sum(normals * raw_normals, axis=1) > 0) edges = np.array(list({tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))})) roughness = lambda n: np.mean(np.linalg.norm(n[edges[:, 0]] - n[edges[:, 1]], axis=1)) if normal_smooth == 0: np.testing.assert_allclose(normals, raw_normals, atol=2e-4) else: assert roughness(normals) < roughness(raw_normals) @pytest.mark.parametrize("mode,active,inactive", [ (0, "Relief Thickness", "Thickness"), (1, "Thickness", "Relief Thickness"), ]) def test_modes_keep_independent_thickness_values(surface, mode, active, inactive): obj, set_value, _, inputs, _ = surface set_value("Depth Scale", 1.0) set_value("Mode", mode) set_value(active, 0.2) before = evaluated(obj) set_value(inactive, 0.7) np.testing.assert_allclose(evaluated(obj)[0], before[0]) assert evaluated(obj)[1] == before[1] set_value("Mode", 1 - mode) set_value("Mode", mode) np.testing.assert_allclose(evaluated(obj)[0], before[0]) assert obj.modifiers[0][inputs[active].identifier] == pytest.approx(0.2) assert obj.modifiers[0][inputs[inactive].identifier] == pytest.approx(0.7) def test_surface_interface_and_authored_layout(surface): obj, _, _, inputs, _ = surface assert list(inputs) == ["Geometry", "Mode", "Subdivide", "Thickness", "Depth Scale", "Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image", "Relief Thickness"] assert [name for name, item in inputs.items() if not item.hide_in_modifier][1:] == [ "Mode", "Subdivide", "Thickness", "Depth Scale", "Reference Depth", "Normal Smooth", "Relief Thickness"] assert inputs["Normal Smooth"].default_value == 50 assert inputs["Normal Smooth"].min_value == 0 assert inputs["Normal Smooth"].max_value == 50 assert inputs["Normal Smooth"].parent.name == "Options" assert inputs["Thickness"].default_value == inputs["Relief Thickness"].default_value == 0 assert inputs["Thickness"].identifier != inputs["Relief Thickness"].identifier actual_inputs = [item.name for item in obj.modifiers[0].node_group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"] assert actual_inputs == ["Geometry", "Mode", "Subdivide", "Thickness", "Thickness", "Depth Scale", "Reference Depth", "Normal Smooth", "Uniform Scale", "Depth Image"] assert inputs["Mode"].default_value == "Relief" assert inputs["Subdivide"].default_value == 6 assert inputs["Depth Scale"].default_value == 1 assert all(item.structure_type == "SINGLE" for item in inputs.values()) assert inputs["Thickness"].subtype == inputs["Reference Depth"].subtype == "DISTANCE" validate_node_layout(obj.modifiers[0].node_group) def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch): from anyimage.operators.convert_to_plane import object as conversion from anyimage.common.image import image_empty_bounds from mathutils import Matrix, Vector obj, _, _, inputs, image = surface group = obj.modifiers[0].node_group monkeypatch.setattr(conversion, "load_node_group", lambda name: group) source = bpy.data.objects.new("Offset Image", None) source.empty_display_type = "IMAGE" source.data = image source.empty_display_size = 4 source.empty_image_offset = (-0.2, -0.8) source.matrix_world = Matrix.Translation((3, -2, 5)) @ Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1)) bpy.context.collection.objects.link(source) bounds = image_empty_bounds(source) matrix = source.matrix_world.copy() material = bpy.data.materials.new("Conversion") metadata = {"image_size": (16, 8), "reference_depth": 2.0, "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} result = conversion.create_depth_plane_object( bpy.context, source, 2, material, image, metadata) assert result.name == "Offset Image" assert result["anyimage_mesh_shape"] == "DEPTH_PLANE" assert bpy.context.view_layer.objects.active == result expected = sorted(tuple(matrix @ Vector((x, y, 0))) for x in bounds[:2] for y in bounds[2:]) actual = sorted(tuple(result.matrix_world @ v.co) for v in result.data.vertices) np.testing.assert_allclose(actual, expected, atol=1e-6) center = matrix @ Vector(((bounds[0] + bounds[1]) / 2, (bounds[2] + bounds[3]) / 2, 0)) np.testing.assert_allclose(result.matrix_world.translation, center, atol=1e-6) modifier = result.modifiers[0] assert modifier[inputs["Thickness"].identifier] == 0 assert modifier[inputs["Relief Thickness"].identifier] == 0 assert modifier[inputs["Depth Image"].identifier] == image assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( 2 * modifier[inputs["Uniform Scale"].identifier]) assert result.data.materials[0] == material def test_failed_modifier_initialization_removes_partial_object(surface, monkeypatch): from anyimage.operators.convert_to_plane import object as conversion obj, _, _, _, image = surface monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group) source = bpy.data.objects.new("Source", None) source.empty_display_type = "IMAGE" source.data = image bpy.context.collection.objects.link(source) material = bpy.data.materials.new("Unused") before_objects, before_meshes = set(bpy.data.objects), set(bpy.data.meshes) def fail(*args): raise RuntimeError("input failed") monkeypatch.setattr(conversion, "set_modifier_input", fail) metadata = {"image_size": (16, 8), "reference_depth": 2, "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} with pytest.raises(RuntimeError, match="input failed"): conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata) assert set(bpy.data.objects) == before_objects assert set(bpy.data.meshes) == before_meshes assert material.users == 0