Add comprehensive tests for image processing nodes and refactor node imports

- Introduced new test files for image layer, image plane, normal scale, object normal rotation, shadeless, and temporary attributes.
- Implemented tests to validate the behavior of alpha curves, depth layers, and normal scales in various rendering engines.
- Refactored import paths for node groups to align with the new directory structure.
- Added checks for temporary attribute lifetimes and ensured proper cleanup without warnings.
- Enhanced CLI commands to require the Blender group and validate the presence of generated node assets before packaging.
- Updated packaging scripts to enforce the existence of necessary assets, preventing build failures.
This commit is contained in:
2026-09-19 21:23:19 +08:00
parent 2b3c0194a6
commit 9debab5c0c
73 changed files with 500 additions and 217 deletions

View File

@@ -8,10 +8,10 @@ import pytest
from tools.nodes.arrangement.arrange import arrange_nodes
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
from tools.nodes.check import ASSET_PATH, SHADER_NAMES
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
from tools.nodes.groups.image_layer import build_image_layer_group
from tools.nodes.groups.shadeless import build_shadeless_group
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from nodes.groups.image_depth_layer import build_image_depth_layer_group
from nodes.groups.image_layer import build_image_layer_group
from nodes.groups.shadeless import build_shadeless_group
BUILDERS = (

View File

@@ -1,102 +0,0 @@
"""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.tools.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.tools.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])

View File

@@ -1,209 +0,0 @@
"""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.tools.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.tools.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

View File

@@ -1,5 +1,6 @@
from tests.support.paths import PROJECT_ROOT
import importlib.util
import subprocess
from unittest.mock import patch
import pytest
@@ -17,84 +18,75 @@ def load_launcher():
return module
def test_blender_path_prefers_explicit_path(tmp_path):
def test_commands_require_the_blender_group(monkeypatch):
launcher = load_launcher()
executable = tmp_path / "blender"
executable.touch()
with patch.dict("os.environ", {"BLENDER_BIN": "ignored"}):
assert launcher.blender_path(executable) == executable
monkeypatch.setattr("sys.argv", ["node-group", "check"])
with patch.object(launcher.importlib.util, "find_spec", return_value=None):
with pytest.raises(RuntimeError, match="--group blender"):
launcher.main()
def test_blender_path_reads_environment(tmp_path):
launcher = load_launcher()
executable = tmp_path / "blender"
executable.touch()
with patch.dict("os.environ", {"BLENDER_BIN": str(executable)}):
assert launcher.blender_path() == executable
def test_blender_path_reads_project_dotenv(tmp_path):
launcher = load_launcher()
executable = tmp_path / "Blender 4.5" / "blender.exe"
executable.parent.mkdir()
executable.touch()
env_path = tmp_path / ".env"
env_path.write_text(
f'BLENDER_BIN="{executable}"\n',
encoding="utf-8",
)
with patch.dict("os.environ", {}, clear=True), patch.object(
launcher,
"PROJECT_ROOT",
tmp_path,
):
assert launcher.blender_path() == executable
def test_blender_path_reports_missing_configuration(tmp_path):
launcher = load_launcher()
with patch.dict("os.environ", {}, clear=True), patch.object(
launcher.shutil,
"which",
return_value=None,
), patch.object(
launcher,
"PROJECT_ROOT",
tmp_path,
):
with pytest.raises(RuntimeError, match="Blender not found"):
launcher.blender_path()
@pytest.mark.parametrize("arguments,modules,run_tests", [
(["build"], ("tools.nodes.build", "tools.nodes.check"), True),
(["check", "--skip-tests"], ("tools.nodes.check",), False),
(["preview", "--fragment"], ("tools.nodes.preview.export",), False),
@pytest.mark.parametrize("arguments,commands", [
(["build"], [
("tools.nodes.build", ()),
("tools.nodes.check", ()),
("pytest", ("tests/nodes", "tests/tools/nodes")),
]),
(["check", "--skip-tests"], [("tools.nodes.check", ())]),
(["preview", "--fragment"], [
("tools.nodes.preview.export", "preview"),
]),
])
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, modules, run_tests):
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, commands):
launcher = load_launcher()
output = tmp_path / "node groups.html"
command = ["node-group", *arguments]
if arguments[0] == "preview":
command += ["--output", str(output)]
monkeypatch.setattr("sys.argv", command)
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
with (
patch.object(launcher, "run_blender_modules") as blender,
patch.object(launcher, "require_bpy"),
patch.object(launcher, "run_module") as run_module,
patch.object(launcher, "run") as run,
patch.object(launcher.webbrowser, "open") as open_browser,
):
launcher.main()
if arguments[0] == "preview":
blender.assert_called_once_with("blender", modules, ["--output", str(output), "--fragment"])
run_module.assert_called_once_with(
"tools.nodes.preview.export",
["--output", str(output), "--fragment"],
)
open_browser.assert_not_called()
else:
blender.assert_called_once_with("blender", modules)
if run_tests:
run.assert_called_once_with((launcher.sys.executable, "-m", "pytest"))
expected_modules = [item[0] for item in commands if item[0] != "pytest"]
assert [call.args[0] for call in run_module.call_args_list] == expected_modules
if any(item[0] == "pytest" for item in commands):
run.assert_called_once_with(
(
launcher.sys.executable,
"-m",
"pytest",
"tests/nodes",
"tests/tools/nodes",
)
)
else:
run.assert_not_called()
def test_module_failure_stops_later_steps(monkeypatch):
launcher = load_launcher()
monkeypatch.setattr("sys.argv", ["node-group", "build"])
with (
patch.object(launcher, "require_bpy"),
patch.object(
launcher,
"run_module",
side_effect=subprocess.CalledProcessError(1, "build"),
) as run_module,
patch.object(launcher, "run") as run,
):
with pytest.raises(subprocess.CalledProcessError):
launcher.main()
run_module.assert_called_once_with("tools.nodes.build")
run.assert_not_called()

View File

@@ -1,72 +0,0 @@
"""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.tools.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)

View File

@@ -1,283 +0,0 @@
"""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 tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.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()

View File

@@ -1,55 +0,0 @@
from collections import Counter
import bpy
import numpy as np
import pytest
from tools.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()

View File

@@ -1,180 +0,0 @@
"""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)

View File

@@ -1,363 +0,0 @@
"""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()

View File

@@ -1,63 +0,0 @@
"""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)

View File

@@ -1,244 +0,0 @@
"""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()

View File

@@ -1,355 +0,0 @@
"""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 tools.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 tools.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

View File

@@ -1,38 +0,0 @@
import numpy as np
import pytest
from tests.support.materials import image_layer
from tools.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"

View File

@@ -1,141 +0,0 @@
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 tools.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

View File

@@ -1,78 +0,0 @@
"""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)

View File

@@ -1,90 +0,0 @@
"""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 tools.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)

View File

@@ -1,58 +0,0 @@
"""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)

View File

@@ -1,35 +0,0 @@
"""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.tools.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()