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:
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
239
tests/tools/nodes/test_depth_symmetry.py
Normal file
239
tests/tools/nodes/test_depth_symmetry.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
79
tests/tools/nodes/test_normal_scale.py
Normal file
79
tests/tools/nodes/test_normal_scale.py
Normal 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)
|
||||
86
tests/tools/nodes/test_object_normal_rotation.py
Normal file
86
tests/tools/nodes/test_object_normal_rotation.py
Normal 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)
|
||||
Reference in New Issue
Block a user