refactor: update depth cutout and surface handling for improved geometry manipulation and clarity

This commit is contained in:
2026-09-18 01:43:52 +08:00
parent 076a8c90f1
commit 0e0287d340
13 changed files with 272 additions and 191 deletions

View File

@@ -1,5 +1,10 @@
"""Expose the extension and standalone server packages to the test suite."""
import sys
# Import the pinned dependencies before bpy prepends Blender's extension
# site-packages, where a half-removed install can shadow the project's own scipy.
import scipy # noqa: F401
from tests.support.paths import PROJECT_ROOT
sys.path[:0] = [str(PROJECT_ROOT / "src"), str(PROJECT_ROOT / "src/anyimage")]

View File

@@ -113,7 +113,7 @@ def _surface(node_group):
image.pixels.foreach_set(pixels.ravel())
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
"Normal Smooth": 2, "Depth Split": 0.25,
"Edge Turn": 2, "Depth Split": 0.25,
"Offset": 0.5}
mode = None
for item in node_group.interface.items_tree:

View File

@@ -1,4 +1,4 @@
"""Verify split profile shaping and thickness-weighted normals."""
"""Verify split profile shaping and displacement direction shaping."""
import bpy
import numpy as np
@@ -6,9 +6,9 @@ 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 tests.tools.nodes.test_boundary_smoothing import diagonal_surface
from tools.nodes.common.cutout_boundary import remove_boundary_triangles
from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal
from tools.nodes.groups.image_depth_cutout import build_normal_direction, build_normal_weight
def profile_values(obj):
@@ -77,63 +77,76 @@ def test_split_profile_tapers_within_boundary_band():
set_value("Depth Scale", 1)
np.testing.assert_allclose(profile_values(obj), .3)
set_value("Depth Split", .1)
points, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
uv = vertex_uv(obj)
# The depth step sits at u=0.5, so that column is the split line.
cut = [i for i in boundary if abs(uv[i,0] - .5) < 1e-4 and 1e-4 < uv[i,1] < 1 - 1e-4]
assert cut
band = edge_band(faces, cut)
outside = [i for i in range(len(points)) if i not in band]
assert outside
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)
np.testing.assert_allclose(values[outside], .3)
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)
@pytest.mark.parametrize("mode", [0, 1])
def test_normal_blend_weights_balloon_and_keeps_full_shell_smoothing(mode):
obj, set_value = surface(resolution=2)
group = obj.modifiers[0].node_group
# The production interface renames the Shell thickness 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 = "Shell Thickness"
is_shell = group.nodes.new("FunctionNodeInputBool")
is_shell.boolean = bool(mode)
weight = build_normal_weight(group, is_shell.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)
def sampled(obj, group, value, name):
"""Store one field on the points and read it back from the evaluated mesh."""
data_type, reader = {"VECTOR": ("FLOAT_VECTOR", "vector"), "VALUE": ("FLOAT", "value")}[value.type]
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
store.inputs["Name"].default_value = "normal_result"
store.data_type, store.domain = data_type, "POINT"
store.inputs["Name"].default_value = name
source = group.nodes.new("NodeGroupInput")
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(result, store.inputs["Value"])
group.links.new(value, store.inputs["Value"])
group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
return np.array([getattr(item, reader) for item in mesh.attributes[name].data])
finally:
result.to_mesh_clear()
def test_normal_weight_follows_profile_only_in_balloon_mode():
obj, _ = surface(resolution=2)
group = obj.modifiers[0].node_group
is_shell = group.nodes.new("FunctionNodeInputBool")
profile = group.nodes.new("ShaderNodeValue")
profile.outputs[0].default_value = .25
weight = build_normal_weight(group, is_shell.outputs[0], profile.outputs[0])
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), .25)
is_shell.boolean = True
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), 0.0)
def test_normal_direction_turns_the_edge_with_edge_turn():
obj, _ = surface(resolution=2)
group = obj.modifiers[0].node_group
smoothed = group.nodes.new("ShaderNodeCombineXYZ")
smoothed.inputs["Z"].default_value = 1
average = group.nodes.new("ShaderNodeCombineXYZ")
average.inputs["X"].default_value = 1
weight = group.nodes.new("ShaderNodeValue")
controls = group.nodes.new("NodeGroupInput")
direction = build_normal_direction(
group, controls, smoothed.outputs[0], average.outputs[0], weight.outputs[0],
)
modifier = obj.modifiers[0]
identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE")
reference = 2
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[-1], [1,0,0])
if mode:
np.testing.assert_array_equal(results, [[1,0,0]] * len(results))
else:
np.testing.assert_array_equal(results[0], [0,0,1])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001
turn = modifier_input_identifier(group, "Edge Turn")
def result(weight_value, turn_value):
weight.outputs[0].default_value = weight_value
set_modifier_input(modifier, turn, turn_value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return sampled(obj, group, direction, "normal_direction")[0]
np.testing.assert_allclose(result(0.0, 0.0), (0, 0, 1), atol=1e-6)
np.testing.assert_allclose(result(0.0, 1.0), (1, 0, 0), atol=1e-6)
np.testing.assert_allclose(result(0.0, 2.0), (2, 0, -1) / np.sqrt(5), atol=1e-6)
np.testing.assert_allclose(result(1.0, 2.0), (1, 0, 0), atol=1e-6)

View File

@@ -7,7 +7,8 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("mode", [0, 1])
def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode):
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)
@@ -19,24 +20,28 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
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, smoothing):
def result(amount, edge_turn):
set_modifier_input(modifier, thickness, amount)
set_value("Normal Smooth", smoothing)
set_modifier_input(modifier, turn, edge_turn)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
return evaluated(obj)
raw, faces = result(0, 16)
smooth, smooth_faces = result(0, 512)
np.testing.assert_array_equal(raw, smooth)
assert faces == smooth_faces
raw, faces = result(1, 16)
smooth, smooth_faces = result(1, 512)
assert faces == smooth_faces
assert np.isfinite(smooth).all()
assert np.max(np.abs(raw - smooth)) > 1e-5
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_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
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)
@@ -49,9 +54,8 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
set_value('Thickness', 0.)
front, _ = evaluated(obj)
def offsets(thickness, smoothing, reference=10.):
def offsets(thickness, reference=10.):
set_value('Thickness', thickness)
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 10 - reference, 0)
@@ -63,11 +67,9 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
order = np.argsort(indices[rear])
return ((points[rear] - adjusted_front[indices[rear]]) / thickness)[order], faces
raw, _ = offsets(.02, 16)
smooth, faces = offsets(.02, 512)
assert np.max(np.linalg.norm(raw - smooth, axis=1)) > .05
thinner, thinner_faces = offsets(.01, 512)
distant, _ = offsets(.02, 512, 20.)
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

View File

@@ -174,7 +174,7 @@ 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_anyimage_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)
@@ -184,10 +184,8 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
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_depth_cut" not in mesh.attributes
assert ".o_anyimage_original_boundary" not in mesh.attributes
assert ".o_anyimage_depth_cut" not 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)

View File

@@ -19,7 +19,7 @@ def test_saved_parameter_descriptions_match_definitions():
"O Image Depth Plane": {"Subdivide", "Thickness", "Depth Split", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
"O Image Relief Plane": {"Subdivide", "Thickness", "Depth Direction", "Depth Offset"},
"O Image Cutout": {"Thickness"},
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Normal Smooth", "Front Inflation"},
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Edge Turn", "Front Inflation"},
"O Image Cutout Symmetry": {"Direction", "Offset", "Scale", "Fill Sides", "Smooth"},
"O Image Depth Panorama": {"Subdivide", "Depth Scale", "Depth Split", "Dome Radius", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
@@ -82,8 +82,8 @@ def test_geometry_attribute_contract_includes_nested_groups():
allowed = {
"UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"),
"_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
"_o_solid_normal": ("FLOAT_VECTOR", "POINT"),
"_o_depth_cut": ("BOOLEAN", "CORNER"),
"_o_front_normal": ("FLOAT_VECTOR", "POINT"),
"_o_symmetry_weld": ("BOOLEAN", "FACE"),
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
"o_depth_face": ("FLOAT", "FACE"),

View File

@@ -26,10 +26,7 @@ def test_depth_attribute_cleanup_has_no_warnings(kind):
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",
"_o_anyimage_depth_cut",
}
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