feat: Implement depth cutout symmetry and associated functionalities

- Introduced a new proposal for depth cutout symmetry, allowing for the reuse of depth replacement and geometry operations.
- Added requirements for maintaining default thickness values for cutout nodes and ensuring proper initialization during object creation.
- Enhanced symmetry node to handle existing geometry, including alignment, mirroring, and boundary filling.
- Implemented fill smoothing to control the geometry of generated walls while preserving original surfaces.
- Created tests to validate the functionality of the new symmetry features against baseline geometry.
- Updated existing assets and node groups to support the new symmetry capabilities without introducing additional dependencies.
This commit is contained in:
2026-09-14 14:58:48 +08:00
parent dc000e50b1
commit ab282e4fdf
28 changed files with 716 additions and 689 deletions

View File

@@ -10,7 +10,7 @@ from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
@pytest.mark.parametrize("enabled", [False, True])
def test_depth_presets_always_generate_the_correct_normal_space(enabled):
assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT"
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "TANGENT"
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "OBJECT"
for shape in ("FLAT", "SOLID"):
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")

View File

@@ -155,10 +155,10 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("FLAT", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("SOLID", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"

View File

@@ -250,8 +250,10 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
assert layer.inputs["Object Space"].default_value is True
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == 0.5
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == pytest.approx(0.2)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0
object_data.set_modifier_input(
modifier,
@@ -623,134 +625,47 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture):
source.name,
result.name,
}
modifier = result.modifiers[0]
mode_id = object_data.modifier_input_identifier(modifier.node_group, "Mode")
assert modifier_value(modifier, mode_id) == 1
object_data.set_modifier_input(modifier, mode_id, 0)
depth_image_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Image",
)
assert isinstance(modifier_value(modifier, depth_image_id), bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
crop_center = Vector(
(
(local_bounds[0] + local_bounds[1]) * 0.5,
(local_bounds[2] + local_bounds[3]) * 0.5,
0.0,
)
)
expected_origin = source.matrix_world @ crop_center
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(expected_origin),
atol=1e-5,
)
assert len(result.modifiers) == 2
modifier, symmetry = result.modifiers
assert modifier.node_group.name == "O Image Depth Cutout"
assert symmetry.node_group.name == "O Image Cutout Symmetry"
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
assert layer.inputs["Object Space"].default_value is True
assert not modifier.show_group_selector and not symmetry.show_group_selector
depth_reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert not np.isclose(modifier_value(modifier, depth_reference_id), 8.0)
depth_direction_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Direction",
)
direction = np.asarray(modifier_value(modifier, depth_direction_id), dtype=np.float64)
assert not np.allclose(direction, (0.0, 0.0, 1.0))
uniform_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier_value(modifier, uniform_scale_id) > 0.0
thickness_id = object_data.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
smooth_id = object_data.modifier_input_identifier(
modifier.node_group,
"Smooth Weight",
)
assert modifier_value(modifier, smooth_id) == 1.0
object_data.set_modifier_input(modifier, smooth_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
def value(modifier, name, subtype=None):
return modifier_value(modifier, object_data.modifier_input_identifier(
modifier.node_group, name, subtype=subtype))
assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0)
for subtype in ("NONE", "DISTANCE"):
assert value(modifier, "Thickness", subtype) == 0
initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"}
for socket in modifier.node_group.interface.items_tree:
if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
and socket.name not in initialized
and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
assert value(modifier, "Depth Axis") == 0
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
assert value(modifier, "Uniform Scale") > 0
assert value(modifier, "Reference Depth") > 0
assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1))
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
(local_bounds[2] + local_bounds[3]) / 2, 0))
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
from scipy.spatial import cKDTree
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_one = positions[nearest_center, 2].max()
object_data.set_modifier_input(modifier, thickness_id, 2.0)
assert len(positions) and np.isfinite(positions).all()
assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5
# Both modes start from the same front, including after changing the menu.
object_data.set_modifier_input(modifier,
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
0 if gesture == "POLYLINE" else 1)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_two = positions[nearest_center, 2].max()
assert np.isclose(
depth_at_thickness_one,
depth_at_thickness_two,
atol=1e-5,
)
object_data.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier_value(modifier, depth_reference_id) + 0.2,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
after_positions = _evaluated_positions(result)
assert np.isclose(
(after_positions[:, 2] - before_positions[:, 2]).max(),
0.2,
atol=1e-5,
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_before = _evaluated_positions(result)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier_value(modifier, depth_reference_id) + 0.15,
)
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_after = _evaluated_positions(result)
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = object_data.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
object_data.set_modifier_input(modifier, double_sided_id, True)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
mirrored = _evaluated_positions(result)
layers = {}
for x, y, z in mirrored:
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
assert layers
for z_values in layers.values():
if len(z_values) == 1:
assert np.isclose(z_values[0], 0.0, atol=1e-5)
continue
assert np.isclose(
min(z_values) + max(z_values),
0.0,
atol=1e-5,
)
changed = _evaluated_positions(result)
np.testing.assert_array_equal(changed, positions)
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():

View File

@@ -1,11 +1,11 @@
"""Shared assertions and asset selection for node tests."""
from tools.nodes.groups.image_cutout import build_image_cutout_group
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group,
"SOLID": build_image_cutout_group,
"DEPTH_SOLID": build_image_depth_cutout_group,
"DEPTH_SYMMETRY": build_image_depth_symmetry_group,
"DEPTH_SYMMETRY": build_image_cutout_symmetry_group,
}

File diff suppressed because one or more lines are too long

View File

@@ -98,7 +98,8 @@ 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,
"Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25}
"Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25,
"Depth Offset": 0.5}
mode = None
for item in node_group.interface.items_tree:
if item.item_type != "SOCKET" or item.in_out != "INPUT":

View File

@@ -83,7 +83,7 @@ def test_split_profile_tapers_within_boundary_band(resolution, aspect):
@pytest.mark.parametrize("mode", [0, 1])
@pytest.mark.parametrize("reference", [0, 2])
def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, reference):
def test_normal_blend_weights_balloon_and_keeps_full_shell_smoothing(mode, reference):
obj, set_value = surface(resolution=2)
group = obj.modifiers[0].node_group
# The production interface renames the Shell thickness socket after building.
@@ -117,6 +117,9 @@ def test_normal_blend_converges_to_raw_and_reaches_full_smoothing(mode, referenc
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
finally:
ev.to_mesh_clear()
np.testing.assert_array_equal(results[0], [0,0,1])
np.testing.assert_array_equal(results[-1], [1,0,0])
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001
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

View File

@@ -0,0 +1,174 @@
"""Check composition against the original Projected geometry baseline."""
from collections import Counter
import json
from pathlib import Path
import bpy
import numpy as np
import pytest
from scipy.spatial import cKDTree
from mathutils import Euler, Vector
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from tests.support.depth_surface import evaluated, assert_closed
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
@pytest.fixture(params=['definition', 'asset'])
def symmetry(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
count = 5
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
faces = []
for row in range(count - 1):
for col in range(count - 1):
a = row * count + col
faces.extend(((a, a + 1, a + count + 1), (a, a + count + 1, a + count)))
# Match Cutout's outline cleanup before recording or comparing projection.
boundary = (np.maximum(abs(x), abs(y)) == 1).ravel()
faces = [face for face in faces if not all(boundary[i] for i in face)]
mesh = bpy.data.meshes.new('Curved Depth')
mesh.from_pydata(np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size))), [], faces)
uv = mesh.uv_layers.new(name='UVMap')
for loop in mesh.loops:
row, col = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((col + .5) / count, (row + .5) / count)
profile = .3 * np.maximum(0, 1 - np.maximum(abs(x), abs(y)))
mesh.attributes.new('o_balloon', 'FLOAT', 'POINT').data.foreach_set('value', profile.ravel())
mesh.attributes.new('user_probe', 'FLOAT', 'POINT').data.foreach_set('value', np.ones(x.size))
image = bpy.data.images.new('Depth', width=count, height=count, float_buffer=True)
image.colorspace_settings.name = 'Non-Color'
image.alpha_mode = 'CHANNEL_PACKED'
depth = 1 + .6 * x ** 2 + .3 * y ** 2
image.pixels.foreach_set(np.dstack((x, -y, depth, np.ones_like(x))).astype(np.float32).ravel())
obj = bpy.data.objects.new('Symmetry', mesh)
bpy.context.collection.objects.link(obj)
setters = []
for name, builder in [('DEPTH_SOLID', build_image_depth_cutout_group),
('DEPTH_SYMMETRY', build_image_cutout_symmetry_group)]:
group = builder() if request.param == 'definition' else load_cutout_node_group(name)
modifier = obj.modifiers.new(group.name, 'NODES')
modifier.node_group = group
def set_value(name, value, subtype=None, modifier=modifier, group=group):
set_modifier_input(modifier, modifier_input_identifier(group, name, subtype=subtype), value)
obj.update_tag(refresh={'DATA'})
bpy.context.view_layer.update()
setters.append(set_value)
cutout, mirror = setters
mirror('Fill Smooth', 0)
for name, value in [('Depth Image', image), ('Reference Depth', 1.45),
('Depth Split', 0), ('Boundary Smooth', 0), ('Smooth', 0), ('Depth Axis', 0)]:
cutout(name, value)
yield obj, cutout, mirror
bpy.ops.wm.read_factory_settings(use_empty=True)
def oriented_faces(points, faces):
"""Ignore vertex numbering while retaining polygon winding."""
keys = [tuple(np.round(point, 6)) for point in points]
result = []
for face in faces:
loop = tuple(keys[i] for i in face)
result.append(min(loop[i:] + loop[:i] for i in range(len(loop))))
return Counter(result)
@pytest.mark.parametrize('mode', [0, 1])
def test_zero_thickness_matches_original_projected(symmetry, mode):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
baseline = json.loads((Path(__file__).parent / 'fixtures/projected_symmetry.json').read_text())
for case in baseline['cases']:
mirror('Symmetry Direction', case['direction'])
mirror('Fill Sides', case['filled'])
points, faces = evaluated(obj)
expected = np.array(case['points'])
assert len(points) == len(expected)
assert cKDTree(points).query(expected)[0].max() < 1e-6
assert oriented_faces(points, faces) == oriented_faces(expected, case['faces'])
@pytest.mark.parametrize('mode', [0, 1])
@pytest.mark.parametrize('smooth', [0, 3])
def test_completed_geometry_is_mirrored_and_closed(symmetry, mode, smooth):
obj, cutout, mirror = symmetry
cutout('Mode', mode)
cutout('Thickness', .2 if mode else .6, 'DISTANCE' if mode else 'NONE')
mirror('Symmetry Direction', (.3, -.2, 1))
mirror('Fill Smooth', smooth)
points, faces = evaluated(obj)
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert_closed(faces)
orientation = Counter()
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
assert not any(orientation.values())
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
direction = Vector((.3, -.2, 1)).normalized()
wall_count = 0
for polygon in mesh.polygons:
i = polygon.index
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[i].vector)
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
assert mesh.attributes[AXIS_ATTRIBUTE].data[i].value == 0
if mesh.attributes[FACE_ATTRIBUTE].data[i].value == 3:
wall_count += 1
if smooth == 0:
assert abs(polygon.normal.z) < 1e-5
assert wall_count
finally:
result.to_mesh_clear()
def test_fill_smoothing_preserves_surfaces_and_only_moves_wall_interiors(symmetry):
obj, _, mirror = symmetry
mirror('Fill Sides', False)
original, original_faces = evaluated(obj)
mirror('Fill Smooth', 3)
points, faces = evaluated(obj)
np.testing.assert_array_equal(points, original)
assert faces == original_faces
mirror('Fill Sides', True)
points, faces = evaluated(obj)
assert_closed(faces)
assert cKDTree(points).query(original)[0].max() < 1e-6
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert len(points) > len(original)
assert cKDTree(original[:, :2]).query(points[:, :2])[0].max() > .001
assert oriented_faces(original, original_faces).items() <= oriented_faces(points, faces).items()
def test_direction_offset_and_empty_geometry(symmetry):
obj, _, mirror = symmetry
mirror('Depth Offset', 2.)
mirror('Symmetry Direction', (.3, -.2, 1))
original, original_faces = evaluated(obj)
mirror('Depth Offset', 2.2)
points, faces = evaluated(obj)
assert faces == original_faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
np.testing.assert_allclose(abs(points[:, 2]) - abs(original[:, 2]), .2, atol=1e-6)
mirror('Depth Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_symmetry_interface_and_cutout_defaults(symmetry):
obj, _, _ = symmetry
inputs = [item for item in obj.modifiers[1].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'INPUT']
assert [item.name for item in inputs] == ['Geometry', 'Symmetry Direction', 'Depth Offset', 'Fill Sides', 'Fill Smooth']
assert inputs[-1].default_value == 2 and inputs[-2].default_value is True
thickness = [item for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == 'SOCKET' and item.name == 'Thickness']
assert len(thickness) == 2 and all(item.default_value == 0 for item in thickness)

View File

@@ -13,7 +13,7 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
@pytest.mark.parametrize("source", ("definition", "asset"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_SYMMETRY"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID"))
def test_depth_axis_is_visible_in_options(shape, source):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = CUTOUT_BUILDERS[shape]() if source == "definition" else load_cutout_node_group(shape)

View File

@@ -50,6 +50,43 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
assert np.max(np.abs(raw - smooth)) > 1e-5
def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0, resolution=12)
image = bpy.data.images['Camera']
pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + .15 * np.sin(pixels[..., 0] * 6) * np.cos(pixels[..., 1] * 5)
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value('Reference Depth', 10.)
set_value('Thickness', 0.)
front, _ = evaluated(obj)
def offsets(thickness, smoothing, reference=10.):
set_value('Thickness', thickness)
set_value('Normal Smooth', smoothing)
set_value('Reference Depth', reference)
points, faces = evaluated(obj)
adjusted_front = front + (0, 0, reference - 10.)
from scipy.spatial import cKDTree
distances, indices = cKDTree(adjusted_front).query(points)
np.testing.assert_allclose(np.sort(distances)[:len(front)], 0, atol=2e-6)
rear = distances > thickness * .5
assert rear.sum() == len(front)
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.)
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
np.testing.assert_allclose(distant, smooth, atol=2e-4)
assert thinner_faces == faces
def set_test_structure(monkeypatch, value):
"""Control structural amplitude without depending on storage nodes."""
from tools.nodes.groups import image_depth_cutout

View File

@@ -1,239 +0,0 @@
"""Check projected clipping, fixed-plane symmetry and straight closed walls."""
from collections import Counter
import bpy
import bmesh
import numpy as np
import pytest
from mathutils import Euler, Vector
from scipy.spatial import cKDTree
from anyimage.common.object import modifier_input_identifier, set_modifier_input
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from tests.support.depth_surface import evaluated
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
@pytest.fixture(params=['definition', 'asset'])
def symmetry(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = (build_image_depth_symmetry_group() if request.param == 'definition'
else load_cutout_node_group('DEPTH_SYMMETRY'))
count = 17
y, x = np.meshgrid(np.linspace(-1, 1, count), np.linspace(-1, 1, count), indexing='ij')
points = np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size)))
faces = []
for row in range(count - 1):
for col in range(count - 1):
a = row * count + col
faces.extend(((a, a + 1, a + count + 1), (a, a + count + 1, a + count)))
mesh = bpy.data.meshes.new('Curved Depth')
mesh.from_pydata(points, [], faces)
uv = mesh.uv_layers.new(name='UVMap')
for loop in mesh.loops:
row, col = divmod(loop.vertex_index, count)
uv.data[loop.index].uv = ((col + .5) / count, (row + .5) / count)
profile = .3 * np.maximum(0, 1 - np.maximum(abs(x), abs(y)))
mesh.attributes.new('o_balloon', 'FLOAT', 'POINT').data.foreach_set('value', profile.ravel())
mesh.attributes.new('user_probe', 'FLOAT', 'POINT').data.foreach_set('value', np.ones(x.size))
image = bpy.data.images.new('Depth', width=count, height=count, float_buffer=True)
image.colorspace_settings.name = 'Non-Color'
image.alpha_mode = 'CHANNEL_PACKED'
depth = 1 + .6 * x ** 2 + .3 * y ** 2
image.pixels.foreach_set(np.dstack((x, -y, depth, np.ones_like(x))).astype(np.float32).ravel())
obj = bpy.data.objects.new('Symmetry', mesh)
bpy.context.collection.objects.link(obj)
modifier = obj.modifiers.new('Symmetry', 'NODES')
modifier.node_group = group
def set_value(name, value):
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
obj.update_tag(refresh={'DATA'})
bpy.context.view_layer.update()
for name, value in [('Mode', 1), ('Depth Image', image), ('Reference Depth', 1.45),
('Depth Scale', 1.), ('Uniform Scale', 1.), ('Boundary Smooth', 0),
('Smooth', 0), ('Double Sided', True)]:
set_value(name, value)
yield obj, set_value, depth.ravel(), faces
bpy.ops.wm.read_factory_settings(use_empty=True)
@pytest.mark.parametrize('direction,smooth,scale', [((0, 0, 1), 0, .001), ((-.4, .2, 1), 0, 1),
((.2, -.3, 1), 3, 100)])
def test_projected_volume_is_closed_symmetric_and_has_straight_walls(symmetry, direction, smooth, scale):
obj, set_value, _, _ = symmetry
for name, value in [('Depth Direction', direction), ('Smooth', smooth),
('Uniform Scale', scale), ('Reference Depth', 1.45 * scale)]:
set_value(name, value)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
points = np.array([v.co[:] for v in mesh.vertices])
assert len(points) and np.isfinite(points).all()
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < scale * 1e-5
bm = bmesh.new()
bm.from_mesh(mesh)
assert all(v.is_manifold for v in bm.verts)
assert all(e.is_manifold for e in bm.edges)
bm.free()
orientation = Counter()
wall_count = 0
for polygon in mesh.polygons:
face = tuple(polygon.vertices)
for a, b in zip(face, (*face[1:], face[0])):
orientation[tuple(sorted((a, b)))] += 1 if a < b else -1
heights = points[list(face), 2]
if heights.min() < 0 < heights.max():
wall_count += 1
assert abs(polygon.normal.z) < 1e-5
assert not polygon.use_smooth
assert wall_count and not any(orientation.values())
assert 'UVMap' in mesh.uv_layers and 'user_probe' in mesh.attributes
finally:
result.to_mesh_clear()
def test_projected_deletes_whole_crossing_faces_and_supports_empty_output(symmetry):
obj, set_value, depth, source_faces = symmetry
set_value('Double Sided', False)
points, faces = evaluated(obj)
assert (points[:, 2] > 0).all()
expected = [face for face in source_faces if np.all(depth[list(face)] < 1.45 - 1e-7)]
assert len(faces) == len(expected)
assert set(np.concatenate(faces)) == set(range(len(points)))
set_value('Reference Depth', .5)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_mode_switch_and_output_axis_preserve_state(symmetry):
obj, set_value, _, _ = symmetry
projected, projected_faces = evaluated(obj)
set_value('Mode', 0)
balloon, balloon_faces = evaluated(obj)
assert len(balloon_faces) != len(projected_faces)
set_value('Mode', 1)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, projected)
assert restored_faces == projected_faces
set_value('Depth Axis', 1)
rotated, rotated_faces = evaluated(obj)
np.testing.assert_allclose(rotated, projected @ np.array(CUTOUT_Z_TO_X)[:3, :3].T, atol=1e-6)
assert rotated_faces == projected_faces
set_value('Depth Axis', 0)
set_value('Mode', 0)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, balloon)
assert restored_faces == balloon_faces
def test_symmetry_asset_has_direct_mode_branches(symmetry):
obj, _, _, _ = symmetry
group = obj.modifiers[0].node_group
assert not any(node.bl_idname in {'GeometryNodeMeshBoolean', 'GeometryNodeGroup',
'GeometryNodeCaptureAttribute'} for node in group.nodes)
selector = next(node for node in group.nodes if node.bl_idname == 'GeometryNodeMenuSwitch'
and node.inputs['Menu'].links[0].from_socket.name == 'Mode')
assert [item.name for item in selector.enum_items] == ['Balloon', 'Projected']
inputs = {item.name: item for item in group.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'INPUT'}
for name in ('Depth Direction', 'Depth Offset'):
assert inputs[name].parent == inputs['Depth Scale'].parent
for name in ('Double Sided', 'Reference Depth', 'Boundary Smooth'):
assert inputs[name].parent.name == 'Options'
assert inputs['Boundary Smooth'].default_value == 1
assert inputs['Mode'].default_value == 'Projected'
assert inputs['Fill Sides'].default_value is True
assert inputs['Depth Offset'].default_value == 0
assert inputs['Depth Offset'].min_value < 0
@pytest.mark.parametrize('mode', [0, 1])
def test_depth_offset_changes_depth_after_direction_without_moving_xy(symmetry, mode):
obj, set_value, _, _ = symmetry
for name, value in [('Mode', mode), ('Depth Direction', (.3, -.2, 1)),
('Reference Depth', 4.), ('Depth Scale', .6)]:
set_value(name, value)
original, faces = evaluated(obj)
for offset in (.2, -.1):
set_value('Depth Offset', offset)
points, changed_faces = evaluated(obj)
assert changed_faces == faces
np.testing.assert_allclose(points[:, :2], original[:, :2], atol=1e-6)
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
delta = abs(points[:, 2]) - abs(original[:, 2])
if mode == 1:
np.testing.assert_allclose(delta, offset, atol=1e-6)
else:
if offset > 0:
assert delta.max() > .1
else:
assert delta.min() < -.05
boundary = abs(original[:, 2]) < 1e-8
np.testing.assert_allclose(points[boundary, 2], 0, atol=1e-8)
set_value('Depth Offset', 0.)
restored, _ = evaluated(obj)
np.testing.assert_allclose(restored, original, atol=1e-6)
if mode == 1:
set_value('Depth Offset', -10.)
points, faces = evaluated(obj)
assert not len(points) and not faces
def test_material_attributes_match_evaluated_faces_and_direction(symmetry):
obj, set_value, _, _ = symmetry
direction = Vector((.3, -.2, 1)).normalized()
set_value('Depth Direction', direction)
for axis in (0, 1):
set_value('Depth Axis', axis)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = result.to_mesh()
try:
points = np.array([v.co[:] for v in mesh.vertices])
if axis:
points = points @ np.array(CUTOUT_Z_TO_X)[:3, :3]
for name in (ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE):
assert mesh.attributes[name].domain == 'FACE'
for polygon in mesh.polygons:
index = polygon.index
rotation = Euler(mesh.attributes[ROTATION_ATTRIBUTE].data[index].vector)
np.testing.assert_allclose(rotation.to_matrix() @ Vector((0, 0, 1)), direction, atol=1e-6)
heights = points[list(polygon.vertices), 2]
kind = 1 if heights.min() > 0 else 2 if heights.max() < 0 else 3
assert mesh.attributes[FACE_ATTRIBUTE].data[index].value == kind
assert mesh.attributes[AXIS_ATTRIBUTE].data[index].value == axis
finally:
result.to_mesh_clear()
set_value('Mode', 0)
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
assert ROTATION_ATTRIBUTE not in result.data.attributes
assert FACE_ATTRIBUTE not in result.data.attributes
def test_fill_sides_only_controls_projected_walls(symmetry):
obj, set_value, _, _ = symmetry
closed, closed_faces = evaluated(obj)
set_value('Fill Sides', False)
points, faces = evaluated(obj)
assert len(faces) < len(closed_faces)
assert cKDTree(points).query(closed)[0].max() < 1e-6
assert cKDTree(points).query(points * (1, 1, -1))[0].max() < 1e-6
assert all(not (points[list(face), 2].min() < 0 < points[list(face), 2].max()) for face in faces)
edges = Counter(tuple(sorted((a, b))) for face in faces
for a, b in zip(face, (*face[1:], face[0])))
assert any(count == 1 for count in edges.values())
set_value('Fill Sides', True)
restored, restored_faces = evaluated(obj)
np.testing.assert_array_equal(restored, closed)
assert restored_faces == closed_faces
for name, value in [('Double Sided', False), ('Mode', 0)]:
set_value(name, value)
before, before_faces = evaluated(obj)
set_value('Fill Sides', False)
after, after_faces = evaluated(obj)
np.testing.assert_array_equal(after, before)
assert after_faces == before_faces
set_value('Fill Sides', True)

View File

@@ -27,7 +27,7 @@ def test_saved_parameter_descriptions_match_definitions():
"Thickness", "Depth Split", "Smooth", "Smooth Weight",
"Boundary Smooth", "Normal Smooth", "Front Inflation",
},
"O Image Depth Symmetry": {"Thickness", "Smooth", "Smooth Weight", "Boundary Smooth"},
"O Image Cutout Symmetry": {"Symmetry Direction", "Depth Offset", "Fill Sides", "Fill Smooth"},
"O Image Depth Panorama": {
"Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight",
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
@@ -72,6 +72,7 @@ def test_saved_parameter_descriptions_match_definitions():
thickness = [item.description for item in sockets if item.name == "Thickness"]
assert len(thickness) == 2 and len(set(thickness)) == 2
if group.name == "O Image Depth Cutout":
assert all(item.default_value == 0 for item in sockets if item.name == "Thickness")
assert not {"Outline Depth Fix", "Center Smooth", "Center Subdivide", "Show Center"} & {
item.name for item in group.interface.items_tree if item.item_type == "SOCKET"
}