- Removed the `always_vertex` parameter from `build_surface_camera` to streamline depth sampling for cutouts and ensure consistent behavior across depth plane and panorama. - Updated depth sampling logic to ensure that all free boundary points, including the original frame of the depth plane, use neighboring face depth instead of their own texture sampling. - Adjusted the depth mask deletion process to operate on faces rather than points, eliminating the need for additional cleanup of dangling edges. - Ensured that the cutout rules are consistently applied across all relevant nodes, removing exceptions and simplifying the logic. - Updated tests to verify the new behavior, ensuring that cutout points follow the depth of adjacent faces and that the original frame maintains its depth sampling. - Refactored geometry handling in multiple depth-related nodes to improve clarity and maintainability.
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Contracts for smooth depth volume, editable presets and normal masks."""
|
|
from collections import Counter
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group, build_volume_fields
|
|
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
|
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [0, 1])
|
|
def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode):
|
|
from tests.support.depth_surface import surface, evaluated
|
|
|
|
obj, set_value = surface(step=1, resolution=10)
|
|
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
|
|
profile = .3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
|
|
profile[abs(profile) < 1e-7] = 0
|
|
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
|
set_value("Mode", mode)
|
|
set_value("Front Inflation", .2)
|
|
modifier = obj.modifiers[0]
|
|
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE" if mode else "NONE")
|
|
|
|
def result(amount, smoothing):
|
|
set_modifier_input(modifier, thickness, amount)
|
|
set_value("Normal Smooth", smoothing)
|
|
return evaluated(obj)
|
|
|
|
raw, faces = result(0, 16)
|
|
smooth, smooth_faces = result(0, 512)
|
|
np.testing.assert_array_equal(raw, smooth)
|
|
assert faces == smooth_faces
|
|
raw, faces = result(1, 16)
|
|
smooth, smooth_faces = result(1, 512)
|
|
assert faces == smooth_faces
|
|
assert np.isfinite(smooth).all()
|
|
assert np.max(np.abs(raw - smooth)) > 1e-5
|
|
|
|
|
|
def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
|
|
from tests.support.depth_surface import surface, evaluated
|
|
|
|
obj, set_value = surface(step=0, resolution=12)
|
|
image = bpy.data.images['Camera']
|
|
pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4)
|
|
pixels[..., 2] = 1 + .15 * np.sin(pixels[..., 0] * 6) * np.cos(pixels[..., 1] * 5)
|
|
image.pixels.foreach_set(pixels.ravel())
|
|
image.update()
|
|
set_value('Reference Depth', 10.)
|
|
set_value('Thickness', 0.)
|
|
front, _ = evaluated(obj)
|
|
|
|
def offsets(thickness, smoothing, reference=10.):
|
|
set_value('Thickness', thickness)
|
|
set_value('Normal Smooth', smoothing)
|
|
set_value('Reference Depth', reference)
|
|
points, faces = evaluated(obj)
|
|
adjusted_front = front + (0, 10 - reference, 0)
|
|
from scipy.spatial import cKDTree
|
|
distances, indices = cKDTree(adjusted_front).query(points)
|
|
np.testing.assert_allclose(np.sort(distances)[:len(front)], 0, atol=2e-6)
|
|
rear = distances > thickness * .5
|
|
assert rear.sum() == len(front)
|
|
order = np.argsort(indices[rear])
|
|
return ((points[rear] - adjusted_front[indices[rear]]) / thickness)[order], faces
|
|
|
|
raw, _ = offsets(.02, 16)
|
|
smooth, faces = offsets(.02, 512)
|
|
assert np.max(np.linalg.norm(raw - smooth, axis=1)) > .05
|
|
thinner, thinner_faces = offsets(.01, 512)
|
|
distant, _ = offsets(.02, 512, 20.)
|
|
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
|
|
np.testing.assert_allclose(distant, smooth, atol=2e-4)
|
|
assert thinner_faces == faces
|
|
|
|
|
|
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, volume, normal, normal_weight):
|
|
result = build(group, volume, normal, normal_weight)
|
|
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)
|
|
|
|
|
|
def test_shell_thickness_extends_behind_the_tilted_front():
|
|
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()
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
front, _ = evaluated(obj)
|
|
set_value("Thickness", 0.2)
|
|
set_value("Front Inflation", 0.5)
|
|
points, faces = evaluated(obj)
|
|
front_set = {tuple(point) for point in front}
|
|
assert front_set <= {tuple(point) for point in points}
|
|
assert not np.allclose(points[len(front) :], front, atol=1e-6)
|
|
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)
|
|
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
|
|
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 1])
|
|
profile[abs(profile) < 1e-7] = 0
|
|
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
|
set_value("Mode", 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)
|
|
|
|
|
|
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[:, 0], center + profile + noise, points[:, 1])), [], 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_reduction_uses_point_domain():
|
|
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_reduction"]
|
|
assert attribute.domain == "POINT"
|
|
values = [item.value for item in attribute.data]
|
|
assert values
|
|
assert set(values) <= {0.0, 0.5, 1.0}
|
|
assert 0.0 in values and 1.0 in values
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_depth_cutout_reuses_boundary_and_slow_field_nodes():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
group = build_image_depth_cutout_group()
|
|
counts = Counter(node.bl_idname for node in group.nodes)
|
|
assert counts["GeometryNodeInputMeshEdgeNeighbors"] <= 15
|
|
# 内部平滑、边界条带与 pin sharp 的法线相干度各一个 Blur。
|
|
assert counts["GeometryNodeBlurAttribute"] <= 13
|
|
|
|
|
|
def test_center_derives_from_shared_slow_fields():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
points = [(x, y) for y in range(3) for x in range(3)]
|
|
mesh = bpy.data.meshes.new("Center fields")
|
|
mesh.from_pydata(
|
|
[(x, y, 0) for x, y in points], [],
|
|
[(0, 1, 4, 3), (1, 2, 5, 4), (3, 4, 7, 6), (4, 5, 8, 7)],
|
|
)
|
|
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
|
|
for value in profile.data:
|
|
value.value = 0.2
|
|
obj = bpy.data.objects.new("Center fields", mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
group = bpy.data.node_groups.new("Center derivation test", "GeometryNodeTree")
|
|
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
|
|
group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat").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"],
|
|
{"depth_slow": fields["depth_slow"], "profile_slow": fields["profile_slow"], "center": fields["center"]},
|
|
)
|
|
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
|
obj.modifiers.new("Fields", "NODES").node_group = group
|
|
bpy.context.view_layer.update()
|
|
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
result = evaluated.to_mesh()
|
|
try:
|
|
depth_slow = np.array([item.value for item in result.attributes["depth_slow"].data])
|
|
profile_slow = np.array([item.value for item in result.attributes["profile_slow"].data])
|
|
center = np.array([item.value for item in result.attributes["center"].data])
|
|
np.testing.assert_allclose(center, depth_slow - profile_slow, atol=1e-5)
|
|
finally:
|
|
evaluated.to_mesh_clear()
|
|
|
|
|
|
def test_zero_thickness_short_circuits_shell_without_warnings():
|
|
from tests.support.depth_surface import surface, evaluated
|
|
obj, set_value = surface()
|
|
set_value("Thickness", 0.0)
|
|
points, faces = evaluated(obj)
|
|
assert len(faces) == len(obj.data.polygons)
|
|
modifier = obj.modifiers[0]
|
|
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = result.to_mesh()
|
|
try:
|
|
assert not list(modifier.node_warnings), [
|
|
warning.message for warning in modifier.node_warnings
|
|
]
|
|
finally:
|
|
result.to_mesh_clear()
|
|
|
|
|
|
@pytest.mark.parametrize("thickness,inflation", [
|
|
(0.0, 1.0), (0.02, 0.0), (0.02, 0.0001),
|
|
(0.02, 0.5), (0.1, 0.5), (0.1, 1.0),
|
|
])
|
|
def test_shell_ignores_front_inflation(thickness, inflation):
|
|
from tests.support.depth_surface import surface, evaluated, assert_closed
|
|
obj, set_value = surface(step=0, resolution=10)
|
|
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
|
|
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 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", inflation)
|
|
inflated, inflated_faces = evaluated(obj)
|
|
assert faces == inflated_faces
|
|
np.testing.assert_array_equal(inflated, baseline)
|
|
if thickness:
|
|
assert_closed(inflated_faces)
|
|
|
|
|
|
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)
|
|
xz = np.array([(v.co.x, v.co.z) for v in obj.data.vertices])
|
|
profile = 0.3 * np.sin(np.pi * xz[:, 0] / 2) * np.sin(np.pi * xz[:, 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)
|
|
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)
|