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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
1
tests/tools/nodes/fixtures/projected_symmetry.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
174
tests/tools/nodes/test_cutout_symmetry.py
Normal file
174
tests/tools/nodes/test_cutout_symmetry.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user