- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""Behavior checks for face separation and connected Depth Surface shells."""
|
|
|
|
from collections import Counter
|
|
import bpy
|
|
import bmesh
|
|
import numpy as np
|
|
import pytest
|
|
|
|
|
|
from tests.support.depth_surface import assert_closed, component_count, evaluated, surface
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [0, 1])
|
|
@pytest.mark.parametrize("split", [0.0, 0.5])
|
|
def test_shell_walls_directly_connect_caps(mode, split):
|
|
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
|
|
|
obj, set_value = surface(resolution=4)
|
|
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
|
|
"value", np.full(len(obj.data.vertices), 0.3)
|
|
)
|
|
set_value("Depth Split", 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")
|
|
for amount in (0.01, 2.0):
|
|
set_modifier_input(modifier, thickness, amount)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
points, shell_faces = evaluated(obj)
|
|
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)
|
|
|
|
|
|
@pytest.mark.parametrize("thickness", [0.1, 1.4])
|
|
@pytest.mark.parametrize("split", [0.0, 0.5])
|
|
def test_surface_caps_and_segmented_walls_are_connected(thickness, split):
|
|
obj, set_value = surface()
|
|
set_value("Depth Split", split)
|
|
set_value("Thickness", thickness)
|
|
coords, faces = evaluated(obj)
|
|
assert np.isfinite(coords).all()
|
|
assert_closed(faces)
|
|
set_value("Thickness", 0.0)
|
|
front, _ = evaluated(obj)
|
|
from scipy.spatial import cKDTree
|
|
assert cKDTree(front).query(coords)[0].max() <= thickness + 1e-5
|
|
assert cKDTree(coords).query(front)[0].max() < 1e-5
|
|
|
|
|
|
@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, faces = evaluated(obj)
|
|
assert len(coords) == 18 and np.abs(coords[:, 2]).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[:, 2], 4)) == {2.0, -2.0}
|
|
|
|
|
|
def test_cleanup_keeps_split_parts_of_a_large_source_island():
|
|
obj, set_value = surface(wrapper=True)
|
|
set_value("Cleanup Threshold", 110.0)
|
|
assert len(evaluated(obj)[1]) == 8
|
|
set_value("Depth Split", 0.5)
|
|
assert len(evaluated(obj)[1]) == 8
|
|
set_value("Thickness", 0.2)
|
|
assert_closed(evaluated(obj)[1])
|
|
set_value("Cleanup Threshold", 210.0)
|
|
assert len(evaluated(obj)[1]) == 0
|
|
|
|
|
|
@pytest.mark.parametrize("depth_scale", [0.0, 2.0])
|
|
def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
|
obj, set_value = surface(step=4.0)
|
|
set_value("Depth Split", 0.5)
|
|
original, original_faces = evaluated(obj)
|
|
set_value("Depth Scale", depth_scale)
|
|
coords, faces = evaluated(obj)
|
|
assert len(coords) == len(original) == 18
|
|
assert faces == original_faces
|
|
np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6)
|
|
|
|
|
|
def test_full_wrapper_keeps_closed_shell():
|
|
obj, set_value = surface(wrapper=True)
|
|
set_value("Depth Split", 0.5)
|
|
set_value("Thickness", 0.2)
|
|
coords, faces = evaluated(obj)
|
|
assert np.isfinite(coords).all()
|
|
assert_closed(faces)
|
|
|
|
|
|
def test_coincident_independent_sheets_do_not_weld_together():
|
|
obj, set_value = surface(step=0.0)
|
|
mesh = obj.data
|
|
vertices = [tuple(v.co) for v in mesh.vertices]
|
|
faces = [tuple(f.vertices) for f in mesh.polygons]
|
|
n = len(vertices)
|
|
duplicate = bpy.data.meshes.new("Close sheets")
|
|
duplicate.from_pydata(
|
|
vertices + [(x, y, z + 1e-8) for x, y, z in vertices],
|
|
[],
|
|
faces + [tuple(v + n for v in face) for face in faces],
|
|
)
|
|
uv = duplicate.uv_layers.new(name="UVMap")
|
|
for loop in duplicate.loops:
|
|
x, y, z = duplicate.vertices[loop.vertex_index].co
|
|
uv.data[loop.index].uv = (x / 2, y)
|
|
obj.data = duplicate
|
|
set_value("Thickness", 0.1)
|
|
coords, faces = evaluated(obj)
|
|
assert_closed(faces)
|
|
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, 4])
|
|
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
|
|
obj, set_value = surface(step=0.0, resolution=resolution)
|
|
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(coords) == len(obj.data.vertices)
|
|
assert len(faces) == len(obj.data.polygons)
|
|
np.testing.assert_allclose(coords, original, atol=1e-6)
|
|
|
|
|
|
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)
|
|
before_depth, after_depth = 3.0 - before[2], 3.0 - after[2]
|
|
np.testing.assert_allclose(
|
|
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
|
|
)
|
|
|
|
|
|
def test_source_surface_sampling_preserves_depth_on_side_walls():
|
|
obj, set_value = surface(step=4.0)
|
|
set_value("Depth Split", 0.5)
|
|
set_value("Thickness", 0.1)
|
|
coords, faces = evaluated(obj)
|
|
assert_closed(faces)
|
|
set_value("Thickness", 0.0)
|
|
front, _ = evaluated(obj)
|
|
from scipy.spatial import cKDTree
|
|
assert cKDTree(front).query(coords)[0].max() <= 0.1 + 1e-5
|
|
assert cKDTree(coords).query(front)[0].max() < 1e-5
|
|
|
|
|
|
@pytest.mark.parametrize("thickness", [0.0, 0.2])
|
|
@pytest.mark.parametrize("split", [0.0, 0.5])
|
|
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("Depth Split", split)
|
|
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_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
|
|
)
|
|
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()
|
|
|
|
|
|
def test_triangle_corner_weights_do_not_open_sloped_side_walls():
|
|
obj, set_value = surface(step=4.0)
|
|
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.arange(32)[:, None] * 0.02
|
|
image.pixels.foreach_set(pixels.ravel())
|
|
image.update()
|
|
set_value("Depth Split", 0.5)
|
|
set_value("Thickness", 0.8)
|
|
coords, faces = evaluated(obj)
|
|
assert_closed(faces)
|
|
assert component_count(faces) == 2
|
|
|
|
|
|
@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)
|
|
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()
|