feat(node_assets): add image plane geometry nodes and validation scripts
- Implemented `image_plane.py` to build the Image Plane Geometry Nodes group with customizable parameters for geometry, subdivide, and thickness. - Created `image_plane_validation.py` to validate the functionality of the image plane, ensuring correct geometry generation and material assignment. - Developed `material_layers.py` to construct shader node groups for image materials, including depth and normal handling. - Authored `plane_layouts.py` to define node arrangements for image geometry assets. - Introduced `shadeless.py` to create a shadeless shader node group that displays color without scene lighting while preserving shadows and reflections.
This commit is contained in:
231
tests/test_image_materials.py
Normal file
231
tests/test_image_materials.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
|
||||
from anyimage.common import image as image_data
|
||||
from anyimage.common import material as material_data
|
||||
from anyimage.operators.convert_to_plane import object as plane_object
|
||||
from anyimage.operators.cutout_tool import object as cutout_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from image_object_test_support import _create_cutout_shape, _group_node, _inputs
|
||||
|
||||
|
||||
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(data=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(data=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", 8)
|
||||
|
||||
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, image, 0.0)
|
||||
shader = next(
|
||||
node
|
||||
for node in material.node_tree.nodes
|
||||
if node.bl_idname == "ShaderNodeBsdfPrincipled"
|
||||
)
|
||||
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
|
||||
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,
|
||||
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,
|
||||
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_converted_color_images_are_new_and_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)
|
||||
|
||||
first = plane_object.create_color_image(source)
|
||||
second = plane_object.create_color_image(source)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user