- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
255 lines
9.5 KiB
Python
255 lines
9.5 KiB
Python
from types import SimpleNamespace
|
|
import bpy
|
|
import numpy as np
|
|
|
|
|
|
from anyimage.common import image as image_data
|
|
from anyimage.common import material as material_data
|
|
from tests.support.image_objects import _create_cutout_shape, _group_node
|
|
|
|
|
|
def test_generated_materials_use_implicit_uv_and_readable_layout():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source = SimpleNamespace(name="Source")
|
|
color = bpy.data.images.new("Color", width=4, height=2)
|
|
normal = bpy.data.images.new("Normal", width=4, height=2)
|
|
depth = bpy.data.images.new("Depth", width=4, height=2)
|
|
|
|
material = material_data.create_image_material(source, color, normal_image=normal)
|
|
nodes = material.node_tree.nodes
|
|
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
|
|
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
|
|
texture_location = lambda image: tuple(next(
|
|
node for node in nodes if node.type == "TEX_IMAGE" and node.image == image
|
|
).location)
|
|
assert texture_location(color) == (-700.0, 300.0)
|
|
assert texture_location(normal) == (-700.0, 0.0)
|
|
assert tuple(next(node for node in nodes if node.type == "GROUP").location) == (-300.0, 300.0)
|
|
assert tuple(next(node for node in nodes if node.type == "BSDF_PRINCIPLED").location) == (100.0, 300.0)
|
|
assert tuple(next(node for node in nodes if node.type == "OUTPUT_MATERIAL").location) == (500.0, 300.0)
|
|
|
|
depth_material = material_data.create_image_material(
|
|
source,
|
|
color,
|
|
displacement_image=depth,
|
|
displacement_midlevel=0.5,
|
|
displacement_scale=2.0,
|
|
)
|
|
nodes = depth_material.node_tree.nodes
|
|
assert not any(node.bl_idname == "ShaderNodeUVMap" for node in nodes)
|
|
assert all(not node.inputs["Vector"].is_linked for node in nodes if node.type == "TEX_IMAGE")
|
|
depth_texture = next(node for node in nodes if node.type == "TEX_IMAGE" and node.image == depth)
|
|
assert tuple(depth_texture.location) == (-1200.0, -300.0)
|
|
math_locations = {node.operation: tuple(node.location) for node in nodes if node.type == "MATH"}
|
|
assert math_locations == {
|
|
"SUBTRACT": (-950.0, -300.0),
|
|
"MULTIPLY": (-700.0, -300.0),
|
|
}
|
|
|
|
panorama = material_data.create_image_material(
|
|
source, color, shadeless=True, texture_extension="REPEAT")
|
|
nodes = panorama.node_tree.nodes
|
|
assert not any(node.bl_idname in {
|
|
"ShaderNodeUVMap", "ShaderNodeSeparateXYZ", "ShaderNodeClamp", "ShaderNodeCombineXYZ",
|
|
} for node in nodes)
|
|
texture = next(node for node in nodes if node.type == "TEX_IMAGE")
|
|
shader = next(node for node in nodes if node.type == "GROUP")
|
|
output = next(node for node in nodes if node.type == "OUTPUT_MATERIAL")
|
|
assert not texture.inputs["Vector"].is_linked
|
|
assert texture.extension == "REPEAT"
|
|
assert (tuple(texture.location), tuple(shader.location), tuple(output.location)) == (
|
|
(-700.0, 300.0), (-300.0, 300.0), (100.0, 300.0))
|
|
|
|
|
|
def test_image_layer_switches_between_tangent_and_object_space_normal():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
|
|
group = material_data.material_node_group()
|
|
normal = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Normal"
|
|
)
|
|
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
|
|
bump_scale = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Bump Scale"
|
|
)
|
|
assert bump_scale.default_value == 0.0
|
|
normal_scale = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Normal Scale"
|
|
)
|
|
assert normal_scale.default_value == 1.0
|
|
object_space = next(
|
|
item
|
|
for item in group.interface.items_tree
|
|
if item.item_type == "SOCKET"
|
|
and item.in_out == "INPUT"
|
|
and item.name == "Object Space"
|
|
)
|
|
assert object_space.default_value is False
|
|
normal_maps = {
|
|
node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
|
}
|
|
assert normal_maps == {"TANGENT", "OBJECT"}
|
|
assert all(
|
|
not node.label
|
|
for node in group.nodes
|
|
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
|
|
)
|
|
|
|
color = bpy.data.images.new("Color", width=2, height=2)
|
|
normal_image = bpy.data.images.new("Normal", width=2, height=2)
|
|
object_material = material_data.create_image_material(
|
|
SimpleNamespace(name="Object Normal"),
|
|
color,
|
|
1.0,
|
|
normal_image=normal_image,
|
|
normal_space="OBJECT",
|
|
)
|
|
layer = _group_node(group, object_material.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is True
|
|
|
|
tangent_material = material_data.create_image_material(
|
|
SimpleNamespace(name="Tangent Normal"),
|
|
color,
|
|
1.0,
|
|
normal_image=normal_image,
|
|
)
|
|
layer = _group_node(group, tangent_material.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is False
|
|
reused = material_data.material_node_group()
|
|
assert {
|
|
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
|
|
} == {"TANGENT", "OBJECT"}
|
|
|
|
|
|
def test_cutout_material_uses_ior_1_2():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True)
|
|
source = bpy.data.objects.new("Cutout", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
source.empty_display_size = 2.0
|
|
bpy.context.collection.objects.link(source)
|
|
|
|
result = _create_cutout_shape(source, "FLAT", 0.1)
|
|
|
|
shader = next(
|
|
node
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
)
|
|
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
|
|
image_layer = _group_node(
|
|
material_data.material_node_group(),
|
|
result.data.materials[0].node_tree,
|
|
)
|
|
ior_level_link = next(
|
|
link
|
|
for link in result.data.materials[0].node_tree.links
|
|
if link.to_node == shader
|
|
and link.to_socket == shader.inputs["Specular IOR Level"]
|
|
)
|
|
assert ior_level_link.from_node == image_layer
|
|
assert ior_level_link.from_socket == image_layer.outputs["Color"]
|
|
|
|
|
|
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
|
|
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
|
|
|
|
material = material_data.create_image_material(source.data, image)
|
|
shader = next(
|
|
node
|
|
for node in material.node_tree.nodes
|
|
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
)
|
|
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
|
|
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
|
|
assert image_layer.inputs["Bump Scale"].default_value == 0.0
|
|
assert any(
|
|
link.from_node == image_layer
|
|
and link.from_socket == image_layer.outputs["Color"]
|
|
and link.to_node == shader
|
|
and link.to_socket == shader.inputs["Specular IOR Level"]
|
|
for link in material.node_tree.links
|
|
)
|
|
|
|
shadeless = material_data.create_image_material(
|
|
source.data,
|
|
image,
|
|
0.0,
|
|
shadeless=True,
|
|
)
|
|
assert not any(
|
|
node.bl_idname == "ShaderNodeBsdfPrincipled"
|
|
for node in shadeless.node_tree.nodes
|
|
)
|
|
|
|
|
|
def test_cutout_material_uses_flat_tangent_normal():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
source = bpy.data.objects.new("Source", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = bpy.data.images.new("Source", width=2, height=2)
|
|
color = bpy.data.images.new("Color", width=2, height=2)
|
|
normal = bpy.data.images.new("Normal", width=1, height=1)
|
|
|
|
result = material_data.create_image_material(
|
|
source.data,
|
|
color,
|
|
0.0,
|
|
normal_image=normal,
|
|
scene=bpy.context.scene,
|
|
)
|
|
|
|
layer = _group_node(material_data.material_node_group(), result.node_tree)
|
|
assert layer.inputs["Object Space"].default_value is False
|
|
|
|
|
|
def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
|
|
from PIL import Image
|
|
|
|
path = tmp_path / "normal.png"
|
|
Image.new("RGB", (1, 1), (128, 128, 255)).save(path)
|
|
source = SimpleNamespace(data=SimpleNamespace(name="Poster.png"))
|
|
|
|
image = image_data.load_normal_result_image(path, source)
|
|
|
|
assert image.name == "Poster_normal.png"
|
|
assert image.colorspace_settings.name == "Non-Color"
|
|
assert image.packed_file is not None
|
|
|
|
|
|
def test_shared_conversion_images_use_blender_duplicate_suffix():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Poster", width=16, height=16)
|
|
source = bpy.data.objects.new("Poster", None)
|
|
source.empty_display_type = "IMAGE"
|
|
source.data = image
|
|
bpy.context.collection.objects.link(source)
|
|
|
|
other = source.copy()
|
|
bpy.context.collection.objects.link(other)
|
|
from tests.support.color_image import color_image
|
|
first = color_image(source.data)
|
|
second = color_image(source.data)
|
|
|
|
assert first is not image
|
|
assert second is not image
|
|
assert first is not second
|
|
assert first.name == "Poster_color.png"
|
|
assert second.name == "Poster_color.png.001"
|