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

@@ -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)