Add depth surface processing and layout tools

- Implemented `cutout_depth_surface.py` for face separation and connected thickness calculations for depth surface assets.
- Created `cutout_depth_surface_layout.py` to arrange nodes for staggered calculation branches in depth surface processing.
- Authored `geometry_layouts.py` to define layouts for various geometry assets, including image cutouts and depth planes.
- Introduced `node_layout.py` for shared presentation rules, facilitating node arrangement and validation in geometry-node layouts.
This commit is contained in:
2026-09-07 19:21:03 +08:00
parent a0adf79db0
commit 7dec3b7ca4
21 changed files with 2075 additions and 373 deletions

View File

@@ -531,7 +531,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Stretch Limit",
"Split",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
@@ -542,7 +542,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert [item.name for item in panels[0].interface_items] == [
"Cleanup",
"Smooth",
"Stretch Limit",
"Split",
"Reference Depth",
"Depth Direction",
]
@@ -634,7 +634,8 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Geometry",
"Thickness",
"Depth Scale",
"Stretch Limit",
"Split",
"Cleanup",
"Reference Depth",
"Uniform Scale",
"Depth Image",
@@ -653,9 +654,10 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
"Geometry": {"DEPTH_SURFACE"},
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Stretch Limit": {"DEPTH_SURFACE"},
"Split": {"DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
@@ -672,7 +674,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
)
assert all(
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
for group in child_groups.values()
for shape, group in child_groups.items() if shape != "DEPTH_SURFACE"
)
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes)
assert all(
@@ -710,112 +712,33 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
stretch_limit = next(
split_control = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
if item.item_type == "SOCKET" and item.name == "Split"
)
child_stretch_limit = next(
child_split = next(
item
for item in child_groups["DEPTH_SURFACE"].interface.items_tree
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
if item.item_type == "SOCKET" and item.name == "Split"
)
assert stretch_limit.default_value == 0.0
assert stretch_limit.min_value == 0.0
assert stretch_limit.max_value == 1.0
assert stretch_limit.subtype == "FACTOR"
assert getattr(stretch_limit.parent, "name", "") == "Options"
assert child_stretch_limit.default_value == 0.0
assert child_stretch_limit.min_value == 0.0
assert child_stretch_limit.max_value == 1.0
assert child_stretch_limit.subtype == "FACTOR"
assert getattr(child_stretch_limit.parent, "name", "") == "Options"
assert split_control.default_value == 0.0
assert split_control.min_value == 0.0
assert split_control.max_value == 1.0
assert split_control.subtype == "FACTOR"
assert getattr(split_control.parent, "name", "") == "Options"
assert child_split.default_value == 0.0
assert child_split.min_value == 0.0
assert child_split.max_value == 1.0
assert child_split.subtype == "FACTOR"
assert getattr(child_split.parent, "name", "") == "Options"
depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes
captures = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeCaptureAttribute"
]
assert len(captures) == 1
assert captures[0].domain == "POINT"
assert [item.name for item in captures[0].capture_items] == [
"Original Edge Length"
]
captured_distance = next(
link.from_node
for link in child_groups["DEPTH_SURFACE"].links
if link.to_node == captures[0]
and link.to_socket.name == "Original Edge Length"
)
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
assert captured_distance.operation == "DISTANCE"
ratio = next(
link.to_node
for link in child_groups["DEPTH_SURFACE"].links
if link.from_node == captured_distance
and link.to_node.bl_idname == "ShaderNodeMath"
and link.to_node.operation == "DIVIDE"
)
assert any(
link.from_node == captures[0]
and link.to_node == ratio
and link.to_socket == ratio.inputs[0]
for link in child_groups["DEPTH_SURFACE"].links
)
assert any(
link.from_node == captured_distance
and link.to_node == ratio
and link.to_socket == ratio.inputs[1]
for link in child_groups["DEPTH_SURFACE"].links
)
captured_distance = next(
link.from_node
for link in child_groups["DEPTH_SURFACE"].links
if link.to_node == captures[0]
and link.to_socket.name == "Original Edge Length"
)
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
assert captured_distance.operation == "DISTANCE"
ratio = next(
link.to_node
for link in child_groups["DEPTH_SURFACE"].links
if link.from_node == captured_distance
and link.to_node.bl_idname == "ShaderNodeMath"
and link.to_node.operation == "DIVIDE"
)
assert any(
link.from_node == captures[0]
and link.to_node == ratio
and link.to_socket == ratio.inputs[0]
for link in child_groups["DEPTH_SURFACE"].links
)
assert any(
link.from_node == captured_distance
and link.to_node == ratio
and link.to_socket == ratio.inputs[1]
for link in child_groups["DEPTH_SURFACE"].links
)
samples = [
node
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeSampleIndex"
]
assert not samples
delete_domains = [
node.domain
for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeDeleteGeometry"
]
assert delete_domains.count("EDGE") == 1
assert delete_domains.count("POINT") == 2
assert any(
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
for node in depth_surface_nodes
)
assert any(
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
for node in depth_surface_nodes
)
assert any(node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes)
from validate_cutout import validate_depth_surface_attributes
validate_depth_surface_attributes(child_groups["DEPTH_SURFACE"])
assert [node.domain for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeDeleteGeometry"] == ["FACE"]
assert "Stretch Limit" not in [item.name for item in child_groups["DEPTH_SURFACE"].interface.items_tree]
cleanup_scale = next(
link.to_node
for link in cutout.links
@@ -1547,20 +1470,20 @@ def _add_cutout_modifier(obj, shape):
return modifier
def _depth_surface_stretch_object():
def _depth_surface_step_object():
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Depth Surface Stretch")
data = bpy.data.meshes.new("Depth Surface Step")
data.from_pydata(vertices, (), faces)
uv_layer = data.uv_layers.new(name="UVMap")
for loop in data.loops:
coordinate = data.vertices[loop.vertex_index].co
uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5)
obj = bpy.data.objects.new("Depth Surface Stretch", data)
obj = bpy.data.objects.new("Depth Surface Step", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE")
@@ -1617,40 +1540,17 @@ def _evaluated_topology(obj):
evaluated.to_mesh_clear()
def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology():
def test_depth_surface_split_preserves_original_faces():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_stretch_object()
stretch_id = object_data.modifier_input_identifier(
modifier.node_group,
"Stretch Limit",
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.set_modifier_input(modifier, stretch_id, 0.0)
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
object_data.set_modifier_input(modifier, stretch_id, 0.5)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
object_data.set_modifier_input(modifier, stretch_id, 0.6)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert 0 < faces < 8
assert vertices < 9
assert not loose_edges
assert not loose_points
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert _evaluated_topology(obj) == (9, 8, set(), set())
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split")
for value in (0., .5, 1.):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert vertices >= 9 and faces == 8
assert not loose_edges and not loose_points
def test_cutout_thickness_keeps_one_connected_shell():

View File

@@ -0,0 +1,321 @@
"""Behavior checks for face separation and connected Depth Surface shells."""
import sys
from pathlib import Path
from collections import Counter
import bpy
import bmesh
import numpy as np
import pytest
ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "tools" / "node_assets"))
from build_cutout import build_node_group, build_cutout_modifier_group
def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
groups = {
shape: build_node_group(shape)
for shape in ("SURFACE", "BALLOON", "DEPTH_BALLOON", "DEPTH_SURFACE")
}
group = build_cutout_modifier_group(groups) if wrapper else groups["DEPTH_SURFACE"]
# Two columns with a complete depth jump along their common boundary.
points = [
(x * size / resolution, y * size / resolution, 0)
for y in range(resolution + 1)
for x in range(2 * resolution + 1)
]
stride = 2 * resolution + 1
faces = [
(
y * stride + x,
y * stride + x + 1,
(y + 1) * stride + x + 1,
(y + 1) * stride + x,
)
for y in range(resolution)
for x in range(2 * resolution)
]
mesh = bpy.data.meshes.new("Step")
mesh.from_pydata(points, [], faces)
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
x, y, _ = points[loop.vertex_index]
uv.data[loop.index].uv = (x / (2 * size), y / size)
obj = bpy.data.objects.new("Step", mesh)
bpy.context.collection.objects.link(obj)
image = bpy.data.images.new("Camera", width=64, height=32, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
yy, xx = np.mgrid[:32, :64]
pixels = np.ones((32, 64, 4), np.float32)
pixels[..., 0] = (xx + 0.5) / 32
pixels[..., 1] = -(yy + 0.5) / 32
pixels[..., 2] = 1 + (xx >= 32) * step
image.pixels.foreach_set(pixels.ravel())
modifier = obj.modifiers.new("Depth Surface", "NODES")
modifier.node_group = group
inputs = {
s.name: s.identifier
for s in group.interface.items_tree
if s.item_type == "SOCKET" and s.in_out == "INPUT"
}
def set_value(name, value):
modifier[inputs[name]] = value
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
for name, value in (
("Depth Image", image),
("Uniform Scale", size),
("Reference Depth", size),
("Depth Scale", 1.0),
("Thickness", 0.0),
("Cleanup", 0.0),
("Split", 0.0),
):
set_value(name, value)
if wrapper:
set_value("Shape", 3)
set_value("Inward Axis", 0)
set_value("Smooth Iterations", 0)
return obj, set_value
def evaluated(obj):
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
return np.array([v.co[:] for v in mesh.vertices]), [
tuple(p.vertices) for p in mesh.polygons
]
finally:
result.to_mesh_clear()
def assert_closed(faces):
edges = Counter(
tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0]))
)
assert edges and set(edges.values()) == {2}, Counter(edges.values())
@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("Split", split)
set_value("Thickness", thickness)
coords, faces = evaluated(obj)
assert np.isfinite(coords).all()
assert_closed(faces)
assert coords[:, 2].min() == pytest.approx(-6 - thickness, abs=1e-5)
@pytest.mark.parametrize("size", [0.01, 1.0, 100.0])
def test_split_mapping_and_uniform_scale(size):
obj, set_value = surface(step=2.0, size=size)
# Face centers are size/2 apart: gradient is 4.
counts = []
for split in (0.0, 0.25, 0.5, 1.0):
set_value("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) == 15 and np.abs(coords[:, 2]).max() < 1e-6
def test_midpoint_selects_gradient_six_and_preserves_faces():
obj, set_value = surface(step=3.0)
set_value("Split", 0.5)
coords, faces = evaluated(obj)
assert len(coords) == 18 and len(faces) == 8
assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0}
def test_cleanup_removes_new_islands_before_thickness():
obj, set_value = surface()
set_value("Cleanup", 1.1)
assert len(evaluated(obj)[1]) == 8
set_value("Split", 0.5)
assert len(evaluated(obj)[1]) == 0
set_value("Thickness", 0.2)
assert len(evaluated(obj)[1]) == 0
def test_full_wrapper_smooth_keeps_closed_shell():
obj, set_value = surface(wrapper=True)
set_value("Split", 0.5)
set_value("Thickness", 0.2)
set_value("Smooth Iterations", 2)
coords, faces = evaluated(obj)
assert np.isfinite(coords).all()
assert_closed(faces)
def component_count(faces):
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)
remaining = set(adjacency)
count = 0
while remaining:
count += 1
pending = [remaining.pop()]
while pending:
for neighbor in adjacency[pending.pop()] & remaining:
remaining.remove(neighbor)
pending.append(neighbor)
return count
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
@pytest.mark.parametrize("resolution", [1, 2, 4])
def test_planar_depth_has_no_split_at_any_density(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
original, original_faces = evaluated(obj)
set_value("Split", 1.0)
coords, faces = evaluated(obj)
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("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=3.0)
original, original_faces = evaluated(obj)
set_value("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 = 1.0 - before[2], 1.0 - after[2]
np.testing.assert_allclose(
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
)
def test_stored_point_positions_prevent_depth_resampling_on_side_walls():
obj, set_value = surface(step=3.0)
set_value("Split", 0.5)
set_value("Thickness", 0.1)
coords, faces = evaluated(obj)
assert_closed(faces)
assert set(np.round(coords[:, 2], 4)) == {0.0, -0.1, -3.0, -3.1}
@pytest.mark.parametrize("thickness", [0.0, 0.2])
@pytest.mark.parametrize("split", [0.0, 0.5])
def test_temporary_attributes_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")
for value in attribute.data:
value.value = 0.75
set_value("Split", split)
set_value("Thickness", thickness)
set_value("Smooth Iterations", 2)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert not any(a.name.startswith("o_depth_") for a in mesh.attributes)
assert "UVMap" 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 test_zero_center_distance_does_not_split_or_produce_nan():
obj, set_value = surface(step=6.0)
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("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=3.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("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, 2, 4])
def test_linear_depth_slope_has_density_independent_selection(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + 4 * pixels[..., 0]
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Split", 0.5)
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
set_value("Split", 1.0)
assert len(evaluated(obj)[0]) > len(obj.data.vertices)

View File

@@ -1,4 +1,5 @@
import unittest
import sys
from pathlib import Path
@@ -7,6 +8,26 @@ SOURCE_ROOT = PROJECT_ROOT / "src" / "anyimage"
class NodeAssetsTest(unittest.TestCase):
def test_geometry_assets_have_readable_local_layouts(self):
import bpy
sys.path.insert(0, str(PROJECT_ROOT / "tools" / "node_assets"))
from node_layout import validate_node_layout
bpy.ops.wm.read_factory_settings(use_empty=True)
with bpy.data.libraries.load(
str(SOURCE_ROOT / "assets" / "O_AnyImage.blend")
) as (source, target):
target.node_groups = list(source.node_groups)
groups = [
group
for group in target.node_groups
if group.bl_idname == "GeometryNodeTree"
]
self.assertEqual(len(groups), 7)
for group in groups:
validate_node_layout(group)
def test_production_code_does_not_create_node_groups(self):
offenders = []
for path in SOURCE_ROOT.rglob("*.py"):
@@ -15,9 +36,9 @@ class NodeAssetsTest(unittest.TestCase):
self.assertEqual(offenders, [])
def test_runtime_node_group_entries_load_assets(self):
material_source = (
SOURCE_ROOT / "common" / "material.py"
).read_text(encoding="utf-8")
material_source = (SOURCE_ROOT / "common" / "material.py").read_text(
encoding="utf-8"
)
mesh_plane_source = (
SOURCE_ROOT / "operators" / "convert_to_plane" / "mesh_plane.py"
).read_text(encoding="utf-8")