"""Contracts for smooth depth volume, editable presets and normal masks.""" from pathlib import Path import sys import bpy import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(ROOT / "tools/node_assets")) from build_cutout_modifiers import build_cutout_group from cutout_volume import build_volume_fields from cutout_rounding import build_volume_rounding from anyimage.operators.cutout_tool.object import load_cutout_node_group from anyimage.operators.cutout_tool.main import normal_mode_for_shape from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile from anyimage.common.object import modifier_input_identifier, set_modifier_input @pytest.mark.parametrize("enabled", [False, True]) def test_depth_presets_always_generate_the_correct_normal_space(enabled): assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT" assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT" for shape in ("FLAT", "SOLID"): assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") def test_new_cutout_does_not_reuse_a_saved_wrapper(): bpy.ops.wm.read_factory_settings(use_empty=True) saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree") saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") saved.use_fake_user = True current = load_cutout_node_group("SOLID") assert current != saved and current.get("anyimage_asset_revision") == 11 assert saved in bpy.data.node_groups.values() assert current == load_cutout_node_group("FLAT") assert any(item.name == "Shape" for item in saved.interface.items_tree) def test_refined_boundary_ears_have_interior_balloon_samples(): points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float) points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool)) profile = poisson_balloon_profile(points, faces, boundary) assert np.all(profile[boundary] == 0) assert all(any(profile[index] > 0 for index in face) for face in faces) def test_depth_rounding_options_are_consolidated(): bpy.ops.wm.read_factory_settings(use_empty=True) group = build_cutout_group("DEPTH_SOLID") inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"} for name, default in (("Edge Round", 0.2), ("Front Inflation", 1), ("Normal Smooth", 50)): assert inputs[name].default_value == pytest.approx(default) assert inputs[name].structure_type == "SINGLE" assert inputs["Edge Round"].parent.name == "Options" names = list(inputs) assert names[names.index("Front Inflation") - 1] == "Edge Round" assert [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] == [ "Cleanup Threshold", "Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth", "Smooth Weight", ] assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} assert all(n.inputs["Name"].default_value != "unused" for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute") @pytest.mark.parametrize("iterations", [0, 50]) def test_uniform_thickness_follows_the_tilted_front_normal(iterations): from scipy.spatial import cKDTree from test_depth_surface_nodes import surface, evaluated, assert_closed obj, set_value = surface(step=0, resolution=4) image = bpy.data.images["Camera"] pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4) pixels[..., 2] = 1 + 0.4 * pixels[..., 0] image.pixels.foreach_set(pixels.ravel()) image.update() set_value("Normal Smooth", iterations) front, _ = evaluated(obj) set_value("Thickness", 0.2) points, faces = evaluated(obj) expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16) assert cKDTree(points).query(expected)[0].max() < 1e-5 assert cKDTree(points).query(front)[0].max() < 1e-5 assert_closed(faces) def test_balloon_shell_converges_to_front_with_thickness(): from scipy.spatial import cKDTree from test_depth_surface_nodes import surface, evaluated, assert_closed obj, set_value = surface(step=1, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) profile[abs(profile) < 1e-7] = 0 obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) set_value("Mode", 0) set_value("Edge Round", 0.0) set_value("Normal Smooth", 0) modifier = obj.modifiers[0] thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE") def amount(value): set_modifier_input(modifier, thickness, value) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() return evaluated(obj) front, _ = amount(0) tree = cKDTree(front) gaps = [] for value in (0.01, 0.001, 0.0001): points, faces = amount(value) assert_closed(faces) gaps.append(tree.query(points)[0].max()) np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02) @pytest.mark.parametrize("amount", [0.0, 1.0]) def test_rounding_shares_front_and_back_falloff(amount): bpy.ops.wm.read_factory_settings(use_empty=True) n = 15 xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)]) balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2)) depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)] mesh = bpy.data.meshes.new("Round profile") mesh.from_pydata(np.column_stack((xy, depth)), [], faces) mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon) obj = bpy.data.objects.new("Round profile", mesh) bpy.context.collection.objects.link(obj) group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree") for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)): socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind) if default is not None: socket.default_value = default group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = group.nodes.new("NodeGroupInput") result = build_volume_fields(group, source.outputs["Geometry"]) result = build_volume_rounding(group, result, (5000, 0)) group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) obj.modifiers.new("Round", "NODES").node_group = group bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: def values(name): return np.array([v.value for v in result.attributes["o_balloon_surface_" + name].data]) center = values("center") u = balloon / balloon.max() weight = amount * (1 - u**3 * (u * (6*u - 15) + 10)) delta = (center + balloon - depth) * weight original = np.maximum(depth - center + balloon, 0.05 * balloon) np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6) np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6) np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6) normals = np.array([v.vector[:] for v in result.attributes["o_balloon_surface_normal"].data]) np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6) assert np.max(abs(normals[:, :2])) > 0.1 finally: evaluated.to_mesh_clear() def test_smooth_back_rejects_front_noise_and_preserves_center_depth(): bpy.ops.wm.read_factory_settings(use_empty=True) n = 41 points = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)]) profile = np.sqrt(np.maximum(0, np.cos(points[:, 0]*np.pi/2)*np.cos(points[:, 1]*np.pi/2))) * 0.6 boundary = np.max(abs(points), axis=1) > 0.999 profile[boundary] = 0 center = 0.3*points[:, 0] + 0.6*points[:, 1] noise = 0.08*np.sin(points[:, 0]*23)*np.sin(points[:, 1]*19) noise[boundary] = 0 faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)] faces += [(y*n+x, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)] mesh = bpy.data.meshes.new("Noisy front") mesh.from_pydata(np.column_stack((points, center + profile + noise)), [], faces) mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32)) obj = bpy.data.objects.new("Noisy front", mesh) bpy.context.collection.objects.link(obj) group = bpy.data.node_groups.new("Volume field test", "GeometryNodeTree") for name, kind in (("Geometry", "NodeSocketGeometry"), ("Thickness", "NodeSocketFloat")): socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind) if name == "Thickness": socket.default_value = 1 group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = group.nodes.new("NodeGroupInput") result = build_volume_fields(group, source.outputs["Geometry"]) group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) obj.modifiers.new("Volume", "NODES").node_group = group bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) result = evaluated.to_mesh() try: thickness = np.array([item.value for item in result.attributes["o_balloon_surface_thickness"].data]) rear = center + profile + noise - thickness error = rear - (center - profile) assert max(abs(error)) < 1e-5 assert np.sqrt(np.mean(error**2)) < np.sqrt(np.mean(noise**2))*0.01 assert max(abs(thickness[boundary])) == 0 finally: evaluated.to_mesh_clear() def test_depth_cutout_normal_map_only_affects_front_faces(): from test_depth_surface_nodes import surface obj, set_value = surface() set_value("Thickness", 0.3) bpy.context.view_layer.update() evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = evaluated.to_mesh() try: attribute = mesh.attributes["o_normal_scale"] assert attribute.domain == "FACE" weights = [item.value for item in attribute.data] assert weights.count(1) == len(obj.data.polygons) assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons) finally: evaluated.to_mesh_clear() @pytest.mark.parametrize("structure", [0, 1, 4]) @pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1]) def test_uniform_front_inflation_scales_with_distance(thickness, structure): from test_depth_surface_nodes import surface, evaluated, assert_closed obj, set_value = surface(step=0, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) profile[abs(profile) < 1e-7] = 0 obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) group = obj.modifiers[0].node_group store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure") for link in list(store.inputs["Value"].links): group.links.remove(link) store.inputs["Value"].default_value = structure set_value("Thickness", thickness) set_value("Front Inflation", 0.0) baseline, faces = evaluated(obj) set_value("Front Inflation", 1.0) inflated, inflated_faces = evaluated(obj) assert faces == inflated_faces delta = np.linalg.norm(inflated - baseline, axis=1) front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6) np.testing.assert_allclose(delta[front], thickness, atol=2e-6) np.testing.assert_allclose(delta[~front], 0, atol=2e-6) if thickness: assert_closed(inflated_faces) else: np.testing.assert_array_equal(inflated, baseline) def test_normal_smooth_controls_front_and_rear_directions(): from test_depth_surface_nodes import surface, evaluated obj, set_value = surface(step=1, resolution=10) set_value("Front Inflation", 1.0) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) profile[abs(profile) < 1e-7] = 0 obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) group = obj.modifiers[0].node_group set_value("Mode", 0) set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0) masks = [] coordinates = [] for iterations in (0, 50): set_value("Normal Smooth", iterations) points, faces = evaluated(obj) ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = ev.to_mesh() mask = [v.value for v in mesh.attributes["o_normal_scale"].data] front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face} rear = set(range(len(points))) - front ev.to_mesh_clear() coordinates.append(points) masks.append((front, rear)) assert masks[0] == masks[1] for ids in masks[0]: assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5 def test_balloon_suppression_is_stronger_without_a_startup_threshold(): from test_depth_surface_nodes import surface, evaluated obj, set_value = surface(step=0, resolution=10) xy = np.array([v.co[:2] for v in obj.data.vertices]) profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) profile[abs(profile) < 1e-7] = 0 obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) group = obj.modifiers[0].node_group store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure") for link in list(store.inputs["Value"].links): group.links.remove(link) store.inputs["Value"].default_value = 1 set_value("Mode", 0) set_value("Edge Round", 0) thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE") previous = 0 for thickness in (0.001, 0.01, 0.1, 0.5, 1): set_modifier_input(obj.modifiers[0], thickness_id, thickness) set_value("Front Inflation", 0) baseline, _ = evaluated(obj) set_value("Front Inflation", 1) inflated, _ = evaluated(obj) growth = np.linalg.norm(inflated - baseline, axis=1).max() / profile.max() assert growth > previous previous = growth assert growth == pytest.approx(0.323, abs=0.001)