- Implemented `image_plane.py` to build the Image Plane Geometry Nodes group with customizable parameters for geometry, subdivide, and thickness. - Created `image_plane_validation.py` to validate the functionality of the image plane, ensuring correct geometry generation and material assignment. - Developed `material_layers.py` to construct shader node groups for image materials, including depth and normal handling. - Authored `plane_layouts.py` to define node arrangements for image geometry assets. - Introduced `shadeless.py` to create a shadeless shader node group that displays color without scene lighting while preserving shadows and reflections.
301 lines
15 KiB
Python
301 lines
15 KiB
Python
"""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 cutout 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 depth_surface_test_support 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 depth_surface_test_support 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 depth_surface_test_support 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 depth_surface_test_support 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 depth_surface_test_support 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 depth_surface_test_support 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)
|