Refactor depth surface handling and unify depth sampling rules

- Removed the `always_vertex` parameter from `build_surface_camera` to streamline depth sampling for cutouts and ensure consistent behavior across depth plane and panorama.
- Updated depth sampling logic to ensure that all free boundary points, including the original frame of the depth plane, use neighboring face depth instead of their own texture sampling.
- Adjusted the depth mask deletion process to operate on faces rather than points, eliminating the need for additional cleanup of dangling edges.
- Ensured that the cutout rules are consistently applied across all relevant nodes, removing exceptions and simplifying the logic.
- Updated tests to verify the new behavior, ensuring that cutout points follow the depth of adjacent faces and that the original frame maintains its depth sampling.
- Refactored geometry handling in multiple depth-related nodes to improve clarity and maintainability.
This commit is contained in:
2026-09-16 16:30:56 +08:00
parent 7f69630371
commit c62ec2cd48
28 changed files with 880 additions and 173 deletions

View File

@@ -9,6 +9,67 @@ import pytest
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
from tools.nodes.common.boundary_smoothing import cut_boundary_influence
from tools.nodes.common.smoothing import pinned_smooth
def pinned_group(
iterations=1,
region=1.0,
weight=1.0,
movement=None,
pin_boundary=True,
pin_sharp=True,
):
group = bpy.data.node_groups.new("Pinned smooth", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(
name="Geometry", in_out=direction, socket_type="NodeSocketGeometry"
)
smooth = group.interface.new_socket(
name="Smooth", in_out="INPUT", socket_type="NodeSocketInt"
)
smooth.default_value = iterations
source = group.nodes.new("NodeGroupInput")
control = group.nodes.new("NodeGroupInput")
region_value = group.nodes.new("ShaderNodeValue")
region_value.outputs[0].default_value = region
result = pinned_smooth(
group,
source.outputs["Geometry"],
control.outputs["Smooth"],
region_value.outputs[0],
weight=weight,
movement=movement,
pin_boundary=pin_boundary,
pin_sharp=pin_sharp,
)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
return group
def pinned_result(points, faces, **options):
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Pinned")
mesh.from_pydata(points, [], faces)
obj = bpy.data.objects.new("Pinned", mesh)
bpy.context.collection.objects.link(obj)
obj.modifiers.new("Pinned", "NODES").node_group = pinned_group(**options)
return evaluated(obj)[0]
def grid(size):
points = [(x, y, 0.0) for y in range(size) for x in range(size)]
faces = [
(
y * size + x,
y * size + x + 1,
(y + 1) * size + x + 1,
(y + 1) * size + x,
)
for y in range(size - 1)
for x in range(size - 1)
]
return points, faces
def diagonal_surface(triangles=False):
@@ -287,3 +348,64 @@ def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
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
def test_boundary_points_follow_the_boundary_strip_blur():
"""A boundary spike relaxes along the outline instead of being pulled inward."""
points, faces = grid(4)
points[1] = (1.0, -0.5, 0.0)
result = pinned_result(points, faces)
# 边界条带只连接边界邻居:自身权重 1,两个边界邻居权重 0.5。
np.testing.assert_allclose(result[1], (1.0, -0.25, 0.0), atol=1e-6)
def test_sharp_features_lower_the_smoothing_weight():
"""A crease inside the normal blur range reduces the pinned displacement."""
points, faces = grid(3)
points[4] = (1.0, -0.5, 0.0)
flat = pinned_result(points, faces)
creased = list(points)
creased[0] = (0.0, 0.0, 1.0)
creased = pinned_result(creased, faces)
# 折痕不在尖点的邻居集合内,两次运行的平滑目标相同,只有锐度系数不同。
flat_step = abs(flat[4][1] + 0.5)
creased_step = abs(creased[4][1] + 0.5)
assert flat_step > 1e-3
assert creased_step < flat_step * 0.99
def test_junction_band_uses_the_neighbourhood_average():
"""Without the boundary pin a boundary spike follows every connected neighbour."""
points, faces = grid(4)
points[1] = (1.0, -0.5, 0.0)
result = pinned_result(points, faces, pin_boundary=False)
# 自身权重 1 与三个邻居的平均:(-0.5 + 0 + 0 + 1) / 4。
np.testing.assert_allclose(result[1], (1.0, 0.125, 0.0), atol=1e-6)
def test_junction_band_keeps_the_sharpness_weight():
"""The sharpness weight stays active without the boundary pin."""
points, faces = grid(3)
points[4] = (1.0, -0.5, 0.0)
flat = pinned_result(points, faces, pin_boundary=False)
creased = list(points)
creased[0] = (0.0, 0.0, 1.0)
creased = pinned_result(creased, faces, pin_boundary=False)
flat_step = abs(flat[4][1] + 0.5)
creased_step = abs(creased[4][1] + 0.5)
assert flat_step > 1e-3
assert creased_step < flat_step * 0.99
def test_movement_mask_keeps_the_symmetry_plane():
"""Restricting the movement leaves the masked axis untouched."""
points, faces = grid(4)
# 网格落在 XZ 对称平面上,Y 是只允许被松开的对称轴。
points = [(x, z, y) for x, y, z in points]
points[5] = (2.0, 0.5, 1.0)
original = np.array(points)
masked = pinned_result(points, faces, movement=(1, 0, 1))
np.testing.assert_array_equal(masked[:, 1], original[:, 1])
assert np.max(np.abs(masked[:, 0] - original[:, 0])) > 1e-4
full = pinned_result(points, faces)
assert np.max(np.abs(full[:, 1] - original[:, 1])) > 1e-4

View File

@@ -113,6 +113,7 @@ def test_symmetry_interface_and_cutout_defaults(symmetry):
"Scale",
"Fill Sides",
"Fill Smooth",
"Seam Smooth",
]
assert tuple(mirror_inputs[1].default_value) == (0, 0, 1)
assert mirror_inputs[3].default_value == 1
@@ -219,7 +220,7 @@ def test_disabled_fill_keeps_the_welded_seam(symmetry):
assert not any(plane[edge[0]] and plane[edge[1]] for edge in open_edges)
def test_fill_smooth_only_relaxes_the_seam(symmetry):
def test_fill_smooth_relaxes_the_junction_band(symmetry):
obj, _cutout, _mirror, _cutout_setter, mirror_setter = symmetry
mirror_setter("Fill Sides", True)
mirror_setter("Fill Smooth", 0)
@@ -232,17 +233,67 @@ def test_fill_smooth_only_relaxes_the_seam(symmetry):
plane = np.abs(points[:, 0]) <= tolerance
assert plane.any()
assert not any(all(plane[index] for index in face) for face in faces)
plain_interior = _front_vertices(
plain_points, plain_faces, plain_marker, lambda point: point[0] > tolerance
assert smooth_faces == plain_faces
axis = np.abs(plain_points[:, 0]) <= tolerance
moved = np.linalg.norm(smooth_points - plain_points, axis=1)
# 衔接带同时覆盖对称轴上的点与外侧轮廓。
assert moved[axis].max() > 1e-4
plain_rim = _positions(_front_rim(plain_points, plain_faces, plain_marker, tolerance))
smooth_rim = _positions(_front_rim(smooth_points, smooth_faces, smooth_marker, tolerance))
assert plain_rim != smooth_rim
def test_fill_smooth_band_reaches_beyond_two_rings():
"""Fill Smooth 平滑的是衔接带,覆盖范围比原先的两圈更宽。"""
from tools.nodes.common.depth_surface import _math
from tools.nodes.groups.image_cutout_symmetry import FILL_SMOOTH_RINGS, _falloff
bpy.ops.wm.read_factory_settings(use_empty=True)
size, rows = FILL_SMOOTH_RINGS + 4, 3
points = [(x, 0.0, y) for y in range(rows) for x in range(size)]
faces = [
(
y * size + x,
y * size + x + 1,
(y + 1) * size + x + 1,
(y + 1) * size + x,
)
for y in range(rows - 1)
for x in range(size - 1)
]
mesh = bpy.data.meshes.new("Fill band")
mesh.from_pydata(points, [], faces)
obj = bpy.data.objects.new("Fill band", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Fill band", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(
name="Geometry", in_out=direction, socket_type="NodeSocketGeometry"
)
source = group.nodes.new("NodeGroupInput")
position = group.nodes.new("GeometryNodeInputPosition")
axes = group.nodes.new("ShaderNodeSeparateXYZ")
group.links.new(position.outputs[0], axes.inputs[0])
outline = _math(group, "COMPARE", axes.outputs["X"], 0)
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT", "POINT"
store.inputs["Name"].default_value = "fill_band"
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(_falloff(group, outline, FILL_SMOOTH_RINGS), store.inputs["Value"])
group.links.new(
store.outputs["Geometry"], group.nodes.new("NodeGroupOutput").inputs["Geometry"]
)
smooth_interior = _front_vertices(
smooth_points, smooth_faces, smooth_marker, lambda point: point[0] > tolerance
)
assert _positions(plain_interior) == _positions(smooth_interior)
plain_rim = _front_rim(plain_points, plain_faces, plain_marker, tolerance)
smooth_rim = _front_rim(smooth_points, smooth_faces, smooth_marker, tolerance)
assert plain_rim.shape == smooth_rim.shape
assert not np.allclose(np.sort(plain_rim[:, 1:], axis=0), np.sort(smooth_rim[:, 1:], axis=0))
obj.modifiers.new("Fill band", "NODES").node_group = group
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
data = result.to_mesh()
try:
band = np.array(
[item.value for item in data.attributes["fill_band"].data]
).reshape(rows, size)
finally:
result.to_mesh_clear()
assert (band[:, 3] > 0).all()
assert (band[:, FILL_SMOOTH_RINGS + 1] == 0).all()
def test_object_normal_attributes_follow_final_orientation(symmetry):

View File

@@ -215,7 +215,8 @@ def test_depth_cutout_reuses_boundary_and_slow_field_nodes():
group = build_image_depth_cutout_group()
counts = Counter(node.bl_idname for node in group.nodes)
assert counts["GeometryNodeInputMeshEdgeNeighbors"] <= 15
assert counts["GeometryNodeBlurAttribute"] <= 12
# 内部平滑、边界条带与 pin sharp 的法线相干度各一个 Blur。
assert counts["GeometryNodeBlurAttribute"] <= 13
def test_center_derives_from_shared_slow_fields():

View File

@@ -13,8 +13,8 @@ def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert list(inputs) == [
"Color", "Alpha", "Normal", "Bump Scale", "Alpha Fix",
"Normal Scale", "Object Space",
"Color", "Alpha", "Normal", "Normal Scale", "Bump Scale",
"Alpha Fix", "Object Space",
]
strength = inputs["Alpha Fix"]
output = next(node for node in image_layer.nodes if node.type == "GROUP_OUTPUT")