feat: Implement weight cutout boundary smoothing for image depth cutout

- 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.
This commit is contained in:
2026-09-14 12:44:20 +08:00
parent 09778c4b48
commit dc000e50b1
53 changed files with 1350 additions and 364 deletions

View File

@@ -5,9 +5,7 @@ import numpy as np
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs
from tests.support.image_objects import _create_cutout_shape, _group_node
def test_image_layer_switches_between_tangent_and_object_space_normal():
@@ -201,28 +199,3 @@ def test_shared_conversion_images_use_blender_duplicate_suffix():
assert first is not second
assert first.name == "Poster_color.png"
assert second.name == "Poster_color.png.001"
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = material_data.material_node_group()
original_inputs = _inputs(group)
image_material = bpy.data.materials.new("Balloon")
image_material.use_nodes = True
image_material.node_tree.nodes.clear()
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
image_layer.node_tree = group
cutout_object._connect_cutout_normal_scale(image_material)
assert _inputs(group) == original_inputs
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
link = next(iter(image_material.node_tree.links))
assert link.from_socket == attribute.outputs["Fac"]
assert link.to_socket == image_layer.inputs["Normal Scale"]

View File

@@ -28,7 +28,7 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain():
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
def test_depth_balloon_plane_calibration_matches_cutout_axes():
def test_depth_symmetry_plane_calibration_matches_cutout_axes():
vertices = []
uv = []
camera_depth = np.empty((3, 3), dtype=np.float32)
@@ -40,7 +40,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
uv.append((column * 0.5, 1.0 - row * 0.5))
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
reference, direction = depth_calibration.depth_balloon_projection_parameters(
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
vertices,
uv,
camera_depth,
@@ -58,7 +58,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
assert np.allclose(direction, expected_direction, atol=1e-6)
def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile():
size = 10
row, column = np.indices((size, size))
camera_depth = np.full((size, size), 2.0, dtype=np.float32)
@@ -70,7 +70,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
vertices.append((float(x), float(size - 1 - y), 0.0))
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
reference, direction = depth_calibration.depth_balloon_projection_parameters(
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
vertices,
uv,
camera_depth,

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_BALLOON", enabled) == "TANGENT"
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "TANGENT"
for shape in ("FLAT", "SOLID"):
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")

View File

@@ -133,7 +133,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
assert [call[2].shape for call in window_manager.pie.calls] == [
"FLAT",
"SOLID",
"DEPTH_BALLOON",
"DEPTH_SYMMETRY",
"DEPTH_SOLID",
]
assert all(call[2].generate_normal for call in window_manager.pie.calls)
@@ -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_BALLOON", False) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "TANGENT"
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_BALLOON", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"

View File

@@ -175,22 +175,10 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
profile_attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
material_tree = result.data.materials[0].node_tree
normal_scale_attribute = next(
node
for node in material_tree.nodes
if node.bl_idname == "ShaderNodeAttribute"
)
assert normal_scale_attribute.attribute_type == "GEOMETRY"
assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes)
image_layer = _group_node(material_data.material_node_group(), material_tree)
normal_scale_link = next(
link
for link in material_tree.links
if link.to_node == image_layer
and link.to_socket == image_layer.inputs["Normal Scale"]
)
assert normal_scale_link.from_node == normal_scale_attribute
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
assert not image_layer.inputs["Normal Scale"].is_linked
assert image_layer.inputs["Normal Scale"].default_value == 1.0
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
@@ -219,7 +207,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute():
profile = np.empty(len(result.data.vertices), dtype=np.float32)
attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
assert any(
assert not any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
@@ -585,10 +573,11 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_uses_metric_plane_inputs():
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_depth_symmetry_uses_metric_plane_inputs(gesture):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Balloon", None)
image = bpy.data.images.new("Depth Symmetry", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Symmetry", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
@@ -619,12 +608,13 @@ def test_depth_balloon_uses_metric_plane_inputs():
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
"DEPTH_SYMMETRY",
0.1,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
selection_values=selection_values,
gesture=gesture,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
@@ -634,6 +624,9 @@ def test_depth_balloon_uses_metric_plane_inputs():
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",

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_balloon import build_image_depth_balloon_group
from tools.nodes.groups.image_depth_symmetry import build_image_depth_symmetry_group
CUTOUT_BUILDERS = {
"FLAT": build_image_cutout_group,
"SOLID": build_image_cutout_group,
"DEPTH_SOLID": build_image_depth_cutout_group,
"DEPTH_BALLOON": build_image_depth_balloon_group,
"DEPTH_SYMMETRY": build_image_depth_symmetry_group,
}

View File

@@ -1,4 +1,4 @@
"""Exercise automatic arrangement without changing geometry or publishing assets."""
"""Exercise automatic arrangement while preserving node programs and geometry."""
import bpy
import numpy as np
@@ -6,12 +6,12 @@ import pytest
from tools.nodes.build import build_node_groups
from tools.nodes.check import GEOMETRY_NAMES
from tools.nodes.check import ASSET_PATH, GEOMETRY_NAMES, SHADER_NAMES
from tools.nodes.arrangement.arrange import arrange_nodes
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
@pytest.fixture(params=sorted(GEOMETRY_NAMES))
@pytest.fixture(params=sorted(GEOMETRY_NAMES | SHADER_NAMES))
def node_group(request):
bpy.ops.wm.read_factory_settings(use_empty=True)
return next(group for group in build_node_groups() if group.name == request.param)
@@ -40,9 +40,18 @@ def _program(node_group):
settings = {
name: _value(getattr(node, name))
for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp",
"interpolation", "interpolation_type", "extension", "pattern_mode")
"interpolation", "interpolation_type", "extension", "pattern_mode",
"space", "uv_map", "attribute_type", "attribute_name", "rotation_type",
"invert", "blend_type", "clamp_factor", "clamp_result", "distribution")
if hasattr(node, name)
}
if hasattr(node, "mapping"):
mapping = node.mapping
settings["mapping"] = (
mapping.extend, mapping.use_clip,
tuple((tuple(point.location), point.handle_type)
for curve in mapping.curves for point in curve.points),
)
inputs = [
(socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None,
[_source(link.from_socket) for link in sorted(
@@ -88,7 +97,7 @@ def _surface(node_group):
pixels[..., 2] = 2 + xx * 0.02
image.pixels.foreach_set(pixels.ravel())
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
"Uniform Scale": 1.0, "Reference Depth": 2.0, "Depth Scale": 0.8,
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
"Smooth": 1, "Normal Smooth": 2, "Depth Split": 0.25}
mode = None
for item in node_group.interface.items_tree:
@@ -119,9 +128,9 @@ def _surface(node_group):
return evaluate
def test_arrangement_preserves_program_and_geometry(node_group):
def test_arrangement_preserves_program_and_evaluation(node_group):
program = _program(node_group)
evaluate = _surface(node_group)
evaluate = _surface(node_group) if node_group.bl_idname == "GeometryNodeTree" else list
geometry = evaluate()
arrange_nodes(node_group)
assert _program(node_group) == program
@@ -142,3 +151,13 @@ def test_arrangement_preserves_program_and_geometry(node_group):
arrange_nodes(node_group)
assert _program(node_group) == program
assert sum(node.bl_idname == "NodeGroupInput" for node in node_group.nodes) == input_count
@pytest.mark.parametrize("name", sorted(SHADER_NAMES))
def test_shader_asset_preserves_definition(name):
bpy.ops.wm.read_factory_settings(use_empty=True)
definition = next(group for group in build_node_groups() if group.name == name)
expected = _program(definition)
with bpy.data.libraries.load(str(ASSET_PATH)) as (_, target):
target.node_groups = [name]
assert _program(target.node_groups[0]) == expected

View File

@@ -8,6 +8,7 @@ import numpy as np
import pytest
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
from tools.nodes.common.boundary_smoothing import cut_boundary_influence
def diagonal_surface(triangles=False):
@@ -51,6 +52,66 @@ def edge_band(faces, seeds, rings=2):
return selected
def test_cutout_split_and_outline_use_distinct_influence():
bpy.ops.wm.read_factory_settings(use_empty=True)
points, faces, split = [], [], []
for side in range(2):
offset = len(points)
for y in range(9):
for x in range(5):
points.append((side * 4 + x, y, 0))
split.append(x == (4 if side == 0 else 0))
faces.extend(
(
offset + y * 5 + x,
offset + y * 5 + x + 1,
offset + (y + 1) * 5 + x + 1,
offset + (y + 1) * 5 + x,
)
for y in range(8)
for x in range(4)
)
mesh = bpy.data.meshes.new("Boundary influence")
mesh.from_pydata(points, [], faces)
mesh.attributes.new("test_split", "BOOLEAN", "POINT").data.foreach_set("value", split)
obj = bpy.data.objects.new("Boundary influence", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Boundary influence", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
split_attribute = group.nodes.new("GeometryNodeInputNamedAttribute")
split_attribute.data_type = "BOOLEAN"
split_attribute.inputs["Name"].default_value = "test_split"
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT", "POINT"
store.inputs["Name"].default_value = "test_influence"
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(cut_boundary_influence(group, split_attribute.outputs["Attribute"]), store.inputs["Value"])
output = group.nodes.new("NodeGroupOutput")
group.links.new(store.outputs["Geometry"], output.inputs["Geometry"])
obj.modifiers.new("Boundary influence", "NODES").node_group = group
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated_obj.to_mesh()
try:
influence = np.array([value.value for value in result.attributes["test_influence"].data])
finally:
evaluated_obj.to_mesh_clear()
np.testing.assert_allclose(influence[4 * 5], 0.1, atol=1e-6)
np.testing.assert_allclose(influence[4 * 5 + 4], 1.0, atol=1e-6)
np.testing.assert_allclose(influence[4], 1.0, atol=1e-6)
def test_cutout_boundary_smooth_defaults_to_four():
obj, _ = surface()
socket = next(
item for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Boundary Smooth"
)
assert socket.default_value == 4
assert (socket.min_value, socket.max_value) == (0, 16)
@pytest.mark.parametrize("triangles", [False, True])
def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
obj, set_value = diagonal_surface(triangles)

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_BALLOON"))
@pytest.mark.parametrize("shape", ("SOLID", "DEPTH_SOLID", "DEPTH_SYMMETRY"))
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)
@@ -55,7 +55,7 @@ def test_cutout_thickness_modes():
assert np.isfinite(z).all() and len(z)
if thickness == 0:
assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7
assert all(abs(item.value - 1) < 1e-7 for item in result.attributes["o_normal_scale"].data)
assert all(abs(item.value) < 1e-7 for item in result.attributes["o_normal_reduction"].data)
else:
back, front = (-0.6, 0.6) if mode == 0 else (-thickness, 0.0)
assert np.isclose(z.min(), back) and np.isclose(z.max(), front)

View File

@@ -162,9 +162,9 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh = evaluated.to_mesh()
try:
attribute = mesh.attributes["o_normal_scale"]
attribute = mesh.attributes["o_normal_reduction"]
assert attribute.domain == "FACE"
weights = [item.value for item in attribute.data]
weights = [1.0 - item.value for item in attribute.data]
assert weights.count(1) == len(obj.data.polygons)
assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons)
finally:

View File

@@ -0,0 +1,239 @@
"""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 Balloon": {"Thickness", "Smooth", "Smooth Weight"},
"O Image Depth Symmetry": {"Thickness", "Smooth", "Smooth Weight", "Boundary Smooth"},
"O Image Depth Panorama": {
"Subdivide", "Depth Scale", "Depth Split", "Smooth", "Smooth Weight",
"Depth Mask", "Mask Threshold", "Boundary Smooth", "Invalid Distance",
@@ -50,7 +50,7 @@ def test_saved_parameter_descriptions_match_definitions():
assert all(item.description for item in sockets), group.name
if group.name in {"O Image Depth Plane", "O Image Depth Cutout"}:
boundary = next(item for item in sockets if item.name == "Boundary Smooth")
assert boundary.default_value == (4 if group.name == "O Image Depth Plane" else 0)
assert boundary.default_value == 4
assert boundary.min_value == 0
assert boundary.max_value == 16
assert boundary.parent.name == "Options"
@@ -141,6 +141,9 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
"UVMap": ("FLOAT2", "CORNER"),
"o_balloon": ("FLOAT", "POINT"),
".o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
"o_depth_face": ("FLOAT", "FACE"),
"o_depth_axis": ("FLOAT", "FACE"),
}
while pending:
group = pending.pop()
@@ -154,7 +157,7 @@ def test_geometry_attribute_contract_includes_nested_groups(source):
if node.bl_idname != "GeometryNodeStoreNamedAttribute":
continue
name = node.inputs["Name"].default_value
if name == "o_normal_scale":
if name == "o_normal_reduction":
assert node.data_type == "FLOAT" and node.domain in {"POINT", "FACE"}
else:
assert name in allowed, (group.name, name)

View File

@@ -0,0 +1,79 @@
"""Render optional geometry weights against ordinary normal map strength."""
import bpy
import numpy as np
import pytest
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from tests.support.materials import image_layer
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
@pytest.mark.parametrize('domain', ['POINT', 'FACE'])
def test_normal_scale_preserves_missing_and_zero_weights(image_layer, engine, domain, tmp_path):
scene = bpy.context.scene
scene.render.engine = engine
scene.cycles.device = 'CPU'
scene.cycles.samples = 1
scene.cycles.use_denoising = False
scene.render.resolution_x, scene.render.resolution_y = 256, 64
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'OPEN_EXR'
scene.render.image_settings.color_depth = '32'
scene.render.filepath = str(tmp_path / 'normal_scale.exr')
bpy.ops.object.camera_add(location=(0, 0, 10))
scene.camera = bpy.context.object
scene.camera.data.type = 'ORTHO'
scene.camera.data.ortho_scale = 8
for mode_index, space in enumerate(('TANGENT', 'OBJECT')):
shared = None
for weight_index, weight in enumerate((None, 0.0, 0.4, 1.0)):
column = mode_index * 4 + weight_index
for reference in (False, True):
bpy.ops.mesh.primitive_plane_add(
size=.8, location=(column - 3.5, .5 if reference else -.5, 0),
)
obj = bpy.context.object
obj.data.uv_layers.active.name = 'UVMap'
if weight is not None and not reference:
attr = obj.data.attributes.new(NORMAL_REDUCTION_ATTRIBUTE_NAME, 'FLOAT', domain)
for item in attr.data:
item.value = 1.0 - weight
if not reference and shared is not None:
obj.data.materials.append(shared)
continue
material = bpy.data.materials.new(f'{space} {weight} {reference}')
material.use_nodes = True
nodes, links = material.node_tree.nodes, material.node_tree.links
nodes.clear()
normal = nodes.new('ShaderNodeNormalMap' if reference else 'ShaderNodeGroup')
color = (.7, .65, .9, 1)
if reference:
normal.space = space
normal.inputs['Color'].default_value = color
normal.inputs['Strength'].default_value = .5 * (1 if weight is None else weight)
else:
normal.node_tree = image_layer
normal.inputs['Object Space'].default_value = space == 'OBJECT'
normal.inputs['Normal'].default_value = color
normal.inputs['Normal Scale'].default_value = .5
shared = material
encode = nodes.new('ShaderNodeVectorMath')
encode.operation = 'MULTIPLY_ADD'
encode.inputs[1].default_value = (.5, .5, .5)
encode.inputs[2].default_value = (.5, .5, .5)
links.new(normal.outputs['Normal'], encode.inputs[0])
emission = nodes.new('ShaderNodeEmission')
output = nodes.new('ShaderNodeOutputMaterial')
links.new(encode.outputs[0], emission.inputs['Color'])
links.new(emission.outputs[0], output.inputs['Surface'])
obj.data.materials.append(material)
bpy.ops.render.render(write_still=True)
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(rendered.pixels[:]).reshape(64, 256, 4)
columns = np.arange(8) * 32 + 16
np.testing.assert_allclose(pixels[16, columns, :3], pixels[48, columns, :3], atol=2e-4)
assert np.linalg.norm(pixels[16, 16, :3] - pixels[16, 48, :3]) > .01
finally:
bpy.data.images.remove(rendered)

View File

@@ -0,0 +1,86 @@
"""Render the image material's object normals from persistent geometry attributes."""
import bpy
import numpy as np
import pytest
from mathutils import Euler, Vector, Matrix
from tests.support.materials import image_layer
from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
@pytest.mark.parametrize('image_layer', ['definition', 'asset'], indirect=True)
@pytest.mark.parametrize('engine', ['CYCLES', 'BLENDER_EEVEE_NEXT'])
def test_object_normals_follow_rotation_mirror_axis_and_wall_mask(image_layer, engine, tmp_path):
scene = bpy.context.scene
scene.render.engine = engine
scene.cycles.device = 'CPU'
scene.cycles.samples = 1
scene.cycles.use_denoising = False
scene.render.resolution_x, scene.render.resolution_y = 224, 32
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'OPEN_EXR'
scene.render.image_settings.color_depth = '32'
scene.render.filepath = str(tmp_path / 'normals.exr')
bpy.ops.object.camera_add(location=(0, 0, 10))
camera = bpy.context.object
camera.data.type = 'ORTHO'
camera.data.ortho_scale = 7
scene.camera = camera
normal = Vector((.2, .3, .9)).normalized()
rotation = Euler((.25, -.4, .15))
expected = []
# Missing attributes, front, back, wall, final axis, zero strength, tangent.
cases = [(0, 0, 1, True), (1, 0, 1, True), (2, 0, 1, True),
(3, 0, 1, True), (1, 1, 1, True), (1, 0, 0, True), (1, 0, 1, False)]
for index, (face, axis, strength, object_space) in enumerate(cases):
bpy.ops.mesh.primitive_plane_add(size=.9, location=(index - 3, 0, 0))
obj = bpy.context.object
obj.data.uv_layers.active.name = 'UVMap'
if face:
attr = obj.data.attributes.new(ROTATION_ATTRIBUTE, 'FLOAT_VECTOR', 'FACE')
for item in attr.data:
item.vector = rotation
for name, value in ((FACE_ATTRIBUTE, face), (AXIS_ATTRIBUTE, axis)):
attr = obj.data.attributes.new(name, 'FLOAT', 'FACE')
for item in attr.data:
item.value = value
material = bpy.data.materials.new(f'Normal {index}')
material.use_nodes = True
nodes, links = material.node_tree.nodes, material.node_tree.links
nodes.clear()
layer = nodes.new('ShaderNodeGroup')
layer.node_tree = image_layer
layer.inputs['Object Space'].default_value = object_space
layer.inputs['Normal Scale'].default_value = strength
layer.inputs['Normal'].default_value = (*((np.array(normal) + 1) / 2), 1)
encode = nodes.new('ShaderNodeVectorMath')
encode.operation = 'MULTIPLY_ADD'
encode.inputs[1].default_value = (.5, .5, .5)
encode.inputs[2].default_value = (.5, .5, .5)
links.new(layer.outputs['Normal'], encode.inputs[0])
emission = nodes.new('ShaderNodeEmission')
output = nodes.new('ShaderNodeOutputMaterial')
links.new(encode.outputs[0], emission.inputs['Color'])
links.new(emission.outputs[0], output.inputs['Surface'])
obj.data.materials.append(material)
value = normal.copy()
if face and object_space:
value = rotation.to_matrix().transposed() @ value
if face == 2:
value.z *= -1
if axis:
value = Matrix(CUTOUT_Z_TO_X).to_3x3() @ value
if strength == 0 or face == 3:
value = Vector((0, 0, 1))
expected.append((np.array(value) + 1) / 2)
bpy.ops.render.render(write_still=True)
rendered = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(rendered.pixels[:]).reshape(32, 224, 4)
np.testing.assert_allclose(
pixels[16, np.arange(7) * 32 + 16, :3], expected,
atol=1e-3 if engine == 'BLENDER_EEVEE_NEXT' else 2e-4,
)
finally:
bpy.data.images.remove(rendered)