- Added a new proposal for boundary smoothing in the weight cutout process, differentiating between Split edges and Outline smoothing requirements. - Introduced a new capability `cutout-boundary-smoothing` to control smoothing strength and default values. - Updated specifications to define the behavior of boundary-specific smoothing strength and default settings. - Created tasks to verify the implementation of boundary behavior and asset updates. - Developed comprehensive tests for depth symmetry and normal scaling to ensure expected behavior and performance. - Introduced new attributes for normal mapping to facilitate persistent face attributes sharing between depth symmetry and image materials. - Built a new node group for O Image Depth Symmetry, integrating the new smoothing capabilities and ensuring compatibility with existing workflows.
240 lines
11 KiB
Python
240 lines
11 KiB
Python
"""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)
|