This commit is contained in:
1
tests/nodes/__init__.py
Normal file
1
tests/nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Node group behavior tests."""
|
||||
102
tests/nodes/test_boundary_depth.py
Normal file
102
tests/nodes/test_boundary_depth.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Verify boundary depth filtering before camera and radial projection."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import evaluated
|
||||
from tests.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
|
||||
|
||||
|
||||
|
||||
def test_cutout_fine_outline_smooths_only_within_boundary_band():
|
||||
from tests.support.depth_surface import surface
|
||||
from anyimage.operators.cutout_tool.mesh import build_mesh
|
||||
|
||||
obj, set_value = surface(step=0, resolution=8)
|
||||
yy, xx = np.mgrid[:32, :64]
|
||||
alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float)
|
||||
points, faces, _, _, _ = build_mesh(alpha, 3, .9)
|
||||
obj.data.clear_geometry()
|
||||
obj.data.from_pydata([(x / 32, y / 32, 0) for x, y in points], [], faces.tolist())
|
||||
uv = obj.data.uv_layers.get("UVMap") or obj.data.uv_layers.new(name="UVMap")
|
||||
for loop in obj.data.loops:
|
||||
uv.data[loop.index].uv = points[loop.vertex_index] / (64, 32)
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = 2 + 12 * ((xx > 25) & (xx < 39) & (yy > 25))
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Boundary Smooth", 0)
|
||||
before, original_faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces = evaluated(obj)
|
||||
assert result_faces == original_faces
|
||||
band = edge_band(original_faces, boundary_neighbors(original_faces))
|
||||
interior = [i for i in range(len(before)) if i not in band]
|
||||
assert interior
|
||||
np.testing.assert_array_equal(after[interior], before[interior])
|
||||
assert np.max(np.linalg.norm(after - before, axis=1)) > 1e-4
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], before)
|
||||
|
||||
|
||||
def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
pixels[..., 2] = 2
|
||||
pixels[2:6, 6:10, 3] = 0
|
||||
pixels[1:3, 7:9, 2] = 12
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
for name, value in (("Subdivide", 5), ("Depth Scale", 1)):
|
||||
set_value(name, value)
|
||||
before, faces, uv, *_ = plane_mesh(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces, result_uv, *_ = plane_mesh(obj)
|
||||
assert faces == result_faces
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
assert after[:, 1].max() < before[:, 1].max() - .1
|
||||
protected = np.any(np.isclose(uv, 0) | np.isclose(uv, 1), axis=1)
|
||||
corners = np.concatenate(faces)
|
||||
outer = np.unique(corners[protected])
|
||||
np.testing.assert_array_equal(after[outer], before[outer])
|
||||
# Position smoothing moves the cut band off its projection ray by design.
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
kept = [i for i in range(len(before)) if i not in band]
|
||||
np.testing.assert_allclose(after[kept][:, [0, 2]] / (1.2 + after[kept][:, 1:2]),
|
||||
before[kept][:, [0, 2]] / (1.2 + before[kept][:, 1:2]), atol=1e-6)
|
||||
set_value("Depth Mask", False)
|
||||
intact = plane_mesh(obj)[0]
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(plane_mesh(obj)[0], intact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region", ["seam", "pole"])
|
||||
def test_panorama_blurs_opening_radially(region):
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
distance = np.minimum(xx, 128 - xx)
|
||||
hole = (distance**2 + (yy - 32)**2 < 9**2) if region == "seam" else (yy > 54)
|
||||
spike = ((distance < 4) & (yy > 40) & (yy < 44)) if region == "seam" else ((yy > 50) & (xx < 8))
|
||||
depth = np.where(spike, 10., 3.)
|
||||
obj, set_value = panorama(depth, (~hole).astype(float), subdivide=5)
|
||||
before, faces, uv = panorama_mesh(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces, result_uv = panorama_mesh(obj)
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
radii = np.linalg.norm(before, axis=1)
|
||||
smoothed = np.linalg.norm(after, axis=1)
|
||||
rim = list(boundary_neighbors(faces))
|
||||
assert smoothed[rim].max() < radii[rim].max() - .1
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
interior = [i for i in range(len(before)) if i not in band]
|
||||
assert interior
|
||||
# Position smoothing moves the opening rim off its radial direction by design.
|
||||
np.testing.assert_allclose(after[interior] / smoothed[interior, None],
|
||||
before[interior] / radii[interior, None], atol=1e-6)
|
||||
np.testing.assert_array_equal(after[interior], before[interior])
|
||||
209
tests/nodes/test_boundary_smoothing.py
Normal file
209
tests/nodes/test_boundary_smoothing.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Verify pinned-boundary smoothing stays within its fixed edge band."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
|
||||
|
||||
def diagonal_surface(triangles=False):
|
||||
obj, set_value = surface(resolution=16)
|
||||
if triangles:
|
||||
mesh = bmesh.new()
|
||||
mesh.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
|
||||
mesh.to_mesh(obj.data)
|
||||
mesh.free()
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
yy, xx = np.mgrid[:32, :64]
|
||||
pixels[..., 2] = 1 + (xx > 18 + yy * 0.7) * 6
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Split", 0.5)
|
||||
# A shallow display keeps the cut nearly flat so smoothing cannot fold the
|
||||
# sawtooth slivers this fixture exists to exercise.
|
||||
set_value("Depth Scale", 0.05)
|
||||
return obj, set_value
|
||||
|
||||
|
||||
def boundary_neighbors(faces):
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
neighbors = {}
|
||||
for (a, b), count in edges.items():
|
||||
if count == 1:
|
||||
neighbors.setdefault(a, []).append(b)
|
||||
neighbors.setdefault(b, []).append(a)
|
||||
return neighbors
|
||||
|
||||
|
||||
def edge_band(faces, seeds, rings=2):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
for a, b in zip(face, (*face[1:], face[0])):
|
||||
adjacency.setdefault(a, set()).add(b)
|
||||
adjacency.setdefault(b, set()).add(a)
|
||||
selected = set(seeds)
|
||||
for _ in range(rings):
|
||||
selected |= {neighbor for vertex in selected for neighbor in adjacency[vertex]}
|
||||
return selected
|
||||
|
||||
|
||||
def test_cutout_boundary_smooth_defaults_to_four():
|
||||
obj, _ = surface()
|
||||
socket = next(
|
||||
item for item in obj.modifiers[0].node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Boundary Smooth"
|
||||
)
|
||||
assert socket.default_value == 4
|
||||
assert (socket.min_value, socket.max_value) == (0, 16)
|
||||
|
||||
|
||||
def test_smoothing_reduces_stairs_without_leaving_two_ring_band():
|
||||
obj, set_value = diagonal_surface(triangles=True)
|
||||
original, faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
|
||||
outer |= np.isclose(original[:, 2], 0) | np.isclose(original[:, 2], 1)
|
||||
cut = {i for i in boundary if not outer[i]}
|
||||
band = edge_band(faces, boundary)
|
||||
movable = np.array([i in band for i in range(len(original))])
|
||||
assert movable.any() and (~movable).any()
|
||||
set_value("Boundary Smooth", 16)
|
||||
smoothed, result_faces = evaluated(obj)
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(smoothed[~movable], original[~movable])
|
||||
assert np.max(np.linalg.norm(smoothed - original, axis=1)) > 0.005
|
||||
nearby = [i for i in band if i not in boundary and not outer[i]]
|
||||
assert np.max(np.linalg.norm(smoothed[nearby] - original[nearby], axis=1)) > 1e-4
|
||||
before, after = [], []
|
||||
for i in cut:
|
||||
adjacent = boundary[i]
|
||||
if len(adjacent) != 2:
|
||||
continue
|
||||
before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i]))
|
||||
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
|
||||
assert sum(after) < 0.8 * sum(before)
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
|
||||
|
||||
def test_smoothing_without_split_moves_outline_only_within_boundary_band():
|
||||
obj, set_value = diagonal_surface()
|
||||
set_value("Depth Split", 0)
|
||||
original, faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 16)
|
||||
result, result_faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
band = edge_band(faces, boundary)
|
||||
outside = [i for i in range(len(original)) if i not in band]
|
||||
assert np.max(np.linalg.norm(result[list(boundary)] - original[list(boundary)], axis=1)) > 1e-4
|
||||
np.testing.assert_array_equal(result[outside], original[outside])
|
||||
assert result_faces == faces
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
|
||||
|
||||
def test_panorama_smoothing_preserves_interior_and_uvs():
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0)
|
||||
obj, set_value = panorama(depth, subdivide=4)
|
||||
set_value("Depth Split", 0.5)
|
||||
original, faces, uv = panorama_mesh(obj)
|
||||
rim = boundary_neighbors(faces)
|
||||
band = edge_band(faces, rim)
|
||||
interior = [i for i in range(len(original)) if i not in band]
|
||||
set_value("Boundary Smooth", 4)
|
||||
result, result_faces, result_uv = panorama_mesh(obj)
|
||||
assert np.isfinite(result).all()
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(result[interior], original[interior])
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
|
||||
|
||||
|
||||
def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, inputs, image = create_surface("DEPTH")
|
||||
assert inputs["Boundary Smooth"].default_value == 4
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
yy, xx = np.mgrid[:8, :16]
|
||||
pixels[..., 2] = 1 + (xx > 5 + yy * 0.7) * 6
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Subdivide", 5)
|
||||
set_value("Depth Scale", 1)
|
||||
set_value("Depth Split", 0.5)
|
||||
original, faces, uv, *_ = plane_mesh(obj)
|
||||
set_value("Boundary Smooth", 4)
|
||||
result, result_faces, result_uv, *_ = plane_mesh(obj)
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
|
||||
set_value("Thickness", 0.03)
|
||||
assert_closed(plane_mesh(obj)[1])
|
||||
|
||||
|
||||
def test_validity_cut_smoothing_preserves_original_outline_and_interior():
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 5)
|
||||
set_value("Depth Split", 0)
|
||||
set_value("Depth Mask", False)
|
||||
whole = plane_mesh(obj)[0]
|
||||
low, high = whole.min(axis=0), whole.max(axis=0)
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
yy, xx = np.mgrid[:8, :16]
|
||||
pixels[..., 3] = xx < 5 + yy * 0.7
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Mask", True)
|
||||
before, faces, uv, *_ = plane_mesh(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
outer = np.any(np.isclose(before[:, [0, 2]], low[[0, 2]]) | np.isclose(before[:, [0, 2]], high[[0, 2]]), axis=1)
|
||||
cut = {i for i in boundary if not outer[i]}
|
||||
band = edge_band(faces, cut)
|
||||
fixed = np.array([i not in band or outer[i] for i in range(len(before))])
|
||||
set_value("Boundary Smooth", 5)
|
||||
after, result_faces, result_uv, *_ = plane_mesh(obj)
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
np.testing.assert_array_equal(after[fixed], before[fixed])
|
||||
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
|
||||
before_bends, after_bends = [], []
|
||||
for i in cut:
|
||||
if len(boundary[i]) == 2:
|
||||
before_bends.append(np.linalg.norm(before[boundary[i]].mean(axis=0)-before[i]))
|
||||
after_bends.append(np.linalg.norm(after[boundary[i]].mean(axis=0)-after[i]))
|
||||
assert sum(after_bends) < sum(before_bends) * 0.8
|
||||
set_value("Thickness", 0.03)
|
||||
assert_closed(plane_mesh(obj)[1])
|
||||
set_value("Thickness", 0)
|
||||
set_value("Depth Mask", False)
|
||||
np.testing.assert_array_equal(plane_mesh(obj)[0], whole)
|
||||
|
||||
|
||||
def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32)
|
||||
obj, set_value = panorama(np.full((64, 128), 3), alpha, subdivide=4)
|
||||
set_value("Depth Split", 0)
|
||||
before, faces, uv = panorama_mesh(obj)
|
||||
band = edge_band(faces, boundary_neighbors(faces))
|
||||
outside = [i for i in range(len(before)) if i not in band]
|
||||
set_value("Boundary Smooth", 5)
|
||||
after, result_faces, result_uv = panorama_mesh(obj)
|
||||
assert result_faces == faces
|
||||
np.testing.assert_array_equal(result_uv, uv)
|
||||
np.testing.assert_array_equal(after[outside], before[outside])
|
||||
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
|
||||
72
tests/nodes/test_cutout_boundary.py
Normal file
72
tests/nodes/test_cutout_boundary.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Verify split profile shaping and displacement direction shaping."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.nodes.test_boundary_smoothing import diagonal_surface
|
||||
|
||||
|
||||
def profile_values(obj):
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
return np.array([v.value for v in mesh.attributes["o_balloon"].data])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def vertex_uv(obj):
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
uv = np.zeros((len(mesh.vertices), 2))
|
||||
for loop in mesh.loops:
|
||||
uv[loop.vertex_index] = mesh.uv_layers["UVMap"].data[loop.index].uv
|
||||
return uv
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iterations", [0, 16])
|
||||
def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
|
||||
obj, set_value = diagonal_surface(triangles=True)
|
||||
original, faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", iterations)
|
||||
points, actual = evaluated(obj)
|
||||
assert actual == faces
|
||||
indices = np.array(faces)
|
||||
def normals(p):
|
||||
return np.cross(p[indices[:,1]]-p[indices[:,0]], p[indices[:,2]]-p[indices[:,0]])
|
||||
assert np.all(np.sum(normals(original)*normals(points), axis=1) > 0)
|
||||
|
||||
|
||||
def test_split_profile_tapers_within_boundary_band():
|
||||
resolution, aspect = 12, .5
|
||||
obj, set_value = surface(step=6, resolution=resolution)
|
||||
for vertex in obj.data.vertices:
|
||||
vertex.co.x *= aspect
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 0] *= aspect
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
|
||||
"value", np.full(len(obj.data.vertices), .3, dtype=np.float32))
|
||||
set_value("Depth Scale", 1)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
set_value("Depth Split", .1)
|
||||
values = profile_values(obj)
|
||||
uv = vertex_uv(obj)
|
||||
# The depth step sits at u=0.5, so only that column is tapered.
|
||||
cut = (np.abs(uv[:, 0] - .5) < 1e-4) & (uv[:, 1] > 1e-3) & (uv[:, 1] < 1 - 1e-3)
|
||||
assert cut.any()
|
||||
np.testing.assert_array_equal(values[cut], 0)
|
||||
untouched = (np.abs(uv[:, 0] - .25) < 1e-3) | (np.abs(uv[:, 0] - .75) < 1e-3)
|
||||
assert untouched.any()
|
||||
np.testing.assert_allclose(values[untouched], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
set_value("Depth Split", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
283
tests/nodes/test_cutout_symmetry.py
Normal file
283
tests/nodes/test_cutout_symmetry.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Check the canonical Y mirror and final X orientation for Depth Symmetry."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tests.support.depth_surface import assert_closed, evaluated
|
||||
from nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
|
||||
|
||||
def _set_value(obj, modifier, group):
|
||||
def setter(name, value, subtype=None):
|
||||
set_modifier_input(
|
||||
modifier,
|
||||
modifier_input_identifier(group, name, subtype=subtype),
|
||||
value,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
return setter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def symmetry():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
count = 5
|
||||
z, x = np.meshgrid(
|
||||
np.linspace(-1, 1, count),
|
||||
np.linspace(-1, 1, count),
|
||||
indexing="ij",
|
||||
)
|
||||
faces = []
|
||||
for row in range(count - 1):
|
||||
for column in range(count - 1):
|
||||
start = row * count + column
|
||||
faces.extend(((start, start + 1, start + count + 1), (start, start + count + 1, start + count)))
|
||||
boundary = (np.maximum(abs(x), abs(z)) == 1).ravel()
|
||||
faces = [face for face in faces if not all(boundary[index] for index in face)]
|
||||
|
||||
mesh = bpy.data.meshes.new("Curved Depth")
|
||||
mesh.from_pydata(np.column_stack((x.ravel(), np.zeros(x.size), z.ravel())), [], faces)
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop in mesh.loops:
|
||||
row, column = divmod(loop.vertex_index, count)
|
||||
uv.data[loop.index].uv = ((column + 0.5) / count, (row + 0.5) / count)
|
||||
profile = 0.3 * np.maximum(0, 1 - np.maximum(abs(x), abs(z)))
|
||||
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
|
||||
"value", profile.ravel()
|
||||
)
|
||||
mesh.attributes.new("user_probe", "FLOAT", "POINT").data.foreach_set(
|
||||
"value", np.ones(x.size)
|
||||
)
|
||||
|
||||
image = bpy.data.images.new("Depth", width=count, height=count, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
depth = 1 + 0.6 * x**2 + 0.3 * z**2
|
||||
image.pixels.foreach_set(
|
||||
np.dstack((x, -z, depth, np.ones_like(x))).astype(np.float32).ravel()
|
||||
)
|
||||
|
||||
obj = bpy.data.objects.new("Symmetry", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
|
||||
cutout_group = build_image_depth_cutout_group()
|
||||
mirror_group = build_image_cutout_symmetry_group()
|
||||
cutout = obj.modifiers.new(cutout_group.name, "NODES")
|
||||
cutout.node_group = cutout_group
|
||||
mirror = obj.modifiers.new(mirror_group.name, "NODES")
|
||||
mirror.node_group = mirror_group
|
||||
|
||||
cutout_setter = _set_value(obj, cutout, cutout_group)
|
||||
mirror_setter = _set_value(obj, mirror, mirror_group)
|
||||
mirror_setter("Smooth", 0)
|
||||
for name, value in (
|
||||
("Depth Image", image),
|
||||
("Reference Depth", 1.45),
|
||||
("Depth Split", 0),
|
||||
("Boundary Smooth", 0),
|
||||
):
|
||||
cutout_setter(name, value)
|
||||
return obj, cutout, mirror, cutout_setter, mirror_setter
|
||||
|
||||
|
||||
def test_symmetry_interface_and_cutout_defaults(symmetry):
|
||||
_obj, cutout, mirror, _cutout_setter, _mirror_setter = symmetry
|
||||
assert not any(node.bl_idname == "GeometryNodeMeshBoolean" for node in mirror.node_group.nodes)
|
||||
mirror_inputs = [
|
||||
item
|
||||
for item in mirror.node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
]
|
||||
assert [item.name for item in mirror_inputs] == [
|
||||
"Geometry",
|
||||
"Direction",
|
||||
"Offset",
|
||||
"Scale",
|
||||
"Fill Sides",
|
||||
"Smooth",
|
||||
"Merge Distance",
|
||||
]
|
||||
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
|
||||
assert mirror_inputs[3].default_value == 1
|
||||
assert mirror_inputs[2].min_value == -10
|
||||
assert mirror_inputs[2].max_value == 10
|
||||
assert mirror_inputs[3].min_value == 0
|
||||
assert mirror_inputs[3].max_value == 2
|
||||
assert mirror_inputs[4].default_value is True
|
||||
assert mirror_inputs[5].default_value == 4
|
||||
assert mirror_inputs[6].default_value == pytest.approx(0.001)
|
||||
assert mirror_inputs[6].min_value == 0
|
||||
|
||||
cutout_names = {
|
||||
item.name
|
||||
for item in cutout.node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
}
|
||||
assert "Depth Axis" not in cutout_names
|
||||
thickness = [
|
||||
item
|
||||
for item in cutout.node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Thickness"
|
||||
]
|
||||
assert len(thickness) == 2 and all(item.default_value == 0 for item in thickness)
|
||||
|
||||
|
||||
def test_final_symmetry_axis_is_x_and_z_is_up(symmetry):
|
||||
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
|
||||
cutout_setter("Mode", 1)
|
||||
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
|
||||
points, faces = evaluated(obj)
|
||||
|
||||
assert len(points) and np.isfinite(points).all()
|
||||
assert cKDTree(points).query(points * (-1, 1, 1))[0].max() < 1e-5
|
||||
assert np.ptp(points[:, 2]) > 1.0
|
||||
|
||||
|
||||
def _tolerance(points):
|
||||
size = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
|
||||
return max(size, 1e-8) * 1e-7
|
||||
|
||||
|
||||
def _evaluated(obj):
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
points = np.array([vertex.co[:] for vertex in mesh.vertices])
|
||||
faces = [tuple(polygon.vertices) for polygon in mesh.polygons]
|
||||
marker = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
return points, faces, marker
|
||||
|
||||
|
||||
def _open_edges(faces):
|
||||
counts = Counter(
|
||||
tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))
|
||||
)
|
||||
return [edge for edge, count in counts.items() if count == 1]
|
||||
|
||||
|
||||
def _marked_vertices(points, faces, marker, value):
|
||||
indices = {index for face, item in zip(faces, marker) if item == value for index in face}
|
||||
return points[sorted(indices)]
|
||||
|
||||
|
||||
def _front_vertices(points, faces, marker, predicate):
|
||||
return np.array(
|
||||
[point for point in _marked_vertices(points, faces, marker, 1) if predicate(point)]
|
||||
)
|
||||
|
||||
|
||||
def _positions(points):
|
||||
return {tuple(np.round(point, 6)) for point in points}
|
||||
|
||||
|
||||
def _front_rim(points, faces, marker, tolerance):
|
||||
return _front_vertices(points, faces, marker, lambda point: abs(point[0]) <= tolerance)
|
||||
|
||||
|
||||
def test_seam_clamps_geometry_onto_the_symmetry_plane(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, _mirror_setter = symmetry
|
||||
points, faces, marker = _evaluated(obj)
|
||||
tolerance = _tolerance(points)
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
|
||||
assert _marked_vertices(points, faces, marker, 1)[:, 0].min() >= -tolerance
|
||||
assert _marked_vertices(points, faces, marker, 2)[:, 0].max() <= tolerance
|
||||
assert plane.any()
|
||||
assert not any(all(plane[index] for index in face) for face in faces)
|
||||
# 跨界的面保留,切口环被回拉到对称面上。
|
||||
assert any(plane[list(face)].any() and not plane[list(face)].all() for face in faces)
|
||||
|
||||
|
||||
def test_disabled_fill_keeps_the_welded_seam(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Fill Sides", False)
|
||||
points, faces = evaluated(obj)
|
||||
tolerance = _tolerance(points)
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
open_edges = _open_edges(faces)
|
||||
|
||||
assert open_edges
|
||||
assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges)
|
||||
|
||||
|
||||
def test_merge_distance_snaps_the_welded_band(symmetry):
|
||||
"""合并距离决定平面附近折平还是补面:带内直接焊住,带外的开口才补。"""
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Merge Distance", 0.05, subtype="DISTANCE")
|
||||
points, faces, marker = _evaluated(obj)
|
||||
band = np.abs(points[:, 0]) < 0.05
|
||||
|
||||
assert_closed(faces)
|
||||
assert band.any()
|
||||
# 补面墙体从带外开口挤向对称面,因此每张补面至少跨过半个合并距离。
|
||||
for face, value in zip(faces, marker):
|
||||
if value == 3:
|
||||
assert np.abs(points[list(face)][:, 0]).max() > 0.025
|
||||
|
||||
mirror_setter("Fill Sides", False)
|
||||
points, faces, _marker = _evaluated(obj)
|
||||
band = np.abs(points[:, 0]) < 0.05
|
||||
|
||||
# 合并距离之内的点全部折到对称面上,接缝直接焊住。
|
||||
assert np.abs(points[band][:, 0]).max() < _tolerance(points)
|
||||
assert not any(
|
||||
np.abs(points[list(edge)][:, 0]).max() < 0.05 for edge in _open_edges(faces)
|
||||
)
|
||||
|
||||
|
||||
def test_fill_smooth_relaxes_the_junction_band(symmetry):
|
||||
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
|
||||
mirror_setter("Fill Sides", True)
|
||||
mirror_setter("Smooth", 0)
|
||||
plain_points, plain_faces, plain_marker = _evaluated(obj)
|
||||
mirror_setter("Smooth", 4)
|
||||
smooth_points, smooth_faces, smooth_marker = _evaluated(obj)
|
||||
tolerance = _tolerance(plain_points)
|
||||
|
||||
for points, faces in ((plain_points, plain_faces), (smooth_points, smooth_faces)):
|
||||
plane = np.abs(points[:, 0]) <= tolerance
|
||||
assert plane.any()
|
||||
assert not any(all(plane[index] for index in face) for face in faces)
|
||||
assert_closed(faces)
|
||||
# 焊好之后衔接带连同补面一起松弛,贴合对称面的一圈随之变形。
|
||||
band = 0.25 * np.ptp(plain_points[:, 0])
|
||||
plain_band = _positions(plain_points[np.abs(plain_points[:, 0]) <= band])
|
||||
smooth_band = _positions(smooth_points[np.abs(smooth_points[:, 0]) <= band])
|
||||
assert plain_band != smooth_band
|
||||
assert cKDTree(smooth_points).query(smooth_points * (-1, 1, 1))[0].max() < 1e-5
|
||||
|
||||
|
||||
def test_object_normal_attributes_follow_final_orientation(symmetry):
|
||||
obj, _cutout, _mirror, cutout_setter, _mirror_setter = symmetry
|
||||
cutout_setter("Mode", 1)
|
||||
cutout_setter("Thickness", 0.2, subtype="DISTANCE")
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = evaluated_obj.to_mesh()
|
||||
try:
|
||||
assert AXIS_ATTRIBUTE in mesh.attributes
|
||||
assert ROTATION_ATTRIBUTE in mesh.attributes
|
||||
assert FACE_ATTRIBUTE in mesh.attributes
|
||||
axis = np.array([item.value for item in mesh.attributes[AXIS_ATTRIBUTE].data])
|
||||
face = np.array([item.value for item in mesh.attributes[FACE_ATTRIBUTE].data])
|
||||
rotation = np.array(
|
||||
[item.vector[:] for item in mesh.attributes[ROTATION_ATTRIBUTE].data]
|
||||
)
|
||||
assert np.allclose(axis, 1)
|
||||
assert set(np.unique(face)) <= {1, 2, 3}
|
||||
assert np.isfinite(rotation).all()
|
||||
finally:
|
||||
evaluated_obj.to_mesh_clear()
|
||||
55
tests/nodes/test_cutouts.py
Normal file
55
tests/nodes/test_cutouts.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from nodes.groups.image_cutout import build_image_cutout_group
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID"))
|
||||
def test_depth_axis_is_removed_from_cutout_options(shape):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = CUTOUT_BUILDERS[shape]()
|
||||
inputs = {item.name: item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
assert "Depth Axis" not in inputs
|
||||
assert "Inward Axis" not in inputs
|
||||
assert all(item.name != "Depth Axis" for item in group.interface.items_tree)
|
||||
|
||||
|
||||
def test_cutout_thickness_modes():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("Profile")
|
||||
mesh.from_pydata([(-1,0,-1), (1,0,-1), (1,0,1), (-1,0,1), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])
|
||||
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
|
||||
profile.data.foreach_set("value", [0,0,0,0,0.6])
|
||||
obj = bpy.data.objects.new("Profile", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Cutout", "NODES")
|
||||
modifier.node_group = build_image_cutout_group()
|
||||
for mode, thickness in ((0, 0), (0, 1), (1, 0), (1, 0.4)):
|
||||
for name, value in (("Mode", mode), ("Thickness", thickness)):
|
||||
subtype = ("DISTANCE" if mode else "NONE") if name == "Thickness" else None
|
||||
set_modifier_input(modifier, modifier_input_identifier(modifier.node_group, name, subtype=subtype), value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
y = np.array([vertex.co.y for vertex in result.vertices])
|
||||
assert np.isfinite(y).all() and len(y)
|
||||
if thickness == 0:
|
||||
assert len(result.polygons) == 4 and np.max(abs(y)) < 1e-7
|
||||
assert all(abs(item.value) < 1e-7 for item in result.attributes["o_normal_reduction"].data)
|
||||
else:
|
||||
back, front = (-0.6, 0.6) if mode == 0 else (0.0, thickness)
|
||||
assert np.isclose(y.min(), back) and np.isclose(y.max(), front)
|
||||
uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0])))
|
||||
assert set(uses.values()) == {2}
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
180
tests/nodes/test_depth_cutout.py
Normal file
180
tests/nodes/test_depth_cutout.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Behavior checks for the Depth Cutout balloon and shell shapes."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
def test_edge_turn_reshapes_thickness_without_moving_zero_thickness(mode):
|
||||
"""Edge Turn swings the wall direction and leaves zero thickness untouched."""
|
||||
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")
|
||||
turn = modifier_input_identifier(modifier.node_group, "Edge Turn")
|
||||
|
||||
def result(amount, edge_turn):
|
||||
set_modifier_input(modifier, thickness, amount)
|
||||
set_modifier_input(modifier, turn, edge_turn)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
return evaluated(obj)
|
||||
|
||||
plain, faces = result(0, 0)
|
||||
turned, turned_faces = result(0, 2)
|
||||
np.testing.assert_array_equal(plain, turned)
|
||||
assert faces == turned_faces
|
||||
plain, faces = result(1, 0)
|
||||
turned, turned_faces = result(1, 2)
|
||||
assert faces == turned_faces
|
||||
assert np.isfinite(turned).all()
|
||||
assert np.max(np.abs(plain - turned)) > 1e-5
|
||||
|
||||
|
||||
def test_shell_offset_scales_with_thickness_and_ignores_reference():
|
||||
"""A thin Shell moves a distance proportional to its thickness."""
|
||||
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, reference=10.):
|
||||
set_value('Thickness', thickness)
|
||||
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
|
||||
|
||||
smooth, faces = offsets(.02)
|
||||
thinner, thinner_faces = offsets(.01)
|
||||
distant, _ = offsets(.02, 20.)
|
||||
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
|
||||
np.testing.assert_allclose(distant, smooth, atol=2e-4)
|
||||
assert thinner_faces == faces
|
||||
|
||||
|
||||
def test_shell_thickness_extends_behind_the_tilted_front():
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
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, _ = 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)
|
||||
|
||||
|
||||
def test_balloon_shell_converges_to_front_with_thickness():
|
||||
from scipy.spatial import cKDTree
|
||||
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 = 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, _ = amount(value)
|
||||
gaps.append(tree.query(points)[0].max())
|
||||
assert gaps[0] > gaps[1] > gaps[2]
|
||||
np.testing.assert_allclose(gaps[1] / gaps[0], 0.1, rtol=0.02)
|
||||
|
||||
|
||||
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 0.0 in values and 1.0 in values
|
||||
assert all(np.isfinite(values))
|
||||
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())
|
||||
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.5), (0.1, 1.0),
|
||||
])
|
||||
def test_shell_ignores_front_inflation(thickness, inflation):
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
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, baseline_faces = evaluated(obj)
|
||||
set_value("Front Inflation", inflation)
|
||||
inflated, inflated_faces = evaluated(obj)
|
||||
assert baseline_faces == inflated_faces
|
||||
np.testing.assert_array_equal(inflated, baseline)
|
||||
363
tests/nodes/test_depth_relief_planes.py
Normal file
363
tests/nodes/test_depth_relief_planes.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Evaluate complete-image projection and rectangular surface topology."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from tests.support.planes import surface, relief, evaluated
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subdivide", [0, 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, 2])
|
||||
def test_camera_projection_and_reference_depth(surface, strength):
|
||||
obj, set_value, _, _, _ = surface
|
||||
base = evaluated(obj)
|
||||
uv = (base[0][:, [0, 2]] + (2, 1)) / (4, 2)
|
||||
texel = np.clip(uv * (16, 8), (0.5, 0.5), (15.5, 7.5))
|
||||
depth = 2 + texel[:, 0] / 16
|
||||
# Outline vertices keep the border ray and adopt the adjacent face-centre
|
||||
# depth, the same rule cut edges use to inherit their face sample.
|
||||
face_depth = np.zeros((len(base[0]), 2))
|
||||
for face in base[1]:
|
||||
center = np.mean(base[0][list(face)], axis=0)
|
||||
value = 2 + np.clip((center[0] + 2) / 4 * 16, 0.5, 15.5) / 16
|
||||
for index in face:
|
||||
face_depth[index] += (value, 1)
|
||||
face_depth = face_depth[:, 0] / face_depth[:, 1]
|
||||
on_outline = np.isclose(uv, 0).any(axis=1) | np.isclose(uv, 1).any(axis=1)
|
||||
ratio = np.where(on_outline, face_depth / depth, 1.0)
|
||||
camera = np.column_stack(((texel[:, 0] - 5) / 8 * ratio,
|
||||
(depth * 0.5) * ratio - 1.2,
|
||||
(texel[:, 1] - 3) / 8 * ratio))
|
||||
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.5 * strength, 0), (len(shifted), 1)), atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth_scale", [0.0, 2.0])
|
||||
def test_relief_depth_scale_sets_normal_reduction(relief, depth_scale):
|
||||
obj, set_value, *_ = relief
|
||||
set_value("Depth Scale", depth_scale)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
values = np.array([
|
||||
value.value for value in mesh.attributes[NORMAL_REDUCTION_ATTRIBUTE_NAME].data
|
||||
])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
assert np.isfinite(values).all()
|
||||
if depth_scale == 0.0:
|
||||
np.testing.assert_allclose(values, 1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [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, _ = evaluated(obj)
|
||||
front_set = {tuple(point) for point in front}
|
||||
assert front_set <= {tuple(point) for point in points}
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
assert materials[0] == "Image" and set(indices) == {0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("valid_only", [False, True])
|
||||
def test_depth_split_preserves_faces_and_closes_each_shell(surface, valid_only):
|
||||
obj, set_value, _, _, image = surface
|
||||
pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[:, 8:, 2] += 10
|
||||
pixels[:2, :, 3] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Depth Mask", valid_only)
|
||||
base = evaluated(obj)
|
||||
set_value("Depth Split", 1.0)
|
||||
points, faces, uv, materials, indices, _ = evaluated(obj)
|
||||
assert len(points) > len(base[0])
|
||||
assert len(faces) == len(base[1])
|
||||
np.testing.assert_allclose(uv, base[2])
|
||||
assert (materials, indices) == base[3:5]
|
||||
set_value("Thickness", 0.2)
|
||||
shell_points, shell_faces, shell_uv, *_ = evaluated(obj)
|
||||
assert np.isfinite(shell_points).all() and np.isfinite(shell_uv).all()
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Depth Split", 0.0)
|
||||
restored = evaluated(obj)
|
||||
np.testing.assert_allclose(restored[0], base[0])
|
||||
assert restored[1] == base[1]
|
||||
|
||||
|
||||
def test_depth_scale_gates_depth_split(surface):
|
||||
obj, set_value, _, _, image = surface
|
||||
pixels = np.asarray(image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[:, 8:, 2] += 10
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
whole = evaluated(obj)
|
||||
set_value("Depth Split", 1.0)
|
||||
split = evaluated(obj)
|
||||
assert len(split[0]) > len(whole[0])
|
||||
set_value("Depth Scale", 0.0)
|
||||
set_value("Depth Split", 0.0)
|
||||
flat = evaluated(obj)
|
||||
set_value("Depth Split", 1.0)
|
||||
cancelled = evaluated(obj)
|
||||
assert cancelled[1] == flat[1]
|
||||
np.testing.assert_allclose(cancelled[0], flat[0], atol=1e-6)
|
||||
|
||||
|
||||
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][:, 1]) > 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.4])
|
||||
def test_relief_reads_z_and_keeps_the_base(relief, thickness):
|
||||
obj, set_value, _, inputs, image = relief
|
||||
set_value("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 np.isfinite(first[0]).all()
|
||||
assert np.ptp(first[0][:, 0]) > 0 and np.ptp(first[0][:, 2]) > 0
|
||||
pixels[..., 0], pixels[..., 1] = 31, -22
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 0.5)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], first[0])
|
||||
assert obj.modifiers[0][inputs["Thickness"].identifier] == pytest.approx(thickness)
|
||||
|
||||
|
||||
def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief):
|
||||
obj, set_value, _, _, _ = relief
|
||||
base = evaluated(obj)
|
||||
set_value("Reference Depth", 2.0)
|
||||
set_value("Depth Scale", 1.5)
|
||||
result = evaluated(obj)
|
||||
assert np.isfinite(result[0]).all()
|
||||
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
|
||||
assert result[1] == base[1]
|
||||
np.testing.assert_array_equal(result[2], base[2])
|
||||
|
||||
|
||||
def test_relief_far_depth_stops_at_thickness_height(relief):
|
||||
obj, set_value, _, _, image = relief
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 2] = 2.0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 0.5)
|
||||
set_value("Reference Depth", 0.9)
|
||||
set_value("Depth Scale", 2.0)
|
||||
set_value("Thickness", 0.4)
|
||||
points = evaluated(obj)[0]
|
||||
assert np.isfinite(points).all()
|
||||
|
||||
|
||||
def test_relief_offset_corrects_reference_before_direction_and_scale(relief):
|
||||
obj, set_value, _, _, image = relief
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 2] = 2.0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 0.5)
|
||||
set_value("Reference Depth", 1.0)
|
||||
set_value("Depth Direction", (0.2, 1.0, -0.1))
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Depth Scale", 1.5)
|
||||
set_value("Depth Offset", 1.0)
|
||||
raised = evaluated(obj)[0]
|
||||
set_value("Depth Offset", 0.5)
|
||||
lowered = evaluated(obj)[0]
|
||||
assert np.isfinite(raised).all() and np.isfinite(lowered).all()
|
||||
assert not np.allclose(raised[:, 1], lowered[:, 1], atol=1e-6)
|
||||
|
||||
|
||||
def test_zero_depth_scale_keeps_relief_at_depth_zero(relief):
|
||||
obj, set_value, _, _, _ = relief
|
||||
set_value("Thickness", 0.4)
|
||||
set_value("Depth Offset", 1.0)
|
||||
set_value("Depth Scale", 0.0)
|
||||
points = evaluated(obj)[0]
|
||||
assert np.isfinite(points).all()
|
||||
|
||||
|
||||
def test_negative_relief_offset_is_clamped_at_depth_zero(relief):
|
||||
obj, set_value, _, _, image = relief
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 2] = 1.0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Uniform Scale", 1.0)
|
||||
set_value("Reference Depth", 1.0)
|
||||
set_value("Depth Offset", -0.5)
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Thickness", 0.3)
|
||||
points = evaluated(obj)[0]
|
||||
assert np.isfinite(points).all()
|
||||
|
||||
|
||||
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
|
||||
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()
|
||||
thickness = 0.001
|
||||
set_value("Thickness", thickness)
|
||||
points = evaluated(obj)[0]
|
||||
distances = np.linalg.norm(points[:, None, :] - front[None, :, :], axis=2)
|
||||
source = distances.argmin(axis=1)
|
||||
distance = distances.min(axis=1)
|
||||
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))
|
||||
assert roughness(normals) < roughness(raw_normals)
|
||||
|
||||
|
||||
|
||||
def test_relief_direction_adjusts_depth_and_keeps_base(relief):
|
||||
thickness = 0.4
|
||||
direction = (0.2, 1, -0.1)
|
||||
obj, set_value, _, _, image = relief
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
pixels[..., 2] = 2
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Reference Depth", 2.0)
|
||||
layers = evaluated(obj)[0]
|
||||
weights = np.clip(layers[:, 1] / thickness, 0, 1) if thickness else np.ones(len(layers))
|
||||
set_value("Depth Scale", 0.5)
|
||||
base = evaluated(obj)
|
||||
set_value("Depth Direction", direction)
|
||||
result = evaluated(obj)
|
||||
front = base[0][:, 1] > 0
|
||||
expected = base[0].copy()
|
||||
if direction[1]:
|
||||
expected[front, 1] += 0.5 * weights[front] * (expected[front][:, [0, 2]] @ np.array([direction[0], direction[2]])) / direction[1]
|
||||
np.testing.assert_array_equal(result[0][:, [0, 2]], base[0][:, [0, 2]])
|
||||
assert np.isfinite(result[0]).all()
|
||||
assert result[1] == base[1]
|
||||
np.testing.assert_array_equal(result[2], base[2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern,threshold", [
|
||||
("half", 0.9), ("stripe", 0.9),
|
||||
])
|
||||
def test_valid_only_keeps_faces_covering_valid_samples(surface, pattern, threshold):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
set_value("Mask Threshold", threshold)
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
pixels = np.ones((8, 16, 4), dtype=np.float32)
|
||||
alpha = np.ones(16, dtype=np.float32)
|
||||
if pattern == "invalid":
|
||||
alpha[:] = 0
|
||||
elif pattern == "partial":
|
||||
alpha[:] = 0.5
|
||||
elif pattern == "half":
|
||||
alpha[:8] = 0
|
||||
elif pattern == "stripe":
|
||||
alpha[:] = 0
|
||||
alpha[8] = 1
|
||||
pixels[..., 3] = alpha
|
||||
depth_image.pixels.foreach_set(pixels.ravel())
|
||||
depth_image.update()
|
||||
|
||||
def sampled(x):
|
||||
return np.interp((x + 2) / 4 * 16 - 0.5, np.arange(16), alpha)
|
||||
|
||||
for subdivisions in (0, 5):
|
||||
set_value("Subdivide", subdivisions)
|
||||
set_value("Depth Mask", False)
|
||||
base = evaluated(obj)
|
||||
expected = [face for face in base[1]
|
||||
if sampled(np.mean(base[0][list(face)][:, 0])) >= threshold]
|
||||
set_value("Depth Mask", True)
|
||||
culled = evaluated(obj)
|
||||
assert len(culled[1]) == len(expected)
|
||||
expected_points = {tuple(base[0][index]) for face in expected for index in face}
|
||||
assert {tuple(point) for point in culled[0]} == expected_points
|
||||
assert np.isfinite(culled[2]).all()
|
||||
set_value("Depth Mask", False)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0])
|
||||
|
||||
|
||||
def test_valid_only_precedes_closed_shell_generation(surface):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
pixels = np.asarray(depth_image.pixels[:], dtype=np.float32).reshape(8, 16, 4)
|
||||
pixels[:, :8, 3] = 0
|
||||
depth_image.pixels.foreach_set(pixels.ravel())
|
||||
depth_image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Thickness", 0.2)
|
||||
points, faces, uv, *_ = evaluated(obj)
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
62
tests/nodes/test_depth_strip_cleanup.py
Normal file
62
tests/nodes/test_depth_strip_cleanup.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Evaluate opposite-boundary cleanup on explicit mesh topology."""
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_strips_are_removed_before_projection(kind):
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0)
|
||||
image = bpy.data.images["Camera"]
|
||||
width, height = image.size
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(height, width, 4)
|
||||
pixels[..., 2] = 1
|
||||
pixels[:, width // 4:3 * width // 4, 2] = 21
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Reference Depth", 11.0)
|
||||
if kind == "cutout":
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(bm, faces=list(bm.faces))
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
assert len(evaluated(obj)[1]) == 14
|
||||
else:
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Depth Split", 0.5)
|
||||
vertices, faces = evaluated(obj)
|
||||
if kind == "cutout":
|
||||
assert len(faces) == 6 and len(vertices) == 7
|
||||
else:
|
||||
assert len(faces) == 4 and len(vertices) == 9
|
||||
assert np.isfinite(vertices).all()
|
||||
|
||||
|
||||
def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh():
|
||||
from tests.support.depth_surface import evaluated
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
obj, set_value, _, _, _ = create_surface("DEPTH")
|
||||
set_value("Subdivide", 0)
|
||||
set_value("Depth Scale", 1.0)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 2
|
||||
set_value("Depth Split", 0.001)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.2)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Depth Split", 0.0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
244
tests/nodes/test_depth_surface_split.py
Normal file
244
tests/nodes/test_depth_surface_split.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Behavior checks for face separation and connected Depth Surface shells."""
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tests.support.depth_surface import evaluated, surface
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [0.01, 100.0])
|
||||
def test_split_mapping_and_uniform_scale(size):
|
||||
obj, set_value = surface(step=2.0, size=size)
|
||||
# The normalized ray jump crosses the threshold only at full strength.
|
||||
counts = []
|
||||
for split in (0.0, 0.25, 0.5, 1.0):
|
||||
set_value("Depth Split", split)
|
||||
coords, faces = evaluated(obj)
|
||||
counts.append(len(coords))
|
||||
assert len(faces) == 8
|
||||
assert counts[:3] == [15] * 3
|
||||
assert counts[3] == 18
|
||||
set_value("Depth Scale", 0.0)
|
||||
coords, _ = evaluated(obj)
|
||||
assert len(coords) == counts[0] and np.abs(coords[:, 1]).max() < 1e-6
|
||||
|
||||
|
||||
def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Depth Split", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 18 and len(faces) == 8
|
||||
assert set(np.round(coords[:, 1], 4)) == {2.0, -2.0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth_scale", [0.0, 1.0])
|
||||
def test_depth_scale_scales_split_selection(depth_scale):
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Depth Scale", depth_scale)
|
||||
set_value("Depth Split", 0.0)
|
||||
whole, whole_faces = evaluated(obj)
|
||||
set_value("Depth Split", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
assert np.isfinite(coords).all()
|
||||
if depth_scale == 0.0:
|
||||
# A flat projection keeps every face, so there is nothing to separate.
|
||||
np.testing.assert_allclose(coords, whole, atol=1e-6)
|
||||
assert faces == whole_faces
|
||||
else:
|
||||
assert len(coords) > len(whole)
|
||||
|
||||
|
||||
def test_depth_scale_multiplies_split_strength():
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Depth Scale", 0.5)
|
||||
set_value("Depth Split", 1.0)
|
||||
scaled, scaled_faces = evaluated(obj)
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Depth Split", 0.5)
|
||||
reference, reference_faces = evaluated(obj)
|
||||
assert len(scaled) == len(reference) > 15
|
||||
assert scaled_faces == reference_faces
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 4])
|
||||
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(bm, faces=list(bm.faces))
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Split", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
if resolution == 1:
|
||||
assert len(coords) == 0 and not faces
|
||||
return
|
||||
assert len(faces) == len(original_faces)
|
||||
assert np.isfinite(coords).all()
|
||||
|
||||
|
||||
def test_outline_points_use_adjacent_face_depth_without_split():
|
||||
obj, set_value = surface(step=0.0, resolution=2)
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = 1
|
||||
# Spike both edge rows to cover Blender's image origin without relying on it.
|
||||
pixels[(0, -1), :, 2] = 10
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
set_value("Depth Split", 0.0)
|
||||
coords, faces = evaluated(obj)
|
||||
boundary = np.isclose(coords[:, 2], 0, atol=0.05) | np.isclose(coords[:, 2], 1, atol=0.05)
|
||||
assert boundary.any()
|
||||
np.testing.assert_allclose(coords[boundary, 1], 0, atol=1e-5)
|
||||
|
||||
|
||||
def test_small_positive_split_preserves_unsplit_projection():
|
||||
obj, set_value = surface(step=2.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Split", 0.01)
|
||||
coords, faces = evaluated(obj)
|
||||
np.testing.assert_allclose(coords, original, atol=1e-6)
|
||||
assert faces == original_faces
|
||||
|
||||
|
||||
def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
obj, set_value = surface(step=4.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Split", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
for source_face, result_face in zip(original_faces, faces):
|
||||
for source_index, result_index in zip(source_face, result_face):
|
||||
before, after = original[source_index], coords[result_index]
|
||||
if obj.data.vertices[source_index].co.x != 1.0:
|
||||
np.testing.assert_allclose(after, before, atol=1e-6)
|
||||
else:
|
||||
assert not np.allclose(after, before, atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
||||
def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
|
||||
obj, set_value = surface()
|
||||
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
|
||||
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
||||
obj.data.attributes.new(".o_user_weight", "FLOAT", "POINT")
|
||||
for value in attribute.data:
|
||||
value.value = 0.75
|
||||
set_value("Depth Split", 0.5)
|
||||
set_value("Thickness", thickness)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"}
|
||||
assert "UVMap" in mesh.attributes
|
||||
assert ".o_user_weight" in mesh.attributes
|
||||
assert not any("o_anyimage" in a.name for a in mesh.attributes)
|
||||
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
|
||||
assert all(
|
||||
v.value == pytest.approx(0.75)
|
||||
for v in mesh.attributes["o_user_weight"].data
|
||||
)
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def output_split_mesh(obj):
|
||||
"""Inspect separation before boundary strip cleanup."""
|
||||
group = obj.modifiers[0].node_group
|
||||
split = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSplitEdges")
|
||||
output = next(node for node in group.nodes if node.bl_idname == "NodeGroupOutput")
|
||||
group.links.new(split.outputs["Mesh"], output.inputs["Geometry"])
|
||||
|
||||
|
||||
def test_zero_center_distance_does_not_split_or_produce_nan():
|
||||
obj, set_value = surface(step=6.0)
|
||||
output_split_mesh(obj)
|
||||
mesh = bpy.data.meshes.new("Coincident face centers")
|
||||
mesh.from_pydata(
|
||||
[(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)]
|
||||
)
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for face in mesh.polygons:
|
||||
for li in face.loop_indices:
|
||||
uv.data[li].uv = (0.25 if face.index == 0 else 0.75, 0.5)
|
||||
obj.data = mesh
|
||||
set_value("Depth Split", 1.0)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 4 and len(faces) == 2
|
||||
assert np.isfinite(coords).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 4])
|
||||
def test_relative_distance_slope_has_density_independent_selection(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
output_split_mesh(obj)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(bm, faces=list(bm.faces))
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = np.exp(6 * pixels[..., 0])
|
||||
pixels[..., :2] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Split", 0.25)
|
||||
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
|
||||
set_value("Depth Split", 1.0)
|
||||
assert len(evaluated(obj)[0]) > len(obj.data.vertices)
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind):
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface("DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
set_value("Depth Scale", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0)
|
||||
image = bpy.data.images["Camera"]
|
||||
output_split_mesh(obj)
|
||||
set_value("Reference Depth", 3)
|
||||
set_value("Depth Split", 0)
|
||||
width, height = image.size
|
||||
pixels = np.ones((height, width, 4), np.float32)
|
||||
original_count = len(evaluated(obj)[0])
|
||||
set_value("Depth Split", 1)
|
||||
# Equal axial Z, but a tenfold ray-distance jump: Z-only splitting misses it.
|
||||
pixels[..., :3] = (0, 0, 1)
|
||||
pixels[:, width // 2:, 0] = np.sqrt(99)
|
||||
signature = None
|
||||
for scale in (0.01, 1.0, 100.0):
|
||||
scaled = pixels.copy()
|
||||
scaled[..., :3] *= scale
|
||||
image.pixels.foreach_set(scaled.ravel())
|
||||
image.update()
|
||||
set_value("Depth Image", image)
|
||||
points, faces = evaluated(obj)
|
||||
assert len(points) > original_count
|
||||
if signature is None:
|
||||
signature = points, faces
|
||||
np.testing.assert_array_equal(points, signature[0])
|
||||
assert faces == signature[1]
|
||||
# Equal ray distances with different Z must keep the faces connected.
|
||||
pixels[..., :3] = (0, 0, 10)
|
||||
pixels[:, width // 2:, :3] = (8, 0, 6)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Image", image)
|
||||
assert len(evaluated(obj)[0]) == original_count
|
||||
# Degenerate distances and a zero reference remain finite and connected.
|
||||
pixels[..., :3] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Reference Depth", 0)
|
||||
points, _ = evaluated(obj)
|
||||
assert len(points) == original_count and np.isfinite(points).all()
|
||||
355
tests/nodes/test_image_depth_panorama.py
Normal file
355
tests/nodes/test_image_depth_panorama.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Evaluate panorama topology, validity, UVs and depth discontinuities."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
|
||||
|
||||
def panorama(depth=3, alpha=1, subdivide=3):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = build_image_depth_panorama_group()
|
||||
image = bpy.data.images.new("Radial Depth", width=128, height=64, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
rgba = np.ones((64, 128, 4), np.float32)
|
||||
rgba[..., :3] = np.broadcast_to(depth, (64, 128))[..., None]
|
||||
rgba[..., 3] = alpha
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
obj = bpy.data.objects.new("Panorama", bpy.data.meshes.new("Panorama"))
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Panorama", "NODES")
|
||||
modifier.node_group = group
|
||||
|
||||
def set_value(name, value):
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
for name, value in (("Depth Image", image), ("Subdivide", subdivide)):
|
||||
set_value(name, value)
|
||||
set_value("Boundary Smooth", 0)
|
||||
set_value("Depth Split", 0)
|
||||
return obj, set_value
|
||||
|
||||
|
||||
def evaluated(obj):
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
points = np.array([v.co[:] for v in mesh.vertices]).reshape(-1, 3)
|
||||
faces = [tuple(f.vertices) for f in mesh.polygons]
|
||||
uv = np.array([v.uv[:] for v in mesh.uv_layers.active.data]) if mesh.uv_layers.active else np.empty((0, 2))
|
||||
return points, faces, uv
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def free_edges(faces):
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
return sum(count == 1 for count in edges.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subdivide", [0, 4])
|
||||
def test_quad_sphere_topology_and_radial_endpoints(subdivide):
|
||||
obj, set_value = panorama(subdivide=subdivide)
|
||||
dome = 50.0
|
||||
set_value("Dome Radius", dome)
|
||||
inputs = [s for s in obj.modifiers[0].node_group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
||||
assert [s.name for s in inputs if s.socket_type == "NodeSocketImage"] == ["Depth Image"]
|
||||
# Depth Scale blends between the dome sphere and the estimated distance.
|
||||
for scale, radius in ((0, dome), (0.5, dome + 0.5 * (3 - dome)), (1, 3)):
|
||||
set_value("Depth Scale", scale)
|
||||
points, faces, uv = evaluated(obj)
|
||||
assert len(faces) == 6 * 4 ** subdivide
|
||||
assert all(len(face) == 4 for face in faces)
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), radius, atol=1e-5)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
assert set(edges.values()) == {2}
|
||||
assert len(points) - len(edges) + len(faces) == 2
|
||||
assert np.isfinite(uv).all()
|
||||
if subdivide > 0:
|
||||
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
|
||||
|
||||
|
||||
def test_depth_mask_keeps_only_the_faces_above_the_threshold():
|
||||
obj, set_value = panorama(alpha=0.5)
|
||||
assert not len(evaluated(obj)[0])
|
||||
set_value("Depth Mask", False)
|
||||
points, faces, _ = evaluated(obj)
|
||||
assert len(faces) == 384
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-5)
|
||||
set_value("Depth Mask", True)
|
||||
set_value("Mask Threshold", 0.49)
|
||||
assert len(evaluated(obj)[1]) == 384
|
||||
|
||||
|
||||
def test_alpha_cut_keeps_the_ring_sharing_the_face_centre_contour():
|
||||
"""Faces are judged at their centre, so the ring straddling the sky stays."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
obj, _ = panorama(3.0, alpha, subdivide=4)
|
||||
points, faces, _ = evaluated(obj)
|
||||
plain, _ = panorama(3.0, 1.0, subdivide=4)
|
||||
_, full, _ = evaluated(plain)
|
||||
# Judging faces by their corners would drop one more ring of the half sphere.
|
||||
assert len(faces) == len(full) // 2
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-5)
|
||||
|
||||
|
||||
def test_invalid_depth_does_not_bleed_into_alpha_boundary():
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
depth = np.where(alpha, 3, 10000)
|
||||
obj, _ = panorama(depth, alpha, subdivide=5)
|
||||
points, _, _ = evaluated(obj)
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-5)
|
||||
|
||||
|
||||
def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
|
||||
z = -np.cos((np.arange(64) + 0.5) * np.pi / 64)
|
||||
depth = np.broadcast_to((2 / np.maximum(-z, 0.1))[:, None], (64, 128))
|
||||
obj, _ = panorama(depth, subdivide=6)
|
||||
points, _, _ = evaluated(obj)
|
||||
rays = points / np.linalg.norm(points, axis=1, keepdims=True)
|
||||
floor = rays[:, 2] < -0.5
|
||||
error = points[floor, 2] + 2
|
||||
assert np.sqrt(np.mean(error ** 2)) < 0.002
|
||||
assert np.max(abs(error)) < 0.004
|
||||
|
||||
|
||||
def test_masked_area_rests_on_the_dome_sphere():
|
||||
"""Depth Mask keeps only the valid half; the excluded half rests on the dome."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
depth = np.where(alpha, 3.0, 1e-7)
|
||||
obj, set_value = panorama(depth, alpha, subdivide=4)
|
||||
points, faces, _ = evaluated(obj)
|
||||
# Depth Mask removes the masked half; the cut ring keeps its face depth.
|
||||
assert len(faces) == 768
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-3)
|
||||
set_value("Depth Mask", False)
|
||||
set_value("Dome Radius", 50.0)
|
||||
radii = np.linalg.norm(evaluated(obj)[0], axis=1)
|
||||
on_dome = np.isclose(radii, 50.0, atol=1e-3)
|
||||
assert on_dome.any() and np.isclose(radii, 3, atol=1e-3).any()
|
||||
assert np.all(on_dome | np.isclose(radii, 3, atol=1e-3))
|
||||
set_value("Dome Radius", 50.0)
|
||||
set_value("Depth Scale", 0)
|
||||
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
|
||||
set_value("Depth Mask", True)
|
||||
assert len(evaluated(obj)[1]) == 768
|
||||
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
|
||||
|
||||
|
||||
def test_masked_region_ignores_its_own_depth_and_depth_scale():
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
depth = np.where(alpha, 3.0, 1e4)
|
||||
obj, set_value = panorama(depth, alpha, subdivide=4)
|
||||
set_value("Depth Mask", False)
|
||||
for scale in (0.0, 1.0):
|
||||
set_value("Depth Scale", scale)
|
||||
radii = np.linalg.norm(evaluated(obj)[0], axis=1)
|
||||
on_dome = np.isclose(radii, 50, atol=1e-3)
|
||||
assert on_dome.any()
|
||||
assert np.all(on_dome | np.isclose(radii, 3.0, atol=1e-3))
|
||||
|
||||
|
||||
def test_masked_regions_keep_their_own_seam_edges():
|
||||
"""Both regions reach the output, so the seam is a free edge on each side."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
plain, _ = panorama(3.0, 1.0, subdivide=4)
|
||||
whole, whole_faces, _ = evaluated(plain)
|
||||
obj, set_value = panorama(3.0, alpha, subdivide=4)
|
||||
set_value("Depth Mask", False)
|
||||
points, faces, _ = evaluated(obj)
|
||||
assert len(faces) == len(whole_faces)
|
||||
assert len(points) > len(whole)
|
||||
set_value("Depth Mask", True)
|
||||
assert free_edges(faces) == 2 * free_edges(evaluated(obj)[1])
|
||||
|
||||
|
||||
def test_boundary_smooth_relaxes_both_masked_region_boundaries():
|
||||
"""Boundary Smooth relaxes both regions at the seam and leaves their interiors."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
depth = np.where(alpha, 3.0, 1e4)
|
||||
depth[:, 56] = 30.0
|
||||
obj, set_value = panorama(depth, alpha, subdivide=4)
|
||||
set_value("Depth Mask", False)
|
||||
before = evaluated(obj)[0]
|
||||
low = np.isclose(np.linalg.norm(before, axis=1), 50, atol=1e-3)
|
||||
assert low.any()
|
||||
set_value("Boundary Smooth", 5)
|
||||
after = evaluated(obj)[0]
|
||||
moved = np.linalg.norm(after - before, axis=1) > 1e-6
|
||||
assert moved[low].any() and moved[~low].any()
|
||||
assert not moved[low].all()
|
||||
np.testing.assert_allclose(np.linalg.norm(after[low & ~moved], axis=1), 50, atol=1e-5)
|
||||
|
||||
|
||||
def test_flat_scale_without_mask_returns_a_whole_sphere():
|
||||
"""Depth Scale 0 without the mask leaves the dome sphere uncut and unsplit."""
|
||||
alpha = np.ones((64, 128))
|
||||
alpha[:, 64:] = 0
|
||||
plain, _ = panorama(3.0, 1.0, subdivide=4)
|
||||
whole, whole_faces, _ = evaluated(plain)
|
||||
obj, set_value = panorama(np.where(alpha, 3.0, 1e4), alpha, subdivide=4)
|
||||
set_value("Depth Mask", False)
|
||||
set_value("Depth Scale", 0)
|
||||
points, faces, _ = evaluated(obj)
|
||||
assert len(faces) == len(whole_faces)
|
||||
assert len(points) == len(whole)
|
||||
assert free_edges(faces) == 0
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-6)
|
||||
|
||||
|
||||
def test_controls_and_material_slot_inheritance():
|
||||
obj, set_value = panorama()
|
||||
group = obj.modifiers[0].node_group
|
||||
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
||||
names = [item.name for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"]
|
||||
assert names[names.index("Subdivide"):names.index("Depth Scale") + 1] == [
|
||||
"Subdivide", "Dome Radius", "Depth Scale",
|
||||
]
|
||||
assert inputs["Dome Radius"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Dome Radius"].default_value == 50
|
||||
assert (inputs["Dome Radius"].min_value, inputs["Dome Radius"].max_value) == (1, 100)
|
||||
assert inputs["Dome Radius"].subtype == "DISTANCE"
|
||||
assert inputs["Mask Threshold"].parent.name == "Options"
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
assert "Smooth Weight" not in inputs
|
||||
assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Depth Mask"].default_value
|
||||
obj.data.from_pydata([(0, 0, 0)], [], [])
|
||||
material = bpy.data.materials.new("Slot Material")
|
||||
obj.data.materials.append(material)
|
||||
for name in ("First Slot", "Replacement Slot"):
|
||||
current = bpy.data.materials.new(name)
|
||||
obj.data.materials[0] = current
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
assert mesh.materials[0].original == current
|
||||
assert all(f.material_index == 0 and f.use_smooth for f in mesh.polygons)
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def test_material_uv_matches_radial_sampling_and_faces_point_inward():
|
||||
obj, _ = panorama(subdivide=4)
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
assert all(np.dot(face.normal, face.center) < 0 for face in mesh.polygons)
|
||||
assert any(loop.uv.x < 0 for loop in mesh.uv_layers.active.data)
|
||||
for loop in mesh.loops:
|
||||
ray = np.array(mesh.vertices[loop.vertex_index].co)
|
||||
ray /= np.linalg.norm(ray)
|
||||
u, v = mesh.uv_layers.active.data[loop.index].uv
|
||||
if abs(ray[2]) < 0.999999:
|
||||
expected_u = np.arctan2(ray[1], ray[0]) / (2 * np.pi)
|
||||
assert abs((u - expected_u + 0.5) % 1 - 0.5) < 1e-5
|
||||
assert abs(v - (1 - np.arccos(np.clip(ray[2], -1, 1)) / np.pi)) < 1e-5
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def test_depth_scale_gates_split_selection():
|
||||
depth = np.ones((64, 128))
|
||||
depth[:, 64:] = 7
|
||||
obj, set_value = panorama(depth)
|
||||
set_value("Depth Scale", 0)
|
||||
whole = evaluated(obj)
|
||||
set_value("Depth Split", 1)
|
||||
flat = evaluated(obj)
|
||||
# A flat projection keeps the sphere whole, whatever the split strength is.
|
||||
assert flat[1] == whole[1]
|
||||
np.testing.assert_allclose(flat[0], whole[0], atol=1e-6)
|
||||
set_value("Depth Scale", 1)
|
||||
assert len(evaluated(obj)[0]) > len(flat[0])
|
||||
|
||||
|
||||
def test_split_strength_and_distance_scale_invariance():
|
||||
depth = np.ones((64, 128))
|
||||
depth[:, 64:] = 7
|
||||
signatures = []
|
||||
for scale in (0.01, 100):
|
||||
obj, set_value = panorama(depth * scale)
|
||||
counts = []
|
||||
for split in (0, 0.1, 0.5, 1):
|
||||
set_value("Depth Split", split)
|
||||
points, faces, uv = evaluated(obj)
|
||||
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
||||
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
counts.append(sum(n == 1 for n in edges.values()))
|
||||
assert counts[0] == 0 and counts[-1] > 0
|
||||
assert counts == sorted(counts)
|
||||
signatures.append(counts)
|
||||
assert signatures[0] == signatures[1]
|
||||
|
||||
|
||||
def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
|
||||
u, v = np.meshgrid((np.arange(128) + 0.5) / 128, (np.arange(64) + 0.5) / 64)
|
||||
depth = 3 + 0.3 * np.cos(2 * np.pi * u) * np.sin(np.pi * v)
|
||||
obj, set_value = panorama(depth)
|
||||
original = evaluated(obj)
|
||||
set_value("Depth Split", 1)
|
||||
set_value("Boundary Smooth", 12)
|
||||
points, faces, uv = evaluated(obj)
|
||||
np.testing.assert_allclose(points, original[0], atol=1e-6)
|
||||
assert faces == original[1]
|
||||
np.testing.assert_allclose(uv, original[2])
|
||||
|
||||
|
||||
def test_split_corners_keep_their_rays_and_own_face_distance():
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
depth = np.ones((64, 128))
|
||||
depth[:, 64:] = 7
|
||||
obj, set_value = panorama(depth)
|
||||
original, _, _ = evaluated(obj)
|
||||
original_radii = np.linalg.norm(original, axis=1)
|
||||
original_rays = original / original_radii[:, None]
|
||||
set_value("Depth Split", 1)
|
||||
points, _, uv = evaluated(obj)
|
||||
radii = np.linalg.norm(points, axis=1)
|
||||
distance, indices = cKDTree(original_rays).query(points / radii[:, None])
|
||||
assert distance.max() < 1e-5
|
||||
counts = Counter(indices)
|
||||
uncut = np.array([counts[index] == 1 for index in indices])
|
||||
np.testing.assert_allclose(radii[uncut], original_radii[indices[uncut]], atol=1e-5)
|
||||
assert any(np.ptp(radii[indices == index]) > 4 for index, count in counts.items() if count > 1)
|
||||
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
|
||||
|
||||
|
||||
def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
|
||||
from nodes.common import depth_surface
|
||||
|
||||
depth = np.broadcast_to(1 + 6 * (np.arange(128) % 16 < 8), (64, 128))
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(depth_surface, "_remove_strip_faces", lambda group, geometry, **kw: geometry)
|
||||
obj, set_value = panorama(depth)
|
||||
set_value("Depth Split", 1)
|
||||
_, faces, _ = evaluated(obj)
|
||||
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
strips = 0
|
||||
for face in faces:
|
||||
boundaries = [edges[tuple(sorted((a, b)))] == 1 for a, b in zip(face, (*face[1:], face[0]))]
|
||||
strips += (boundaries[0] and boundaries[2]) or (boundaries[1] and boundaries[3])
|
||||
assert strips > 0
|
||||
obj, set_value = panorama(depth)
|
||||
original_faces = evaluated(obj)[1]
|
||||
set_value("Depth Split", 1)
|
||||
assert len(evaluated(obj)[1]) == len(faces) - strips
|
||||
set_value("Depth Split", 0)
|
||||
assert evaluated(obj)[1] == original_faces
|
||||
38
tests/nodes/test_image_layer.py
Normal file
38
tests/nodes/test_image_layer.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
|
||||
|
||||
def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
|
||||
inputs = {
|
||||
item.name: item for item in image_layer.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
}
|
||||
assert list(inputs) == [
|
||||
"Color", "Alpha", "Normal", "Normal Scale", "Bump Scale",
|
||||
"Alpha Fix", "Object Space",
|
||||
]
|
||||
output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT")
|
||||
curve = output.inputs["Alpha"].links[0].from_node
|
||||
assert curve.type == "CURVE_FLOAT"
|
||||
assert curve.inputs["Value"].links[0].from_socket.name == "Alpha"
|
||||
assert curve.inputs["Factor"].links[0].from_socket.name == "Alpha Fix"
|
||||
mapping = curve.mapping
|
||||
samples = np.array([
|
||||
mapping.evaluate(mapping.curves[0], float(alpha))
|
||||
for alpha in np.linspace(0, 1, 1001)
|
||||
])
|
||||
np.testing.assert_allclose(samples[:801], 0, atol=3e-4)
|
||||
assert samples[-1] == pytest.approx(1)
|
||||
|
||||
|
||||
def test_depth_layer_keeps_foreground_alpha_threshold(image_layer):
|
||||
depth = build_image_depth_layer_group()
|
||||
output = next(node for node in depth.nodes if node.type == "GROUP_OUTPUT")
|
||||
threshold = output.inputs["Alpha"].links[0].from_node
|
||||
assert threshold.operation == "GREATER_THAN"
|
||||
assert threshold.inputs[1].default_value == 0.5
|
||||
assert threshold.inputs[0].links[0].from_socket.name == "Alpha"
|
||||
141
tests/nodes/test_image_plane.py
Normal file
141
tests/nodes/test_image_plane.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import bpy
|
||||
|
||||
from anyimage.common.object import (
|
||||
modifier_input_identifier,
|
||||
set_modifier_input,
|
||||
)
|
||||
|
||||
from anyimage.operators.convert_to_plane.image_plane import (
|
||||
add_image_plane_processing,
|
||||
create_image_plane_mesh,
|
||||
)
|
||||
|
||||
|
||||
def evaluated_mesh(obj):
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
return evaluated, evaluated.to_mesh()
|
||||
|
||||
|
||||
def test_material_inheritance_removes_only_source_vertices():
|
||||
from nodes.common.material_slots import inherit_material_slots
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_count = 4
|
||||
mesh = bpy.data.meshes.new("Source")
|
||||
mesh.from_pydata([(20 + i % 3, i // 3, 0) for i in range(source_count)], [],
|
||||
[(0, 1, 3, 2)])
|
||||
materials = [bpy.data.materials.new(name) for name in ("First", "Second")]
|
||||
for material in materials:
|
||||
mesh.materials.append(material)
|
||||
obj = bpy.data.objects.new("Material Source", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Material Inheritance", "GeometryNodeTree")
|
||||
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
grid = group.nodes.new("GeometryNodeMeshGrid")
|
||||
grid.inputs["Vertices X"].default_value = 3
|
||||
grid.inputs["Vertices Y"].default_value = 2
|
||||
geometry = inherit_material_slots(group, grid.outputs["Mesh"], source.outputs["Geometry"])
|
||||
group.links.new(geometry, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Material Inheritance", "NODES").node_group = group
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
ev, result = evaluated_mesh(obj)
|
||||
try:
|
||||
assert len(result.vertices) == 6 and len(result.polygons) == 2
|
||||
assert all(abs(v.co.x) <= 0.5 for v in result.vertices)
|
||||
assert [material.original for material in result.materials if material] == materials
|
||||
assert result.materials[0].original == materials[0]
|
||||
assert all(face.material_index == 0 for face in result.polygons)
|
||||
finally:
|
||||
ev.to_mesh_clear()
|
||||
|
||||
|
||||
def test_saved_image_plane_evaluation():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = create_image_plane_mesh("Image Plane", (-1.0, 1.0, -0.5, 0.5))
|
||||
assert len(mesh.polygons) == 1
|
||||
material = bpy.data.materials.new("Image Plane Material")
|
||||
mesh.materials.append(material)
|
||||
obj = bpy.data.objects.new("Image Plane", mesh)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
modifier = add_image_plane_processing(obj)
|
||||
group = modifier.node_group
|
||||
assert group.name == "O Image Plane"
|
||||
assert group.is_modifier
|
||||
inputs = {
|
||||
item.name: item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"
|
||||
}
|
||||
assert list(inputs) == ["Geometry", "Subdivide", "Thickness"]
|
||||
assert inputs["Subdivide"].default_value == 0
|
||||
assert inputs["Subdivide"].max_value == 10
|
||||
assert inputs["Thickness"].default_value == 0.0
|
||||
assert obj.data.materials[0] == material
|
||||
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
assert result.materials[0].original == material
|
||||
assert len(result.polygons) == 2
|
||||
assert len(result.vertices) == 6
|
||||
assert "UVMap" in result.attributes
|
||||
uv_values = {
|
||||
tuple(round(value, 6) for value in loop.uv)
|
||||
for loop in result.uv_layers["UVMap"].data
|
||||
}
|
||||
assert uv_values == {
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.0),
|
||||
(1.0, 0.0),
|
||||
(0.0, 1.0),
|
||||
(0.5, 1.0),
|
||||
(1.0, 1.0),
|
||||
}
|
||||
assert all(abs(vertex.co.y) < 1e-6 for vertex in result.vertices)
|
||||
assert all(polygon.material_index == 0 for polygon in result.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 3)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
assert len(result.polygons) == 128
|
||||
assert len(result.vertices) == 153
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 2)
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Thickness"), 1.2)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
assert result.materials[0].original == material
|
||||
assert len(result.polygons) == 184
|
||||
y_values = [vertex.co.y for vertex in result.vertices]
|
||||
assert abs(min(y_values) + 0.6) < 1e-6
|
||||
assert abs(max(y_values) - 0.6) < 1e-6
|
||||
assert len({round(value, 6) for value in y_values}) == 6
|
||||
edge_users = {edge.key: 0 for edge in result.edges}
|
||||
for polygon in result.polygons:
|
||||
for edge_key in polygon.edge_keys:
|
||||
edge_users[edge_key] += 1
|
||||
assert all(users == 2 for users in edge_users.values())
|
||||
assert all(polygon.material_index == 0 for polygon in result.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 0)
|
||||
set_modifier_input(modifier, modifier_input_identifier(group, "Thickness"), 0.01)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
evaluated, result = evaluated_mesh(obj)
|
||||
try:
|
||||
y_values = {round(vertex.co.y, 6) for vertex in result.vertices}
|
||||
assert len(y_values) >= 4
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
assert group.library is not None
|
||||
78
tests/nodes/test_normal_scale.py
Normal file
78
tests/nodes/test_normal_scale.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Render optional geometry weights against ordinary normal map strength."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from tests.support.materials import image_layer
|
||||
|
||||
|
||||
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
|
||||
@pytest.mark.parametrize('domain', ['POINT', 'FACE'])
|
||||
def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, domain, tmp_path):
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = engine
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.resolution_x, scene.render.resolution_y = 320, 64
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'OPEN_EXR'
|
||||
scene.render.image_settings.color_depth = '32'
|
||||
scene.render.filepath = str(tmp_path / 'normal_scale.exr')
|
||||
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||
scene.camera = bpy.context.object
|
||||
scene.camera.data.type = 'ORTHO'
|
||||
scene.camera.data.ortho_scale = 10
|
||||
for mode_index, space in enumerate(('TANGENT',)):
|
||||
shared = None
|
||||
for weight_index, weight in enumerate((None, 0.0, 0.4, 1.0, 2.0)):
|
||||
column = mode_index * 5 + weight_index
|
||||
for reference in (False, True):
|
||||
bpy.ops.mesh.primitive_plane_add(
|
||||
size=.8, location=(column - 4.5, .5 if reference else -.5, 0),
|
||||
)
|
||||
obj = bpy.context.object
|
||||
obj.data.uv_layers.active.name = 'UVMap'
|
||||
if weight is not None and not reference:
|
||||
attr = obj.data.attributes.new(NORMAL_REDUCTION_ATTRIBUTE_NAME, 'FLOAT', domain)
|
||||
for item in attr.data:
|
||||
item.value = 1.0 - weight
|
||||
if not reference and shared is not None:
|
||||
obj.data.materials.append(shared)
|
||||
continue
|
||||
material = bpy.data.materials.new(f'{space} {weight} {reference}')
|
||||
material.use_nodes = True
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
nodes.clear()
|
||||
normal = nodes.new('ShaderNodeNormalMap' if reference else 'ShaderNodeGroup')
|
||||
color = (.7, .65, .9, 1)
|
||||
if reference:
|
||||
normal.space = space
|
||||
normal.inputs['Color'].default_value = color
|
||||
normal.inputs['Strength'].default_value = .5 * (1 if weight is None else weight)
|
||||
else:
|
||||
normal.node_tree = image_layer
|
||||
normal.inputs['Object Space'].default_value = space == 'OBJECT'
|
||||
normal.inputs['Normal'].default_value = color
|
||||
normal.inputs['Normal Scale'].default_value = .5
|
||||
shared = material
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(normal.outputs['Normal'], encode.inputs[0])
|
||||
emission = nodes.new('ShaderNodeEmission')
|
||||
output = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(encode.outputs[0], emission.inputs['Color'])
|
||||
links.new(emission.outputs[0], output.inputs['Surface'])
|
||||
obj.data.materials.append(material)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
|
||||
try:
|
||||
pixels = np.array(rendered.pixels[:]).reshape(64, 320, 4)
|
||||
columns = np.arange(5) * 32 + 16
|
||||
np.testing.assert_allclose(pixels[16, columns, :3], pixels[48, columns, :3], atol=2e-4)
|
||||
assert np.linalg.norm(pixels[16, 16, :3] - pixels[16, 48, :3]) > .01
|
||||
finally:
|
||||
bpy.data.images.remove(rendered)
|
||||
90
tests/nodes/test_object_normal_rotation.py
Normal file
90
tests/nodes/test_object_normal_rotation.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Render the image material's object normals from persistent geometry attributes."""
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from mathutils import Euler, Vector, Matrix
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from anyimage.common.object import blender_frame_rotation
|
||||
from nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
|
||||
|
||||
SYMMETRY_Y_TO_X = Matrix.Rotation(1.5707963267948966, 4, "Z").to_3x3()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
|
||||
def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, engine, tmp_path):
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = engine
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.resolution_x, scene.render.resolution_y = 224, 32
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'OPEN_EXR'
|
||||
scene.render.image_settings.color_depth = '32'
|
||||
scene.render.filepath = str(tmp_path / 'normals.exr')
|
||||
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||
camera = bpy.context.object
|
||||
camera.data.type = 'ORTHO'
|
||||
camera.data.ortho_scale = 7
|
||||
scene.camera = camera
|
||||
normal = Vector((.2, .3, .9)).normalized()
|
||||
rotation = Euler((.25, -.4, .15))
|
||||
expected = []
|
||||
# Missing attributes, front, back, wall, final axis, zero strength, tangent.
|
||||
cases = [(0, 0, 1, True), (1, 0, 1, True), (2, 0, 1, True),
|
||||
(3, 0, 1, True), (1, 1, 1, True), (1, 0, 0, True), (1, 0, 1, False)]
|
||||
for index, (face, axis, strength, object_space) in enumerate(cases):
|
||||
bpy.ops.mesh.primitive_plane_add(size=.9, location=(index - 3, 0, 0))
|
||||
obj = bpy.context.object
|
||||
obj.data.uv_layers.active.name = 'UVMap'
|
||||
if face:
|
||||
attr = obj.data.attributes.new(ROTATION_ATTRIBUTE, 'FLOAT_VECTOR', 'FACE')
|
||||
for item in attr.data:
|
||||
item.vector = rotation
|
||||
for name, value in ((FACE_ATTRIBUTE, face), (AXIS_ATTRIBUTE, axis)):
|
||||
attr = obj.data.attributes.new(name, 'FLOAT', 'FACE')
|
||||
for item in attr.data:
|
||||
item.value = value
|
||||
material = bpy.data.materials.new(f'Normal {index}')
|
||||
material.use_nodes = True
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
nodes.clear()
|
||||
layer = nodes.new('ShaderNodeGroup')
|
||||
layer.node_tree = image_layer
|
||||
layer.inputs['Object Space'].default_value = object_space
|
||||
layer.inputs['Normal Scale'].default_value = strength
|
||||
layer.inputs['Normal'].default_value = (*((np.array(normal) + 1) / 2), 1)
|
||||
encode = nodes.new('ShaderNodeVectorMath')
|
||||
encode.operation = 'MULTIPLY_ADD'
|
||||
encode.inputs[1].default_value = (.5, .5, .5)
|
||||
encode.inputs[2].default_value = (.5, .5, .5)
|
||||
links.new(layer.outputs['Normal'], encode.inputs[0])
|
||||
emission = nodes.new('ShaderNodeEmission')
|
||||
output = nodes.new('ShaderNodeOutputMaterial')
|
||||
links.new(encode.outputs[0], emission.inputs['Color'])
|
||||
links.new(emission.outputs[0], output.inputs['Surface'])
|
||||
obj.data.materials.append(material)
|
||||
value = normal.copy()
|
||||
if object_space:
|
||||
value = Matrix(blender_frame_rotation()).to_3x3() @ value
|
||||
if face and object_space:
|
||||
value = rotation.to_matrix().transposed() @ value
|
||||
if face == 2:
|
||||
value.y *= -1
|
||||
if axis:
|
||||
value = SYMMETRY_Y_TO_X @ value
|
||||
if strength == 0 or face == 3:
|
||||
value = Vector((0, 0, 1))
|
||||
expected.append((np.array(value) + 1) / 2)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
|
||||
try:
|
||||
pixels = np.array(rendered.pixels[:]).reshape(32, 224, 4)
|
||||
np.testing.assert_allclose(
|
||||
pixels[16, np.arange(7) * 32 + 16, :3], expected,
|
||||
atol=1e-3 if engine == 'BLENDER_EEVEE_NEXT' else 2e-4,
|
||||
)
|
||||
finally:
|
||||
bpy.data.images.remove(rendered)
|
||||
58
tests/nodes/test_shadeless.py
Normal file
58
tests/nodes/test_shadeless.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Render the bundled Shadeless group's opacity and HDR color endpoints."""
|
||||
|
||||
import bpy
|
||||
import addon_utils
|
||||
import numpy as np
|
||||
|
||||
from anyimage.common.node import load_node_group
|
||||
|
||||
|
||||
def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
addon_utils.enable("cycles", default_set=False)
|
||||
group = load_node_group("O Shadeless")
|
||||
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
||||
assert list(inputs) == ["Color", "Alpha", "Strength"]
|
||||
assert inputs["Alpha"].default_value == 1 and inputs["Strength"].default_value == 1
|
||||
assert inputs["Strength"].min_value == 0
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 8
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.film_transparent = True
|
||||
scene.render.resolution_x, scene.render.resolution_y = 128, 32
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "OPEN_EXR"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.image_settings.color_depth = "32"
|
||||
scene.render.filepath = str(tmp_path / "shadeless.exr")
|
||||
bpy.ops.object.camera_add(location=(0, 0, 5))
|
||||
scene.camera = bpy.context.object
|
||||
scene.camera.data.type = "ORTHO"
|
||||
scene.camera.data.ortho_scale = 4
|
||||
for index, (alpha, strength) in enumerate(((0, 1), (0.5, 1), (1, 1), (1, 2))):
|
||||
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1.5, 0, 0))
|
||||
material = bpy.data.materials.new(f"Alpha {alpha} Strength {strength}")
|
||||
material.use_nodes = True
|
||||
material.node_tree.nodes.clear()
|
||||
shader = material.node_tree.nodes.new("ShaderNodeGroup")
|
||||
shader.node_tree = group
|
||||
shader.inputs["Color"].default_value = (4, 2, 0.5, 1)
|
||||
shader.inputs["Alpha"].default_value = alpha
|
||||
shader.inputs["Strength"].default_value = strength
|
||||
output = material.node_tree.nodes.new("ShaderNodeOutputMaterial")
|
||||
material.node_tree.links.new(shader.outputs[0], output.inputs["Surface"])
|
||||
bpy.context.object.data.materials.append(material)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
|
||||
try:
|
||||
pixels = np.array(image.pixels[:]).reshape(32, 128, 4)
|
||||
for index, alpha in enumerate((0, 0.5, 1, 1)):
|
||||
patch = pixels[8:24, index * 32 + 8:index * 32 + 24]
|
||||
np.testing.assert_allclose(patch[..., 3].mean(), alpha, atol=0.035)
|
||||
np.testing.assert_allclose(pixels[16, 80, :3], (4, 2, 0.5), atol=1e-4)
|
||||
np.testing.assert_allclose(pixels[16, 112, :3], (8, 4, 1), atol=1e-4)
|
||||
finally:
|
||||
bpy.data.images.remove(image)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
35
tests/nodes/test_temporary_attributes.py
Normal file
35
tests/nodes/test_temporary_attributes.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Check temporary attribute lifetimes across depth modifier branches."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import surface
|
||||
from tests.support.planes import create_surface
|
||||
from tests.nodes.test_image_depth_panorama import panorama
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
def test_depth_attribute_cleanup_has_no_warnings(kind):
|
||||
if kind == "cutout":
|
||||
obj, set_value = surface()
|
||||
elif kind == "plane":
|
||||
obj, set_value, *_ = create_surface("DEPTH")
|
||||
set_value("Depth Scale", 1)
|
||||
else:
|
||||
obj, set_value = panorama(subdivide=2)
|
||||
modifier = obj.modifiers[0]
|
||||
for split in (0.0, 0.5, 0.0):
|
||||
set_value("Depth Split", split)
|
||||
for smooth in (0, 5):
|
||||
set_value("Boundary Smooth", smooth)
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert mesh.vertices
|
||||
assert not any("o_anyimage" in a.name for a in mesh.attributes)
|
||||
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
|
||||
assert not list(modifier.node_warnings), [
|
||||
warning.message for warning in modifier.node_warnings
|
||||
]
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
Reference in New Issue
Block a user