feat: Implement depth surface strip cleanup and organize tool CLI
- Added a proposal for depth surface strip cleanup, detailing the rationale and changes to the depth plane and cutout nodes. - Created specifications for the new cleanup requirements, including scenarios for strip removal and boundary edge detection. - Developed tasks for implementing shared strip detection logic and integrating new interfaces for depth nodes. - Established a new CLI structure for organizing node groups and model commands, replacing old entry points with a more intuitive command structure. - Introduced a prototype for panorama surface generation, including requirements for distance reconstruction and geometry node surfaces. - Added comprehensive tests for depth strip cleanup functionality, ensuring correct behavior for various mesh topologies and edge cases.
This commit is contained in:
222
tests/tools/nodes/test_depth_strip_cleanup.py
Normal file
222
tests/tools/nodes/test_depth_strip_cleanup.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Evaluate opposite-boundary cleanup on explicit mesh topology."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.common.depth_surface import _remove_strip_faces
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
|
||||
|
||||
def cleaned_faces(vertices, faces, *, triangles=False):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("Topology")
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
ids = mesh.attributes.new("source_face", "INT", "FACE")
|
||||
ids.data.foreach_set("value", np.arange(len(faces), dtype=np.int32))
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop in mesh.loops:
|
||||
uv.data[loop.index].uv = mesh.vertices[loop.vertex_index].co[:2]
|
||||
obj = bpy.data.objects.new("Topology", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Strip cleanup test", "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")
|
||||
result = _remove_strip_faces(group, source.outputs["Geometry"], triangles=triangles)
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Cleanup", "NODES").node_group = group
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert all(np.isfinite(vertex.co).all() for vertex in result.vertices)
|
||||
if not result.polygons:
|
||||
assert not result.vertices and not result.edges
|
||||
return set()
|
||||
assert "UVMap" in result.attributes
|
||||
for loop in result.loops:
|
||||
np.testing.assert_allclose(result.uv_layers["UVMap"].data[loop.index].uv,
|
||||
result.vertices[loop.vertex_index].co[:2])
|
||||
return {value.value for value in result.attributes["source_face"].data}
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cells, expected", [
|
||||
([(0, 0)], set()),
|
||||
([(x, 0) for x in range(5)], set()),
|
||||
([(x, y) for y in range(3) for x in range(3)], set(range(9))),
|
||||
# The center becomes isolated only after its three arms disappear.
|
||||
([(0, 0), (1, 0), (2, 0), (1, 1)], {1}),
|
||||
])
|
||||
@pytest.mark.parametrize("offset, reverse", [(0, False), (1, False), (2, True), (3, True)])
|
||||
def test_quad_strips_and_corners_are_classified_once(cells, expected, offset, reverse):
|
||||
vertices, indices, faces = [], {}, []
|
||||
for x, y in cells:
|
||||
face = []
|
||||
for point in [(x, y, 0), (x + 1, y, 0), (x + 1, y + 1, 0), (x, y + 1, 0)]:
|
||||
if point not in indices:
|
||||
indices[point] = len(vertices)
|
||||
vertices.append(point)
|
||||
face.append(indices[point])
|
||||
face = face[offset:] + face[:offset]
|
||||
faces.append(face[::-1] if reverse else face)
|
||||
assert cleaned_faces(vertices, faces) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [3, 5, 6])
|
||||
def test_non_quad_faces_are_preserved(count):
|
||||
angles = np.linspace(0, 2 * np.pi, count, endpoint=False)
|
||||
vertices = [(np.cos(a), np.sin(a), 0) for a in angles]
|
||||
assert cleaned_faces(vertices, [tuple(range(count))]) == {0}
|
||||
|
||||
|
||||
def test_nonmanifold_edge_is_not_a_boundary():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0),
|
||||
(2, 0, 0), (1, 2, 0), (0, 2, 1), (-1, 0, 0)]
|
||||
faces = [(0, 1, 2, 3), (2, 1, 4), (3, 2, 5), (2, 3, 6), (0, 3, 7)]
|
||||
assert cleaned_faces(vertices, faces) == set(range(5))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width, height, expected", [(1, 1, 0), (5, 1, 0), (2, 2, 8), (3, 3, 18)])
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_triangle_pairs_remove_strips_and_preserve_surface_corners(width, height, expected, reverse):
|
||||
vertices = [(x, y, 0) for y in range(height + 1) for x in range(width + 1)]
|
||||
faces = []
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
a = y * (width + 1) + x
|
||||
b, c, d = a + 1, a + width + 2, a + width + 1
|
||||
faces.extend([(a, b, c), (a, c, d)])
|
||||
# Reverse alternate faces to test matching by vertex identity, not winding.
|
||||
if reverse:
|
||||
faces = [face[::-1] if i % 2 else face[1:] + face[:1] for i, face in enumerate(faces)]
|
||||
result = cleaned_faces(vertices, faces, triangles=True)
|
||||
assert len(result) == expected
|
||||
|
||||
|
||||
def test_isolated_triangle_is_removed_only_for_cutout():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
|
||||
assert cleaned_faces(vertices, [(0, 1, 2)], triangles=True) == set()
|
||||
assert cleaned_faces(vertices, [(0, 1, 2)]) == {0}
|
||||
|
||||
|
||||
def test_triangle_pair_requires_a_manifold_shared_edge():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (.5, 1, 0), (.5, -1, 0), (.5, 0, 1)]
|
||||
faces = [(0, 1, 2), (1, 0, 3), (0, 1, 4)]
|
||||
assert cleaned_faces(vertices, faces, triangles=True) == {0, 1, 2}
|
||||
|
||||
|
||||
def test_triangle_pair_requires_four_distinct_vertices():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
|
||||
assert cleaned_faces(vertices, [(0, 1, 2), (2, 1, 0)], triangles=True) == {0, 1}
|
||||
|
||||
|
||||
def test_triangle_cleanup_marks_both_faces_without_recursive_peeling():
|
||||
vertices = [(0, 0, 0), (2, 0, 0), (1, 2, 0), (1, -2, 0),
|
||||
(3, 2, 0), (-1, 2, 0), (-1, -1, 0)]
|
||||
faces = [(0, 1, 2), (1, 0, 3), (2, 1, 4), (0, 2, 5), (3, 0, 6)]
|
||||
assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_split_input_precedes_smooth_without_cleanup_switch(kind):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane"
|
||||
else build_image_depth_cutout_group())
|
||||
inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
||||
names = [s.name for s in inputs]
|
||||
assert names.count("Split Threshold") == 1
|
||||
assert names[names.index("Smooth") - 1] == "Split Threshold"
|
||||
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 "Remove Strip Faces" not in names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_strips_are_removed_before_projection_and_closed_thickness(kind):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface("definition", "DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0)
|
||||
image = bpy.data.images["Camera"]
|
||||
width, height = image.size
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(height, width, 4)
|
||||
pixels[..., 2] = 1
|
||||
pixels[:, width // 4:3 * width // 4, 2] = 21
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Split Threshold", 0.5)
|
||||
vertices, faces = evaluated(obj)
|
||||
assert len(faces) == 4 and len(vertices) == 9
|
||||
assert component_count(faces) == 1
|
||||
set_value("Thickness", 0.2)
|
||||
before_smooth, shell = evaluated(obj)
|
||||
assert len(shell) == 16
|
||||
assert_closed(shell)
|
||||
set_value("Smooth", 2)
|
||||
after_smooth, smooth_shell = evaluated(obj)
|
||||
assert smooth_shell == shell
|
||||
assert np.isfinite(after_smooth).all()
|
||||
assert_closed(smooth_shell)
|
||||
assert component_count(smooth_shell) == 1
|
||||
set_value("Thickness", 0.0)
|
||||
assert len(evaluated(obj)[1]) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, _ = create_surface("definition", "DEPTH")
|
||||
set_value("Subdivide", 0)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0, resolution=1)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 2
|
||||
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)
|
||||
|
||||
|
||||
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
|
||||
import bmesh
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
obj, set_value = surface(step=0.0, resolution=1)
|
||||
mesh = bmesh.new()
|
||||
mesh.from_mesh(obj.data)
|
||||
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)
|
||||
Reference in New Issue
Block a user