Files
AnyImage/tests/test_mesh_blender.py
nan 377eee43f5 Refactor and enhance geometry node groups for depth plane and material layers
- Updated the depth plane node group to load from an asset and simplified the interface.
- Created a new build script for depth plane geometry nodes.
- Introduced material layer groups for image and depth materials with appropriate sockets.
- Added functionality to connect balloon normal scale to the image material.
- Enhanced tests to validate the new node groups and their connections.
- Ensured that production code does not create node groups directly.
2026-08-31 12:25:23 +08:00

1287 lines
44 KiB
Python

import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
from mathutils import Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.operators.common import image_data as common_image
from anyimage.operators.common import depth_data as common_depth
from anyimage.operators.common import mesh_result as material
from anyimage.operators.common.image_region import (
RegionMask,
RegionPath,
rasterize_region_path,
)
from anyimage.operators.convert_to_plane import nodes as plane_nodes
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import main as cutout_main
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as shapes
from anyimage.server.geometry.depth_texture import write_vector_depth_texture
def _inputs(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def _group_node(group, node_tree):
return next(
node
for node in node_tree.nodes
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group
)
def _create_cutout_shape(
source,
shape,
detail,
*,
bounds=None,
depth_image=None,
depth_metadata=None,
region_values=None,
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
if region_values is None:
region_values = np.ones((bottom - top, right - left), dtype=np.float32)
region_mask = RegionMask(region_values, bounds)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
detail,
region_mask,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
detail,
region_mask,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
)
def _depth_image(points, valid, name="Depth"):
points = np.asarray(points, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
pixels = np.zeros((height, width, 4), dtype=np.float32)
pixels[valid, :3] = points[valid]
pixels[..., 3] = valid
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def _depth_metadata(intrinsics, image_size, reference_depth):
return {
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
"image_size": tuple(int(value) for value in image_size),
"reference_depth": float(reference_depth),
}
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=2)
source = bpy.data.objects.new("Source", None)
source.data = source_image
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
valid = np.asarray(((True, False), (True, True)))
path = tmp_path / "vector-depth.exr"
write_vector_depth_texture(points, valid, path)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = common_depth.load_depth_result_image(path, source, metadata)
packed = np.empty(16, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[valid, :3], points[valid])
assert np.array_equal(packed[..., 3] > 0.5, valid)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
bpy.ops.wm.read_factory_settings(use_empty=True)
plane = plane_nodes.plane_node_group("PLANE")
depth_plane = plane_nodes.plane_node_group("DEPTH_PLANE")
cutout = cutout_object.load_cutout_node_group()
assert plane.name == "O Mesh Plane"
assert cutout.name == shapes.CUTOUT_NODE_GROUP_NAME
assert all(
cutout_object.load_cutout_node_group() == cutout
for shape in shapes.CUTOUT_SHAPES
)
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Amount",
"Reference Depth",
"Uniform Scale",
"Depth Image",
"Material",
]
assert _inputs(cutout) == [
"Geometry",
"Shape",
"Thickness",
"Depth Scale",
"Double Sided",
"Smooth Iterations",
"Cleanup",
"Smooth",
"Base Plane Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
panels = [item for item in cutout.interface.items_tree if item.item_type == "PANEL"]
assert [panel.name for panel in panels] == ["Options", "Data"]
assert [item.name for item in panels[0].interface_items] == [
"Cleanup",
"Smooth",
"Base Plane Depth",
"Depth Direction",
]
assert [item.name for item in panels[1].interface_items] == [
"Uniform Scale",
"Depth Image",
]
assert all(panel.default_closed for panel in panels)
shape_socket = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Shape"
)
assert shape_socket.socket_type == "NodeSocketMenu"
assert not shape_socket.menu_expanded
menu_switch = next(
node for node in cutout.nodes if node.bl_idname == "GeometryNodeMenuSwitch"
)
labels = {
shape: shape.replace("_", " ").title()
for shape in shapes.CUTOUT_NODE_GROUP_NAMES
}
assert [item.name for item in menu_switch.enum_items] == list(labels.values())
assert any(
link.from_socket.name == "Shape"
and link.to_node == menu_switch
and link.to_socket.name == "Menu"
for link in cutout.links
)
child_groups = {
shape: bpy.data.node_groups[name]
for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items()
}
assert cutout.is_modifier
assert all(not group.is_modifier for group in child_groups.values())
child_nodes = {
node.node_tree: node
for node in cutout.nodes
if node.bl_idname == "GeometryNodeGroup"
}
assert set(child_nodes) == set(child_groups.values())
for shape, group in child_groups.items():
label = labels[shape]
assert any(
link.from_node == child_nodes[group]
and link.to_node == menu_switch
and link.to_socket.name == label
for link in cutout.links
)
assert _inputs(child_groups["SURFACE"]) == ["Geometry", "Thickness"]
assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"]
assert _inputs(child_groups["DEPTH_BALLOON"]) == [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Base Plane Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
assert _inputs(child_groups["DEPTH_SURFACE"]) == [
"Geometry",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Uniform Scale",
"Depth Image",
]
dependencies = {}
for link in cutout.links:
if link.from_node.bl_idname != "NodeGroupInput":
continue
if link.to_node.bl_idname != "GeometryNodeGroup":
continue
shape = next(
shape
for shape, group in child_groups.items()
if group == link.to_node.node_tree
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
"Thickness": set(shapes.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
}
assert (
sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in cutout.nodes)
== 1
)
assert not any(
node.bl_idname == "GeometryNodeAttributeStatistic" for node in cutout.nodes
)
assert all(
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
for group in child_groups.values()
)
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes)
assert all(
not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes)
for group in child_groups.values()
)
repeat_counts = {
shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes)
for shape, group in child_groups.items()
}
assert repeat_counts == {
"SURFACE": 1,
"BALLOON": 0,
"DEPTH_BALLOON": 0,
"DEPTH_SURFACE": 1,
}
assert (
sum(node.bl_idname == "GeometryNodeRepeatInput" for node in cutout.nodes) == 1
)
smooth = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth"
)
iterations = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth Iterations"
)
cleanup = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Cleanup"
)
assert cleanup.default_value == 0.0
assert cleanup.subtype == "NONE"
cleanup_scale = next(
link.to_node
for link in cutout.links
if link.from_socket.name == "Cleanup"
and link.from_node.bl_idname == "NodeGroupInput"
)
assert cleanup_scale.bl_idname == "ShaderNodeMath"
assert cleanup_scale.operation == "MULTIPLY"
assert np.isclose(cleanup_scale.inputs[1].default_value, 0.01)
assert smooth.default_value == 1.0
assert smooth.subtype == "FACTOR"
assert iterations.default_value == 0
assert iterations.min_value == 0
assert getattr(iterations.parent, "name", "") == ""
thickness = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Thickness"
)
assert thickness.subtype == "NONE"
assert thickness.default_value == 0.0
for group in child_groups.values():
extrudes = [
node for node in group.nodes if node.bl_idname == "GeometryNodeExtrudeMesh"
]
assert extrudes
assert all(not node.inputs["Individual"].default_value for node in extrudes)
assert all(
node.inputs["Offset Scale"].default_value == 1.0 for node in extrudes
)
for group in (
child_groups["SURFACE"],
child_groups["DEPTH_SURFACE"],
depth_plane,
):
thickness = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Thickness"
)
assert thickness.subtype == "DISTANCE"
for group in (
child_groups["BALLOON"],
child_groups["DEPTH_BALLOON"],
):
thickness = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Thickness"
)
assert thickness.subtype == "NONE"
assert thickness.default_value == 1.0
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert material.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = material.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.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.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.material_node_group()
assert {
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
} == {"TANGENT", "OBJECT"}
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source", width=4096, height=2048)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
material = bpy.data.materials.new("Source.material")
result = plane_object.create_plane_object(
bpy.context,
source,
2,
material,
)
assert result["anyimage_mesh_shape"] == "PLANE"
assert len(result.data.vertices) == 4
assert len(result.data.polygons) == 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert shapes.BALLOON_ATTRIBUTE_NAME not in result.data.attributes
assert result.modifiers[0].node_group.name == "O Mesh Plane"
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert len(evaluated_mesh.polygons) > 0
finally:
evaluated.to_mesh_clear()
def test_balloon_build_starts_from_the_unified_single_sheet():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Balloon Sheet", 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,
"BALLOON",
8,
)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
profile_attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME]
assert profile_attribute.data_type == "FLOAT"
assert profile_attribute.domain == "POINT"
profile = np.empty(len(result.data.vertices), dtype=np.float32)
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 == shapes.BALLOON_ATTRIBUTE_NAME
image_layer = _group_node(material.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 result.modifiers[0].node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
def test_teddy_preference_writes_the_shared_balloon_attribute():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Teddy Sheet", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
with patch.object(
cutout_geometry,
"addon_preferences",
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
):
result = _create_cutout_shape(
source,
"SURFACE",
8,
)
attribute = result.data.attributes[shapes.BALLOON_ATTRIBUTE_NAME]
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 not any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
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, "SURFACE", 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)
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.create_image_material(
source,
color,
0.0,
normal_image=normal,
scene=bpy.context.scene,
)
layer = _group_node(material.material_node_group(), result.node_tree)
assert layer.inputs["Object Space"].default_value is False
def test_depth_surface_protected_smooth_evaluates():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
valid = np.ones((size, size), dtype=bool)
depth_image = _depth_image(points, valid)
depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0)
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
assert (
modifier[material.modifier_input_identifier(modifier.node_group, "Shape")]
== shapes.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 8),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[material.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert (
modifier[material.modifier_input_identifier(modifier.node_group, "Smooth")]
== 1.0
)
assert (
modifier[
material.modifier_input_identifier(modifier.node_group, "Smooth Iterations")
]
== 0
)
reference_id = material.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
)
assert modifier[reference_id] == 8.0
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Smooth"),
0.0,
)
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices])
baseline_min = baseline_x.min()
baseline_max = baseline_x.max()
finally:
evaluated.to_mesh_clear()
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert len(evaluated_mesh.polygons) > 0
x = np.asarray([vertex.co.x for vertex in evaluated_mesh.vertices])
assert np.isclose(x.min(), baseline_min, atol=1e-5)
assert np.isclose(x.max(), baseline_max + 1.0, atol=1e-5)
finally:
evaluated.to_mesh_clear()
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Smooth Iterations"),
4,
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Smooth"),
1.0,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
internal_mesh = evaluated.to_mesh()
try:
internal_positions = np.asarray(
[tuple(vertex.co) for vertex in internal_mesh.vertices]
)
finally:
evaluated.to_mesh_clear()
assert internal_positions.ndim == 2
assert internal_positions.shape[1] == 3
assert np.isfinite(internal_positions).all()
def _evaluated_positions(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
return np.asarray([tuple(vertex.co) for vertex in result.vertices])
finally:
evaluated.to_mesh_clear()
def _add_cutout_modifier(obj, shape):
group = cutout_object.load_cutout_node_group()
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
material.set_modifier_input(
modifier,
material.modifier_input_identifier(group, "Shape"),
shapes.CUTOUT_SHAPE_INDICES[shape],
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(group, "Cleanup"),
0.0,
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(group, "Smooth"),
0.0,
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(group, "Smooth Iterations"),
1,
)
return modifier
def test_cutout_thickness_keeps_one_connected_shell():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
faces = []
for z in range(2):
for y in range(2):
a = z * 3 + y
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
data = bpy.data.meshes.new("Connected Cutout")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Connected Cutout", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "SURFACE")
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 18
assert len(result.polygons) == 24
x = np.asarray([vertex.co.x for vertex in result.vertices])
assert np.isclose(x.min(), -0.5, atol=1e-6)
assert np.isclose(x.max(), 0.5, atol=1e-6)
front_faces = [
polygon for polygon in result.polygons if polygon.normal.x < -0.9
]
back_faces = [polygon for polygon in result.polygons if polygon.normal.x > 0.9]
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
]
assert len(front_faces) == 8
assert len(back_faces) == 8
assert len(side_faces) == 8
center = Vector((0.0, 1.0, 1.0))
for polygon in side_faces:
radial = polygon.center - center
assert polygon.normal.dot(radial) > 0.0
finally:
evaluated.to_mesh_clear()
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
2.4,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
]
assert len(side_faces) == 24
for polygon in side_faces:
coordinates = [result.vertices[index].co for index in polygon.vertices]
edge_lengths = [
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
for index, coordinate in enumerate(coordinates)
]
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
finally:
evaluated.to_mesh_clear()
def test_balloon_uses_injected_profile_at_default_height():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
faces = []
for z in range(2):
for y in range(2):
a = z * 3 + y
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Balloon Profile")
data.from_pydata(vertices, (), faces)
cutout_object._assign_float_point_attribute(
data,
shapes.BALLOON_ATTRIBUTE_NAME,
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
obj = bpy.data.objects.new("Balloon Profile", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "BALLOON")
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
bpy.context.view_layer.update()
positions = _evaluated_positions(obj)
assert np.isclose(positions[:, 0].min(), -1.0, atol=1e-6)
assert np.isclose(positions[:, 0].max(), 1.0, atol=1e-6)
assert np.isclose(
positions[:, 0].min(),
-positions[:, 0].max(),
atol=1e-5,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
baseline_face_count = len(result.polygons)
finally:
evaluated.to_mesh_clear()
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.polygons) == baseline_face_count
x = np.asarray([vertex.co.x for vertex in result.vertices])
assert np.isclose(x.min(), -2.0, atol=1e-6)
assert np.isclose(x.max(), 2.0, atol=1e-6)
finally:
evaluated.to_mesh_clear()
material.set_modifier_input(
modifier,
material.modifier_input_identifier(modifier.node_group, "Thickness"),
0.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_positions = _evaluated_positions(obj)
assert len(zero_positions) == len(vertices)
assert np.allclose(zero_positions[:, 0], 0.0, atol=1e-6)
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
faces = []
for z in range(2):
for y in range(2):
a = z * 3 + y
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
for island in range(12):
start = len(vertices)
y = 5.0 + island * 0.1
vertices.extend(((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01)))
faces.append((start, start + 1, start + 2))
data = bpy.data.meshes.new("Cleanup")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Cleanup", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "SURFACE")
cleanup_id = material.modifier_input_identifier(modifier.node_group, "Cleanup")
material.set_modifier_input(modifier, cleanup_id, 0.0)
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 45
material.set_modifier_input(modifier, cleanup_id, 0.1)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_uses_metric_plane_inputs_without_controller():
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)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
crop_bounds = (50, 20, 90, 80)
local_bounds = cutout_geometry.crop_plane_bounds(
source,
crop_bounds,
(100, 100),
)
y, x = np.indices((61, 41), dtype=np.float32)
normalized_x = x / 40.0 * 2.0 - 1.0
normalized_y = y / 60.0 * 2.0 - 1.0
points = np.zeros((61, 41, 3), dtype=np.float32)
points[..., 0] = normalized_x
points[..., 1] = normalized_y
points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1
points[27:34, 17:24, 2] -= 0.25
intrinsics = np.asarray(
((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
valid = np.ones((61, 41), dtype=bool)
z_depth = np.repeat(points[..., 2, None], 3, axis=2)
depth_image = _depth_image(z_depth, valid)
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
region_values = np.ones((60, 40), dtype=np.float32)
region_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
region_values=region_values,
)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
assert "o_balloon_depth" not in result.data.attributes
assert "o_depth_offset" not in result.data.attributes
assert not result.children
assert {obj.name for obj in bpy.context.scene.objects} == {
source.name,
result.name,
}
modifier = result.modifiers[0]
depth_image_id = material.modifier_input_identifier(
modifier.node_group,
"Depth Image",
)
assert isinstance(modifier[depth_image_id], bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
crop_center = Vector(
(
(local_bounds[0] + local_bounds[1]) * 0.5,
(local_bounds[2] + local_bounds[3]) * 0.5,
0.0,
)
)
expected_origin = source.matrix_world @ crop_center
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(expected_origin),
atol=1e-5,
)
depth_reference_id = material.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = material.modifier_input_identifier(
modifier.node_group,
"Depth Direction",
)
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
assert not np.allclose(direction, (0.0, 0.0, 1.0))
uniform_scale_id = material.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier[uniform_scale_id] > 0.0
thickness_id = material.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
smooth_id = material.modifier_input_identifier(
modifier.node_group,
"Smooth",
)
assert modifier[smooth_id] == 1.0
material.set_modifier_input(modifier, smooth_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_one = positions[nearest_center, 0].min()
material.set_modifier_input(modifier, thickness_id, 2.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_two = positions[nearest_center, 0].min()
assert np.isclose(
depth_at_thickness_one,
depth_at_thickness_two,
atol=1e-5,
)
material.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
material.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.2,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
after_positions = _evaluated_positions(result)
assert np.isclose(
(after_positions[:, 0] - before_positions[:, 0]).min(),
-0.2,
atol=1e-5,
)
depth_scale_id = material.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
material.set_modifier_input(modifier, depth_scale_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_before = _evaluated_positions(result)
material.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.15,
)
material.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_scale_after = _evaluated_positions(result)
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
material.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = material.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
material.set_modifier_input(modifier, double_sided_id, True)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
mirrored = _evaluated_positions(result)
layers = {}
for x, y, z in mirrored:
layers.setdefault((round(float(y), 6), round(float(z), 6)), []).append(x)
assert layers
for x_values in layers.values():
if len(x_values) == 1:
assert np.isclose(x_values[0], 0.0, atol=1e-5)
continue
assert np.isclose(
min(x_values) + max(x_values),
0.0,
atol=1e-5,
)
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 = common_image.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_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
top_down = np.ones((4, 4, 4), dtype=np.float32)
top_down[1, 1, 3] = 25.0 / 255.0
top_down[1, 2, 3] = 26.0 / 255.0
region_mask = rasterize_region_path(
(4, 4),
RegionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
fill_rule="NONZERO",
),
source_alpha=top_down[:, :, 3],
alpha_threshold=0.1,
)
selection = region_mask.full_values((4, 4))
assert selection[1, 1] == 0.0
assert selection[1, 2] > 0.1
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
source_rgba = np.ones((48, 64, 4), dtype=np.float32)
source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None]
image.pixels.foreach_set(np.flipud(source_rgba).ravel())
source = bpy.data.objects.new("Local Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
source.select_set(True)
bpy.context.view_layer.objects.active = source
region_path = RegionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
fill_rule="NONZERO",
)
source_bounds = common_image.image_empty_bounds(source)
with patch.object(
common_image,
"image_rgba",
wraps=common_image.image_rgba,
) as read_rgba:
rgba = common_image.image_rgba(image)
region_mask = rasterize_region_path(
tuple(image.size),
region_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
cutout_main.create_cutout_shape(
bpy.context,
source,
"SURFACE",
16,
region_mask,
source_rgba=rgba,
)
read_rgba.assert_called_once_with(image)
result = bpy.context.view_layer.objects.active
assert result is not source
assert result.type == "MESH"
assert result["anyimage_mesh_shape"] == "SURFACE"
modifier = result.modifiers[0]
assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
assert (
modifier[material.modifier_input_identifier(modifier.node_group, "Shape")]
== shapes.CUTOUT_SHAPE_INDICES["SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 16),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[material.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert (
modifier[material.modifier_input_identifier(modifier.node_group, "Smooth")]
== 1.0
)
assert (
modifier[
material.modifier_input_identifier(modifier.node_group, "Smooth Iterations")
]
== 0
)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
assert shapes.BALLOON_ATTRIBUTE_NAME in result.data.attributes
source_inverse = source.matrix_world.inverted()
source_x = [
(source_inverse @ (result.matrix_world @ vertex.co)).x
for vertex in result.data.vertices
]
assert min(source_x) >= source_bounds[0]
assert max(source_x) <= source_bounds[1]
assert bpy.data.objects.get(source.name) is source
assert result.data.materials[0].node_tree is not None
color_texture = next(
node
for node in result.data.materials[0].node_tree.nodes
if node.bl_idname == "ShaderNodeTexImage"
)
assert color_texture.image is not image
assert tuple(color_texture.image.size) == (64, 34)
copied = np.empty(34 * 64 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((34, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = region_mask.bounds
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert shapes.BALLOON_CUTOUT_SHAPES == frozenset(
{"BALLOON", "DEPTH_BALLOON"}
)
group = bpy.data.node_groups.new(
material.IMAGE_MATERIAL_NODE_GROUP_NAME,
"ShaderNodeTree",
)
group.interface.new_socket(
name="Normal Scale",
in_out="INPUT",
socket_type="NodeSocketFloat",
)
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_balloon_normal_scale(image_material)
assert _inputs(group) == ["Normal Scale"]
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == shapes.BALLOON_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"]