- Introduced new utility functions in common nodes for evaluating fields and capturing attributes. - Simplified volume field calculations in image depth cutout and panorama groups by utilizing new utility functions. - Enhanced readability and maintainability of the image depth cutout and panorama node groups by removing redundant code and improving function usage. - Adjusted default values for thickness and depth scale in image relief plane group for better user experience. - Improved handling of geometry attributes and connections in image depth plane group to streamline processing.
273 lines
13 KiB
Python
273 lines
13 KiB
Python
"""Contracts for smooth depth volume, editable presets and normal masks."""
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from tools.nodes.groups.image_depth_cutout import build_volume_fields
|
|
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
|
|
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
|
from tools.nodes.common.nodes import capture_fields
|
|
|
|
|
|
def store_test_fields(group, geometry, fields):
|
|
"""Expose evaluated fields only in the test output mesh."""
|
|
for name, field in fields.items():
|
|
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
|
store.domain = "POINT"
|
|
store.data_type = "FLOAT_VECTOR" if field.type == "VECTOR" else "FLOAT"
|
|
store.inputs["Name"].default_value = name
|
|
group.links.new(geometry, store.inputs["Geometry"])
|
|
group.links.new(field, store.inputs["Value"])
|
|
geometry = store.outputs["Geometry"]
|
|
return geometry
|
|
|
|
|
|
def set_test_structure(monkeypatch, value):
|
|
"""Control structural amplitude without depending on storage nodes."""
|
|
from tools.nodes.groups import image_depth_cutout
|
|
build = image_depth_cutout.build_front_fields
|
|
|
|
def fields(group, rim, normal):
|
|
result = build(group, rim, normal)
|
|
structure = group.nodes.new("ShaderNodeValue")
|
|
structure.outputs[0].default_value = value
|
|
result["structure"] = structure.outputs[0]
|
|
return result
|
|
|
|
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
|
|
|
|
|
|
@pytest.mark.parametrize("iterations", [0, 50])
|
|
def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
|
|
from scipy.spatial import cKDTree
|
|
from tests.support.depth_surface 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 tests.support.depth_surface 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), ("Uniform Thickness", "NodeSocketFloat", 0), ("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")
|
|
fields = build_volume_fields(group)
|
|
geometry, center = capture_fields(group, source.outputs["Geometry"], "POINT", center=("FLOAT", fields["center"]))
|
|
mode = group.nodes.new("FunctionNodeInputBool")
|
|
result, rounded = build_volume_rounding(group, geometry, {**fields, **center}, mode.outputs[0])
|
|
result = store_test_fields(group, result, {**center, **rounded})
|
|
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[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["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")
|
|
fields = build_volume_fields(group)
|
|
result = store_test_fields(group, source.outputs["Geometry"], {"thickness": fields["thickness"]})
|
|
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["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 tests.support.depth_surface 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, monkeypatch):
|
|
from tests.support.depth_surface import surface, evaluated, assert_closed
|
|
set_test_structure(monkeypatch, structure)
|
|
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)
|
|
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 tests.support.depth_surface 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(monkeypatch):
|
|
from tests.support.depth_surface import surface, evaluated
|
|
set_test_structure(monkeypatch, 1)
|
|
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
|
|
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)
|