Add tests and implement boundary smoothing and cutout functionality
- Introduced tests for boundary smoothing in `test_boundary_smoothing.py`, verifying that smoothing reduces stair-step artifacts while preserving boundary integrity. - Added tests for cutout boundary behavior in `test_cutout_boundary.py`, ensuring proper handling of boundary triangles and normal blending. - Created `test_temporary_attributes.py` to check the cleanup of temporary attributes across depth modifier branches, ensuring no warnings are raised. - Implemented point culling functionality in `test_validity.py`, confirming that invalid points are removed without affecting adjacent geometry. - Developed boundary smoothing logic in `boundary_smoothing.py`, allowing for depth smoothing while maintaining edge integrity. - Added cutout boundary management in `cutout_boundary.py`, including functions for removing boundary triangles and tapering thickness profiles. - Implemented validity checks in `validity.py` to ensure proper deletion of invalid vertices and associated wire edges.
This commit is contained in:
186
tests/tools/nodes/test_boundary_depth.py
Normal file
186
tests/tools/nodes/test_boundary_depth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""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
|
||||
from tools.nodes.common.boundary_smoothing import smooth_boundary_camera
|
||||
|
||||
|
||||
def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surfaces():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
points, faces, cameras, protected = [], [], [], []
|
||||
for offset, depth in ((0, 2), (81, 50)):
|
||||
for y in range(9):
|
||||
for x in range(9):
|
||||
value = 20 if offset == 0 and (x, y) == (4, 0) else depth
|
||||
if offset == 0 and (x, y) == (8, 8):
|
||||
value = 0
|
||||
points.append((x, y, 0))
|
||||
cameras.append((x * value / 10, y * value / 10, value))
|
||||
protected.append(x == 0)
|
||||
faces.extend((offset + y * 9 + x, offset + y * 9 + x + 1,
|
||||
offset + (y + 1) * 9 + x + 1, offset + (y + 1) * 9 + x)
|
||||
for y in range(8) for x in range(8))
|
||||
mesh = bpy.data.meshes.new("Disconnected camera samples")
|
||||
mesh.from_pydata(points, [], faces)
|
||||
camera = np.asarray(cameras, np.float32)
|
||||
mesh.attributes.new("test_camera", "FLOAT_VECTOR", "POINT").data.foreach_set("vector", camera.ravel())
|
||||
mesh.attributes.new("test_protected", "BOOLEAN", "POINT").data.foreach_set("value", protected)
|
||||
obj = bpy.data.objects.new("Depth samples", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Boundary depth test", "GeometryNodeTree")
|
||||
for direction in ("INPUT", "OUTPUT"):
|
||||
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
|
||||
control = group.interface.new_socket(name="Boundary Smooth", in_out="INPUT", socket_type="NodeSocketInt")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
attributes = []
|
||||
for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_protected", "BOOLEAN")):
|
||||
node = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
node.data_type = kind
|
||||
node.inputs["Name"].default_value = name
|
||||
attributes.append(node.outputs["Attribute"])
|
||||
position = group.nodes.new("GeometryNodeSetPosition")
|
||||
group.links.new(source.outputs["Geometry"], position.inputs["Geometry"])
|
||||
group.links.new(smooth_boundary_camera(group, *attributes), position.inputs["Position"])
|
||||
output = group.nodes.new("NodeGroupOutput")
|
||||
group.links.new(position.outputs[0], output.inputs["Geometry"])
|
||||
modifier = obj.modifiers.new("Depth", "NODES")
|
||||
modifier.node_group = group
|
||||
modifier[control.identifier] = 0
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], camera)
|
||||
modifier[control.identifier] = 12
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
result, result_faces = evaluated(obj)
|
||||
assert result_faces == faces
|
||||
assert 2 < result[4, 2] < 10
|
||||
assert np.isfinite(result).all()
|
||||
np.testing.assert_array_equal(result[protected], camera[protected])
|
||||
np.testing.assert_array_equal(result[81:], camera[81:])
|
||||
np.testing.assert_array_equal(result[80], camera[80])
|
||||
band = edge_band(faces, set(boundary_neighbors(faces)) - set(np.flatnonzero(protected)))
|
||||
interior = [i for i in range(len(camera)) if i not in band]
|
||||
assert interior
|
||||
np.testing.assert_array_equal(result[interior], camera[interior])
|
||||
valid = camera[:, 2] > 0
|
||||
np.testing.assert_allclose(result[valid, :2] / result[valid, 2:3],
|
||||
camera[valid, :2] / camera[valid, 2:3], atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_cutout_fine_outline_blurs_depth_without_moving_projection(source):
|
||||
from tests.support.depth_surface import surface
|
||||
from anyimage.operators.cutout_tool.fine_mesh import build_fine_mesh
|
||||
from anyimage.operators.cutout_tool.object import load_cutout_node_group
|
||||
|
||||
obj, set_value = surface(step=0, resolution=8)
|
||||
if source == "asset":
|
||||
bpy.data.node_groups.remove(obj.modifiers[0].node_group, do_unlink=True)
|
||||
obj.modifiers[0].node_group = load_cutout_node_group("DEPTH_SOLID")
|
||||
yy, xx = np.mgrid[:32, :64]
|
||||
alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float)
|
||||
points, faces, _, _ = build_fine_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)
|
||||
set_value("Smooth Weight", 0)
|
||||
before, original_faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 12)
|
||||
after, result_faces = evaluated(obj)
|
||||
assert result_faces == original_faces
|
||||
assert after[:, 2].min() > before[:, 2].min() + 1
|
||||
# Reference Depth = 1, Uniform Scale = 1 and Depth Scale = 1.
|
||||
np.testing.assert_allclose(after[:, :2] / (1 - after[:, 2:3]),
|
||||
before[:, :2] / (1 - before[:, 2:3]), atol=1e-6)
|
||||
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])
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], before)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_plane_validity_hole_blurs_depth_and_protects_rectangle(source):
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface(source, "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), ("Smooth Weight", 0)):
|
||||
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[:, 2].min() > before[:, 2].min() + .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])
|
||||
np.testing.assert_allclose(after[:, :2] / (1.2 - after[:, 2:3]),
|
||||
before[:, :2] / (1.2 - before[:, 2:3]), atol=1e-6)
|
||||
set_value("Valid Only", False)
|
||||
intact = plane_mesh(obj)[0]
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(plane_mesh(obj)[0], intact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("region", ["seam", "pole"])
|
||||
def test_panorama_blurs_opening_radially_and_keeps_invalid_distance(source, region):
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
from tools.nodes.build import OUTPUT_PATH
|
||||
|
||||
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)
|
||||
if source == "asset":
|
||||
obj.modifiers[0].node_group.name = "Panorama definition"
|
||||
with bpy.data.libraries.load(str(OUTPUT_PATH)) as (_, target):
|
||||
target.node_groups = ["O Image Depth Panorama"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
set_value("Smooth Weight", 0)
|
||||
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
|
||||
np.testing.assert_allclose(after / smoothed[:, None], before / radii[:, None], atol=1e-6)
|
||||
band = edge_band(faces, boundary_neighbors(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])
|
||||
set_value("Valid Only", False)
|
||||
near = panorama_mesh(obj)[0]
|
||||
set_value("Invalid Distance", 250)
|
||||
far = panorama_mesh(obj)[0]
|
||||
valid = np.linalg.norm(near, axis=1) < 50
|
||||
assert valid.any() and (~valid).any()
|
||||
np.testing.assert_array_equal(far[valid], near[valid])
|
||||
np.testing.assert_allclose(np.linalg.norm(far[~valid], axis=1), 250, atol=5e-5)
|
||||
set_value("Depth Scale", 0)
|
||||
np.testing.assert_allclose(np.linalg.norm(panorama_mesh(obj)[0], axis=1), 1, atol=1e-6)
|
||||
335
tests/tools/nodes/test_boundary_smoothing.py
Normal file
335
tests/tools/nodes/test_boundary_smoothing.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Verify pinned-boundary smoothing stays within its fixed edge band."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
|
||||
|
||||
|
||||
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("Split Threshold", 0.5)
|
||||
set_value("Depth Scale", 0)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("triangles", [False, True])
|
||||
def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
|
||||
obj, set_value = diagonal_surface(triangles)
|
||||
original, faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
|
||||
outer |= np.isclose(original[:, 1], 0) | np.isclose(original[:, 1], 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", 20)
|
||||
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)
|
||||
np.testing.assert_array_equal(smoothed[:, 2], original[:, 2])
|
||||
set_value("Smooth Weight", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
set_value("Smooth Weight", 1)
|
||||
set_value("Boundary Smooth", 0)
|
||||
np.testing.assert_array_equal(evaluated(obj)[0], original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_smoothing_without_split_moves_original_boundary_only_within_band(source):
|
||||
obj, set_value = diagonal_surface()
|
||||
if source == "asset":
|
||||
from anyimage.common.node import node_asset_path
|
||||
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = ["O Image Depth Cutout"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
set_value("Split Threshold", 0)
|
||||
original, faces = evaluated(obj)
|
||||
set_value("Boundary Smooth", 20)
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
def test_smoothed_cut_shells_stay_closed_and_separate(mode):
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
obj, set_value = diagonal_surface()
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
|
||||
"value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32)
|
||||
)
|
||||
set_value("Depth Scale", 1)
|
||||
set_value("Boundary Smooth", 4)
|
||||
original, faces = evaluated(obj)
|
||||
count = component_count(faces)
|
||||
set_value("Mode", mode)
|
||||
modifier = obj.modifiers[0]
|
||||
identifier = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
|
||||
set_modifier_input(modifier, identifier, 0.05)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
result, shell_faces = evaluated(obj)
|
||||
assert np.isfinite(result).all()
|
||||
assert_closed(shell_faces)
|
||||
assert component_count(shell_faces) == count
|
||||
|
||||
|
||||
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("Split Threshold", 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, inputs, image = create_surface(source, "DEPTH")
|
||||
assert inputs["Boundary Smooth"].default_value == 5
|
||||
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("Split Threshold", 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_edge_iterations_reuse_boundary_topology_and_mapping():
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
from tools.nodes.arrangement.zones import node_zones
|
||||
|
||||
obj, _ = diagonal_surface()
|
||||
group = obj.modifiers[0].node_group
|
||||
entry, _, members = next(
|
||||
zone for zone in node_zones(group)
|
||||
if origin_socket(zone[0].inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
|
||||
)
|
||||
nearest = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSampleNearest"
|
||||
and any(link.to_node in members for link in node.outputs["Index"].links))
|
||||
fixed_position = origin_socket(nearest.inputs["Sample Position"].links[0].from_socket).node
|
||||
assert fixed_position.bl_idname == "GeometryNodeSampleIndex"
|
||||
source = origin_socket(entry.inputs["Geometry"].links[0].from_socket)
|
||||
assert origin_socket(fixed_position.inputs["Geometry"].links[0].from_socket) == source
|
||||
assert fixed_position.domain == "POINT"
|
||||
assert any(node.bl_idname == "GeometryNodeSeparateGeometry" for node in members)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_every_split_node_enables_boundary_smoothing_by_default(source):
|
||||
from tools.nodes.build import OUTPUT_PATH, build_node_groups
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
if source == "definition":
|
||||
groups = build_node_groups()
|
||||
else:
|
||||
with bpy.data.libraries.load(str(OUTPUT_PATH)) as (library, target):
|
||||
target.node_groups = list(library.node_groups)
|
||||
groups = target.node_groups
|
||||
split_groups = []
|
||||
for group in groups:
|
||||
inputs = {item.name: item for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
assert "Edge Smooth" not in inputs
|
||||
if "Split Threshold" not in inputs:
|
||||
continue
|
||||
split_groups.append(group.name)
|
||||
smooth = inputs["Boundary Smooth"]
|
||||
assert smooth.default_value == 5
|
||||
assert (smooth.min_value, smooth.max_value) == (0, 20)
|
||||
assert smooth.parent.name == "Options"
|
||||
assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys()
|
||||
depth_mix = next(n for n in group.nodes if n.bl_idname == "ShaderNodeMix"
|
||||
and n.data_type == "FLOAT" and n.inputs[3].is_linked
|
||||
and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute")
|
||||
depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node
|
||||
assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
|
||||
depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
|
||||
and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked
|
||||
and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"]
|
||||
assert len(depth_bypass) == 1
|
||||
assert set(split_groups) == {
|
||||
"O Image Depth Plane", "O Image Depth Cutout", "O Image Depth Panorama",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_validity_cut_smoothing_preserves_original_outline_and_interior(source):
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, _, image = create_surface(source, "DEPTH")
|
||||
set_value("Subdivide", 5)
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Valid Only", 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("Valid Only", True)
|
||||
before, faces, uv, *_ = plane_mesh(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
outer = np.any(np.isclose(before[:, :2], low[:2]) | np.isclose(before[:, :2], high[: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("Valid Only", 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("Split Threshold", 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
@pytest.mark.parametrize("iterations,weight", [(0, 1.0), (8, 1.0), (8, 0.0)])
|
||||
def test_boundary_loop_bypass_uses_iterations_only(source, kind, iterations, weight):
|
||||
from anyimage.common.node import node_asset_path
|
||||
from tests.support.planes import create_surface
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama
|
||||
from tools.nodes.arrangement.flow import origin_socket
|
||||
|
||||
if kind == "cutout":
|
||||
obj, set_value = surface(resolution=4)
|
||||
elif kind == "plane":
|
||||
obj, set_value, *_ = create_surface("definition", "DEPTH")
|
||||
else:
|
||||
obj, set_value = panorama(subdivide=2)
|
||||
if source == "asset":
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = [obj.modifiers[0].node_group.name]
|
||||
obj.modifiers[0].node_group = target.node_groups[0].copy()
|
||||
set_value("Split Threshold", 0)
|
||||
set_value("Smooth Weight", weight)
|
||||
set_value("Boundary Smooth", iterations)
|
||||
group = obj.modifiers[0].node_group
|
||||
entry = next(n for n in group.nodes if n.bl_idname == "GeometryNodeRepeatInput"
|
||||
and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth")
|
||||
bypass = next(n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
|
||||
and n.input_type == "GEOMETRY" and n.inputs["True"].is_linked
|
||||
and origin_socket(n.inputs["True"].links[0].from_socket).node == entry.paired_output)
|
||||
control = origin_socket(bypass.inputs["Switch"].links[0].from_socket)
|
||||
assert control.name == "Boundary Smooth"
|
||||
assert control.identifier == origin_socket(entry.inputs["Iterations"].links[0].from_socket).identifier
|
||||
assert origin_socket(bypass.inputs["False"].links[0].from_socket) == origin_socket(entry.inputs["Geometry"].links[0].from_socket)
|
||||
original, faces = evaluated(obj)
|
||||
group.links.remove(bypass.inputs["Switch"].links[0])
|
||||
bypass.inputs["Switch"].default_value = True
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
unskipped, unskipped_faces = evaluated(obj)
|
||||
np.testing.assert_array_equal(original, unskipped)
|
||||
assert faces == unskipped_faces
|
||||
128
tests/tools/nodes/test_cutout_boundary.py
Normal file
128
tests/tools/nodes/test_cutout_boundary.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Verify split profile shaping and thickness-weighted normals."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface, boundary_neighbors, edge_band
|
||||
from tools.nodes.common.cutout_boundary import remove_boundary_triangles
|
||||
from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal
|
||||
|
||||
|
||||
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 test_boundary_triangle_cleanup_is_one_pass():
|
||||
obj, _ = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
mesh = bpy.data.meshes.new("Boundary ears")
|
||||
mesh.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0),(.5,.5,0),(2,0,0)], [],
|
||||
[(0,1,4),(1,2,4),(2,3,4),(3,0,4),(1,5,2)])
|
||||
obj.data = mesh
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
cleaned = remove_boundary_triangles(group, source.outputs["Geometry"])
|
||||
group.links.new(cleaned, next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
_, faces = evaluated(obj)
|
||||
assert len(faces) == 4
|
||||
assert all(len(f) == 3 for f in faces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iterations", [0, 5, 12, 20])
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [12, 24])
|
||||
@pytest.mark.parametrize("aspect", [.5, 2])
|
||||
def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
|
||||
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", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
set_value("Split Threshold", .1)
|
||||
points, faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
cut = [i for i in boundary if 0 < points[i,1] < 1 and .5*aspect < points[i,0] < 1.5*aspect]
|
||||
assert cut
|
||||
band = edge_band(faces, cut)
|
||||
outside = [i for i in range(len(points)) if i not in band]
|
||||
assert outside
|
||||
results = []
|
||||
for amount in (0, .2, .6):
|
||||
set_value("Edge Round", amount)
|
||||
values = profile_values(obj)
|
||||
np.testing.assert_array_equal(values[cut], 0)
|
||||
np.testing.assert_allclose(values[outside], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
results.append(values)
|
||||
assert np.all(results[2] <= results[0] + 1e-7)
|
||||
set_value("Split Threshold", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
@pytest.mark.parametrize("reference", [0, 2])
|
||||
def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference):
|
||||
obj, set_value = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
# The production interface renames the uniform socket after building.
|
||||
socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"
|
||||
and s.name == "Thickness" and s.subtype == "DISTANCE")
|
||||
socket.name = "Uniform Thickness"
|
||||
uniform = group.nodes.new("FunctionNodeInputBool")
|
||||
uniform.boolean = bool(mode)
|
||||
weight = build_normal_weight(group, uniform.outputs[0])
|
||||
raw = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
raw.inputs["Z"].default_value = 1
|
||||
blurred = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
blurred.inputs["X"].default_value = 1
|
||||
result = blend_normal(group, raw.outputs[0], blurred.outputs[0], weight)
|
||||
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
|
||||
store.inputs["Name"].default_value = "normal_result"
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
|
||||
group.links.new(result, store.inputs["Value"])
|
||||
group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
|
||||
modifier = obj.modifiers[0]
|
||||
identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE")
|
||||
set_value("Reference Depth", reference)
|
||||
results = []
|
||||
for amount in (0, .001, .01, 1):
|
||||
set_modifier_input(modifier, identifier, amount * (max(reference, 1e-8) if mode else 1))
|
||||
obj.update_tag(refresh={"DATA"}); bpy.context.view_layer.update()
|
||||
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()); mesh = ev.to_mesh()
|
||||
try:
|
||||
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
|
||||
finally:
|
||||
ev.to_mesh_clear()
|
||||
np.testing.assert_array_equal(results[0], [0,0,1])
|
||||
np.testing.assert_array_equal(results[-1], [1,0,0])
|
||||
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001
|
||||
@@ -22,7 +22,8 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
assert len(set(groups.values())) == 3
|
||||
for group in set(groups.values()):
|
||||
validate_node_structure(group)
|
||||
assert group.is_modifier and group.get("anyimage_asset_revision") == 11
|
||||
revision = 18 if group == groups["DEPTH_SOLID"] else 11
|
||||
assert group.is_modifier and group.get("anyimage_asset_revision") == revision
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
||||
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
assert "Shape" not in inputs and "Rim Thickness" not in inputs
|
||||
@@ -34,6 +35,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
if "Double Sided" in inputs:
|
||||
assert inputs["Double Sided"].default_value
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
assert np.isclose(inputs["Split Threshold"].default_value, 0.1)
|
||||
assert np.isclose(inputs["Edge Round"].default_value, 0.2)
|
||||
assert inputs["Edge Round"].parent.name == "Options"
|
||||
assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round"
|
||||
@@ -44,6 +46,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
|
||||
expected = ["Cleanup Threshold"]
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
expected.insert(0, "Boundary Smooth")
|
||||
expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
|
||||
elif group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Reference Depth"]
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_fields
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tools.nodes.common.nodes import capture_fields
|
||||
from tools.nodes.common.nodes import sample_field
|
||||
|
||||
|
||||
def store_test_fields(group, geometry, fields):
|
||||
@@ -27,8 +27,8 @@ def set_test_structure(monkeypatch, value):
|
||||
from tools.nodes.groups import image_depth_cutout
|
||||
build = image_depth_cutout.build_front_fields
|
||||
|
||||
def fields(group, rim, normal):
|
||||
result = build(group, rim, normal)
|
||||
def fields(group, rim, normal, normal_weight):
|
||||
result = build(group, rim, normal, normal_weight)
|
||||
structure = group.nodes.new("ShaderNodeValue")
|
||||
structure.outputs[0].default_value = value
|
||||
result["structure"] = structure.outputs[0]
|
||||
@@ -106,9 +106,12 @@ def test_rounding_shares_front_and_back_falloff(amount):
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
fields = build_volume_fields(group)
|
||||
geometry, center = capture_fields(group, source.outputs["Geometry"], "POINT", center=("FLOAT", fields["center"]))
|
||||
geometry = source.outputs["Geometry"]
|
||||
center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")}
|
||||
mode = group.nodes.new("FunctionNodeInputBool")
|
||||
result, rounded = build_volume_rounding(group, geometry, {**fields, **center}, mode.outputs[0])
|
||||
result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0])
|
||||
rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT")
|
||||
for name, value in rounded.items()}
|
||||
result = store_test_fields(group, result, {**center, **rounded})
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Round", "NODES").node_group = group
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
|
||||
def test_surface_interface_and_node_structure(surface):
|
||||
obj, _, _, inputs, _ = surface
|
||||
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
|
||||
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
|
||||
"Boundary Smooth", "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
|
||||
assert list(inputs) == expected
|
||||
assert inputs["Split Threshold"].default_value == 0
|
||||
assert inputs["Split Threshold"].min_value == 0
|
||||
@@ -307,7 +307,7 @@ def test_relief_interface_and_node_structure(relief):
|
||||
|
||||
@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0])
|
||||
@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"])
|
||||
def test_valid_only_samples_face_centers(surface, pattern, threshold):
|
||||
def test_valid_only_deletes_invalid_points_and_wire_edges(surface, pattern, threshold):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
set_value("Validity Threshold", threshold)
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
@@ -329,9 +329,9 @@ def test_valid_only_samples_face_centers(surface, pattern, threshold):
|
||||
set_value("Subdivide", subdivisions)
|
||||
set_value("Valid Only", False)
|
||||
base = evaluated(obj)
|
||||
u = np.array([(base[0][list(face), 0].mean() + 2) / 4 for face in base[1]])
|
||||
u = (base[0][:, 0] + 2) / 4
|
||||
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
|
||||
expected = [face for face, alpha_value in zip(base[1], sampled) if alpha_value >= threshold]
|
||||
expected = [face for face in base[1] if np.all(sampled[list(face)] >= threshold)]
|
||||
set_value("Valid Only", True)
|
||||
culled = evaluated(obj)
|
||||
assert len(culled[1]) == len(expected)
|
||||
@@ -363,7 +363,7 @@ def test_validity_graph_matches_reference_domains_and_connections(surface):
|
||||
obj, _, _, _, _ = surface
|
||||
group = obj.modifiers[0].node_group
|
||||
delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry")
|
||||
assert (delete.domain, delete.mode) == ("FACE", "ALL")
|
||||
assert (delete.domain, delete.mode) == ("POINT", "ALL")
|
||||
compare = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
|
||||
assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN")
|
||||
assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold"
|
||||
@@ -374,7 +374,9 @@ def test_validity_graph_matches_reference_domains_and_connections(surface):
|
||||
consumers = [link for link in group.links if link.to_node.bl_idname != "NodeReroute"
|
||||
and origin_socket(link.from_socket) == color.outputs["Color"]]
|
||||
assert any(link.to_node == alpha.node for link in consumers)
|
||||
assert origin_socket(delete.inputs["Geometry"].links[0].from_socket).node.node_tree.name == "O Image Plane"
|
||||
protected = origin_socket(delete.inputs["Geometry"].links[0].from_socket).node
|
||||
assert protected.bl_idname == "GeometryNodeGroup"
|
||||
assert protected.node_tree.name == "O Image Plane"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
||||
@@ -412,6 +414,7 @@ def test_plane_interface_panels(source, kind):
|
||||
assert [item.name for item in panels] == ["Options", "Data"]
|
||||
controls = ["Reference Depth"]
|
||||
if kind == "DEPTH":
|
||||
controls.insert(0, "Boundary Smooth")
|
||||
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
|
||||
else:
|
||||
controls += ["Smooth Weight", "Depth Direction"]
|
||||
|
||||
@@ -134,7 +134,8 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind):
|
||||
split = inputs[names.index("Split Threshold")]
|
||||
assert split.parent == inputs[names.index("Smooth")].parent
|
||||
assert split.parent.name != "Options"
|
||||
assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR")
|
||||
assert split.default_value == pytest.approx(0.1 if kind == "cutout" else 0)
|
||||
assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR")
|
||||
assert "Remove Strip Faces" not in names
|
||||
|
||||
|
||||
@@ -198,7 +199,7 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
|
||||
|
||||
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
|
||||
def test_cutout_removes_an_all_boundary_triangle_strip():
|
||||
import bmesh
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
@@ -208,15 +209,10 @@ def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
|
||||
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
|
||||
mesh.to_mesh(obj.data)
|
||||
mesh.free()
|
||||
set_value("Split Threshold", 0.0)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 4
|
||||
set_value("Split Threshold", 0.001)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.2)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Split Threshold", 0.0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
# Every vertex lies on the boundary, even before any depth separation.
|
||||
for split in (0.0, 0.001, 0.0):
|
||||
set_value("Split Threshold", split)
|
||||
for thickness in (0.0, 0.2):
|
||||
set_value("Thickness", thickness)
|
||||
points, faces = evaluated(obj)
|
||||
assert not faces and not len(points)
|
||||
|
||||
@@ -24,6 +24,14 @@ def test_shell_walls_directly_connect_caps(mode, split):
|
||||
front, faces = evaluated(obj)
|
||||
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
||||
boundary_count = sum(count == 1 for count in edges.values())
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
welded = {i for i, value in enumerate(mesh.attributes["o_balloon"].data)
|
||||
if mode == 0 and value.value == 0}
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
collapsed_walls = sum(count == 1 and a in welded and b in welded for (a, b), count in edges.items())
|
||||
set_value("Mode", mode)
|
||||
modifier = obj.modifiers[0]
|
||||
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
|
||||
@@ -32,9 +40,9 @@ def test_shell_walls_directly_connect_caps(mode, split):
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
points, shell_faces = evaluated(obj)
|
||||
assert len(points) == 2 * len(front)
|
||||
assert len(shell_faces) == 2 * len(faces) + boundary_count
|
||||
assert all(len(face) == 4 for face in shell_faces)
|
||||
assert len(points) == 2 * len(front) - len(welded)
|
||||
assert len(shell_faces) == 2 * len(faces) + boundary_count - collapsed_walls
|
||||
assert all(len(face) in (3, 4) for face in shell_faces)
|
||||
assert np.isfinite(points).all()
|
||||
assert_closed(shell_faces)
|
||||
|
||||
@@ -146,6 +154,31 @@ def test_coincident_independent_sheets_do_not_weld_together():
|
||||
assert component_count(faces) == 2
|
||||
|
||||
|
||||
def test_shell_source_indices_cross_identity_grid_rows():
|
||||
from scipy.spatial import cKDTree
|
||||
from tools.nodes.common.depth_surface import thicken_depth_surface
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = bpy.data.node_groups.new("Indexed Shell", "GeometryNodeTree")
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
grid = group.nodes.new("GeometryNodeMeshGrid")
|
||||
grid.inputs["Vertices X"].default_value = 65
|
||||
grid.inputs["Vertices Y"].default_value = 65
|
||||
amount = group.nodes.new("ShaderNodeValue")
|
||||
amount.outputs[0].default_value = 0.1
|
||||
shell = thicken_depth_surface(group, grid.outputs["Mesh"], amount.outputs[0])
|
||||
group.links.new(shell["geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj = bpy.data.objects.new("Indexed Shell", bpy.data.meshes.new("Empty"))
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.modifiers.new("Shell", "NODES").node_group = group
|
||||
points, faces = evaluated(obj)
|
||||
expected = np.array([(x / 64 - .5, y / 64 - .5, z)
|
||||
for z in (0, -.1) for y in range(65) for x in range(65)])
|
||||
assert len(points) == len(expected)
|
||||
assert cKDTree(points).query(expected)[0].max() < 1e-6
|
||||
assert_closed(faces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 2, 4])
|
||||
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
@@ -185,7 +218,7 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
)
|
||||
|
||||
|
||||
def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
|
||||
def test_source_surface_sampling_preserves_depth_on_side_walls():
|
||||
obj, set_value = surface(step=3.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Thickness", 0.1)
|
||||
@@ -200,10 +233,11 @@ def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
|
||||
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
||||
@pytest.mark.parametrize("split", [0.0, 0.5])
|
||||
def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, split):
|
||||
def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, split):
|
||||
obj, set_value = surface(wrapper=True)
|
||||
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
|
||||
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
||||
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
|
||||
for value in attribute.data:
|
||||
value.value = 0.75
|
||||
set_value("Split Threshold", split)
|
||||
@@ -214,6 +248,9 @@ def test_internal_fields_stay_anonymous_and_user_attributes_survive(thickness, s
|
||||
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_anyimage_user_weight" in mesh.attributes
|
||||
assert ".o_anyimage_original_boundary" not in mesh.attributes
|
||||
assert ".o_anyimage_depth_cut" not in mesh.attributes
|
||||
assert all(
|
||||
v.value == pytest.approx(0.75)
|
||||
for v in mesh.attributes["o_user_weight"].data
|
||||
|
||||
@@ -36,6 +36,7 @@ def panorama(depth=3, alpha=1, subdivide=3, reference=3):
|
||||
|
||||
for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)):
|
||||
set_value(name, value)
|
||||
set_value("Boundary Smooth", 0)
|
||||
return obj, set_value
|
||||
|
||||
|
||||
@@ -209,6 +210,7 @@ def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
|
||||
obj, set_value = panorama(depth)
|
||||
original = evaluated(obj)
|
||||
set_value("Split Threshold", 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]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from anyimage.common.object import (
|
||||
modifier_input_identifier,
|
||||
@@ -17,6 +18,46 @@ def evaluated_mesh(obj):
|
||||
return evaluated, evaluated.to_mesh()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_count", [0, 4, 9])
|
||||
def test_material_inheritance_removes_only_source_vertices(source_count):
|
||||
from tools.nodes.common.material import inherit_material_slots
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
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)] if source_count else [])
|
||||
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
|
||||
for arranged in (False, True):
|
||||
if arranged:
|
||||
arrange_nodes(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)
|
||||
if source_count:
|
||||
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))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Check the saved library's public inventory and persistence."""
|
||||
import bpy
|
||||
import pytest
|
||||
from anyimage.common.node import load_node_group, node_asset_path
|
||||
from tools.nodes.check import GEOMETRY_NAMES, SHADER_NAMES
|
||||
|
||||
@@ -15,11 +16,46 @@ def test_saved_asset_inventory():
|
||||
assert node_group.use_fake_user and node_group.users > 0
|
||||
if name in GEOMETRY_NAMES:
|
||||
assert node_group.is_modifier
|
||||
stores = [node for node in node_group.nodes if node.bl_idname == "GeometryNodeStoreNamedAttribute"]
|
||||
assert all(node.inputs["Name"].default_value in {"UVMap", "o_normal_scale"} for node in stores), name
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
assert node_group.library is not None
|
||||
if name in {"O Image Depth Layer", "O Shadeless"}:
|
||||
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color", "Alpha"]
|
||||
assert [item.name for item in node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_geometry_attribute_contract_includes_nested_groups(source):
|
||||
from tools.nodes.build import build_node_groups
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
if source == "definition":
|
||||
pending = build_node_groups()
|
||||
else:
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (library, target):
|
||||
target.node_groups = list(library.node_groups)
|
||||
pending = list(target.node_groups)
|
||||
seen = set()
|
||||
allowed = {
|
||||
"UVMap": ("FLOAT2", "CORNER"),
|
||||
"o_balloon": ("FLOAT", "POINT"),
|
||||
".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
}
|
||||
while pending:
|
||||
group = pending.pop()
|
||||
if group in seen or group.bl_idname != "GeometryNodeTree":
|
||||
continue
|
||||
seen.add(group)
|
||||
for node in group.nodes:
|
||||
assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name
|
||||
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
|
||||
pending.append(node.node_tree)
|
||||
if node.bl_idname != "GeometryNodeStoreNamedAttribute":
|
||||
continue
|
||||
name = node.inputs["Name"].default_value
|
||||
if name == "o_normal_scale":
|
||||
assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"}
|
||||
else:
|
||||
assert name in allowed, (group.name, name)
|
||||
assert (node.data_type, node.domain) == allowed[name]
|
||||
assert {group.name for group in seen} == GEOMETRY_NAMES
|
||||
|
||||
43
tests/tools/nodes/test_temporary_attributes.py
Normal file
43
tests/tools/nodes/test_temporary_attributes.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Check temporary attribute lifetimes across depth modifier branches."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from anyimage.common.node import node_asset_path
|
||||
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("source", ["definition", "asset"])
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
def test_depth_attribute_cleanup_has_no_warnings(source, kind):
|
||||
if kind == "cutout":
|
||||
obj, set_value = surface()
|
||||
elif kind == "plane":
|
||||
obj, set_value, *_ = create_surface("definition", "DEPTH")
|
||||
else:
|
||||
obj, set_value = panorama(subdivide=2)
|
||||
modifier = obj.modifiers[0]
|
||||
if source == "asset":
|
||||
name = modifier.node_group.name
|
||||
modifier.node_group.name += " Definition"
|
||||
with bpy.data.libraries.load(str(node_asset_path())) as (_, target):
|
||||
target.node_groups = [name]
|
||||
modifier.node_group = target.node_groups[0]
|
||||
for split in (0.0, 0.5, 0.0):
|
||||
set_value("Split Threshold", 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 {a.name for a in mesh.attributes} & {
|
||||
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
|
||||
}
|
||||
assert not list(modifier.node_warnings), [
|
||||
warning.message for warning in modifier.node_warnings
|
||||
]
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
40
tests/tools/nodes/test_validity.py
Normal file
40
tests/tools/nodes/test_validity.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Verify point culling removes its dangling wire geometry."""
|
||||
|
||||
import bpy
|
||||
|
||||
from tools.nodes.common.nodes import interface_socket
|
||||
from tools.nodes.common.validity import delete_invalid_points
|
||||
|
||||
|
||||
def test_point_culling_removes_wire_edges_without_removing_adjacent_surface():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("Two Quads")
|
||||
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (2, 0, 0),
|
||||
(0, 1, 0), (1, 1, 0), (2, 1, 0)], [],
|
||||
[(0, 1, 4, 3), (1, 2, 5, 4)])
|
||||
obj = bpy.data.objects.new("Surface", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Point Culling", "GeometryNodeTree")
|
||||
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
inlet = group.nodes.new("NodeGroupInput")
|
||||
outlet = group.nodes.new("NodeGroupOutput")
|
||||
index = group.nodes.new("GeometryNodeInputIndex")
|
||||
invalid = group.nodes.new("ShaderNodeMath")
|
||||
invalid.operation = "LESS_THAN"
|
||||
invalid.inputs[1].default_value = 1
|
||||
group.links.new(index.outputs[0], invalid.inputs[0])
|
||||
geometry = delete_invalid_points(group, inlet.outputs["Geometry"], invalid.outputs[0])
|
||||
group.links.new(geometry, outlet.inputs["Geometry"])
|
||||
modifier = obj.modifiers.new("Cull", "NODES")
|
||||
modifier.node_group = group
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.polygons) == 1
|
||||
assert len(result.edges) == 4
|
||||
assert {tuple(vertex.co) for vertex in result.vertices} == {
|
||||
(1, 0, 0), (2, 0, 0), (1, 1, 0), (2, 1, 0),
|
||||
}
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
Reference in New Issue
Block a user