Files
AnyImage/tests/test_mesh_blender.py
nan 32bcd45d7b Add new node asset scripts for geometry and shader operations
- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters.
- Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections.
- Added `validate.py` to validate the integrity of the newly created node groups and their inputs.
- Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies.
- Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
2026-08-30 00:17:29 +08:00

1250 lines
42 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 Matrix, Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.operators.common import material, node_graph
from anyimage.operators.cutout_tool import build_shapes as mesh
from anyimage.operators.cutout_tool import node_assets as cutout_nodes
from anyimage.operators.cutout_tool import shape_types as shapes
from anyimage.operators.convert_to_plane import nodes as plane_nodes
from anyimage.operators.convert_to_plane import build as plane_build
from anyimage.operators.common.normal_map import bake_tangent_normal_image
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 test_depth_surface_image_packs_metric_points_and_edge_validity():
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))
depth = np.asarray(((1.0, 1.0), (1.0, 2.0)), dtype=np.float32)
valid = np.asarray(((True, False), (True, True)))
image = mesh._surface_depth_image(source, points, valid, depth)
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[:, :, :3], points)
assert packed[0, 1, 3] == -1.0
assert np.all(packed[valid, 3] >= 0.0)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_tangent_bake_changes_pixels_using_evaluated_mesh_tbn():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.objects.new("Source", None)
bpy.context.collection.objects.link(source)
target_mesh = bpy.data.meshes.new("Target")
target_mesh.from_pydata(
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
(),
((0, 1, 2),),
)
target_mesh.polygons[0].use_smooth = True
mesh._assign_uv(
target_mesh,
np.asarray(((0.0, 0.0), (1.0, 0.0), (0.0, 1.0))),
)
target = bpy.data.objects.new("Target", target_mesh)
bpy.context.collection.objects.link(target)
target.matrix_world = Matrix.Rotation(np.radians(-90.0), 4, "Y")
image = bpy.data.images.new("Object Normal", width=3, height=3, alpha=True)
pixels = np.ones((3, 3, 4), dtype=np.float32)
pixels[..., :3] = (1.0, 0.5, 0.5)
image.pixels.foreach_set(pixels.ravel())
bake_tangent_normal_image(bpy.context, source, target, image)
baked = np.empty(3 * 3 * 4, dtype=np.float32)
image.pixels.foreach_get(baked)
baked = baked.reshape((3, 3, 4))
assert not np.allclose(baked[0, 0, :3], pixels[0, 0, :3])
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_nodes.cutout_node_group("CUTOUT")
assert plane.name == "O Mesh Plane"
assert cutout.name == shapes.CUTOUT_NODE_GROUP_NAME
assert all(
cutout_nodes.cutout_node_group(shape) == cutout
for shape in shapes.CUTOUT_SHAPES
)
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Amount",
"Depth Image",
"Material",
]
assert _inputs(cutout) == [
"Geometry",
"Shape",
"Thickness",
"Depth Scale",
"Double Sided",
"Smooth Iterations",
"Cleanup",
"Smooth",
"Reference 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",
"Reference 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"
)
assert [item.name for item in menu_switch.enum_items] == list(
shapes.CUTOUT_SHAPE_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 = shapes.CUTOUT_SHAPE_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["CUTOUT"]) == ["Geometry", "Thickness"]
assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"]
assert _inputs(child_groups["DEPTH_BALLOON"]) == [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
assert _inputs(child_groups["DEPTH_SURFACE"]) == [
"Geometry",
"Thickness",
"Depth Scale",
"Reference 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"},
"Reference 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 == {
"CUTOUT": 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"
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["CUTOUT"],
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_material(
"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_material(
"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_build.build_mesh_plane(
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)
balloon_material = bpy.data.materials.new("Balloon Sheet.material")
result = mesh.build_cutout_object(
bpy.context,
source,
"BALLOON",
8,
balloon_material,
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
image_size=(64, 64),
keep_original=True,
)
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
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)
teddy_material = bpy.data.materials.new("Teddy Sheet.material")
with patch.object(
mesh,
"addon_preferences",
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
):
result = mesh.build_cutout_object(
bpy.context,
source,
"CUTOUT",
8,
teddy_material,
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
image_size=(64, 64),
keep_original=True,
)
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
def test_depth_surface_object_normal_is_remapped_to_cutout_axes():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new(
"Surface Normal",
width=1,
height=1,
alpha=True,
float_buffer=True,
)
image.pixels.foreach_set((0.5, 0.5, 1.0, 1.0))
mesh._orient_cutout_object_normal(image)
pixels = np.empty(4, dtype=np.float32)
image.pixels.foreach_get(pixels)
assert np.allclose(pixels, (0.0, 0.5, 0.5, 1.0), atol=1e-6)
def test_internal_depth_plane_image_uses_standard_data_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
plane_source = bpy.data.objects.new("Plane Source", None)
plane_source.data = bpy.data.images.new(
"Plane Source.png", width=2, height=2, alpha=True
)
anchored = np.zeros((2, 2, 3), dtype=np.float32)
plane_depth = plane_build.create_depth_image(plane_source, anchored)
assert plane_depth.name == "Plane Source_depth.exr"
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)
surface_material = bpy.data.materials.new("Surface.material")
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
field = (
points,
intrinsics,
np.ones((size, size), dtype=bool),
np.ones((size, size), dtype=bool),
(size, size),
1.0,
)
result = mesh.build_cutout_object(
bpy.context,
source,
"DEPTH_SURFACE",
8,
surface_material,
field=field,
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
image_size=(64, 64),
keep_original=True,
)
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[
mesh.modifier_input_identifier(modifier.node_group, "Shape")
] == shapes.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
expected_cleanup = mesh._cutout_cleanup_area(
mesh._cutout_point_spacing(source, 8),
mesh._world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert modifier[
mesh.modifier_input_identifier(modifier.node_group, "Smooth")
] == 1.0
assert modifier[
mesh.modifier_input_identifier(
modifier.node_group, "Smooth Iterations"
)
] == 0
reference_id = mesh.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert modifier[reference_id] == 8.0
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(
modifier.node_group, "Smooth Iterations"
),
4,
)
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(
modifier.node_group, "Smooth Iterations"
),
0,
)
external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree")
external_group.is_modifier = True
node_graph.interface_socket(
external_group, "Geometry", "OUTPUT", "NodeSocketGeometry"
)
node_graph.interface_socket(
external_group, "Geometry", "INPUT", "NodeSocketGeometry"
)
external_iterations = node_graph.interface_socket(
external_group, "Iterations", "INPUT", "NodeSocketInt"
)
external_iterations.default_value = 4
external_iterations.min_value = 0
external_smooth = node_graph.interface_socket(
external_group, "Smooth", "INPUT", "NodeSocketFloat"
)
external_smooth.default_value = 1.0
external_input = external_group.nodes.new("NodeGroupInput")
external_output = external_group.nodes.new("NodeGroupOutput")
external_geometry = node_graph.expand_smooth(
external_group, external_input.outputs["Geometry"]
)
external_group.links.new(
external_geometry, external_output.inputs["Geometry"]
)
external_modifier = result.modifiers.new("External O Smooth", "NODES")
external_modifier.node_group = external_group
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
external_mesh = evaluated.to_mesh()
try:
external_positions = np.asarray(
[tuple(vertex.co) for vertex in external_mesh.vertices]
)
finally:
evaluated.to_mesh_clear()
assert internal_positions.shape == external_positions.shape
assert np.allclose(internal_positions, external_positions, atol=1e-6)
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_nodes.cutout_node_group(shape)
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(group, "Shape"),
shapes.CUTOUT_SHAPE_INDICES[shape],
)
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(group, "Cleanup"),
0.0,
)
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(group, "Smooth"),
0.0,
)
mesh.set_modifier_input(
modifier,
mesh.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, "CUTOUT")
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.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)
mesh._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")
mesh.set_modifier_input(
modifier,
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.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()
mesh.set_modifier_input(
modifier,
mesh.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, "CUTOUT")
cleanup_id = mesh.modifier_input_identifier(modifier.node_group, "Cleanup")
mesh.set_modifier_input(modifier, cleanup_id, 0.0)
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 45
mesh.set_modifier_input(modifier, cleanup_id, 0.001)
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)
balloon_material = bpy.data.materials.new("Depth Balloon.material")
crop_bounds = (50, 20, 90, 80)
crop = ((0, 0), (40, 0), (40, 60), (0, 60))
local_bounds = mesh._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,
)
field = (
points,
intrinsics,
np.ones((61, 41), dtype=bool),
np.ones((61, 41), dtype=bool),
(41, 61),
2.0,
)
result = mesh.build_cutout_object(
bpy.context,
source,
"DEPTH_BALLOON",
8,
balloon_material,
field=field,
regions=((crop,),),
image_size=(40, 60),
bounds=local_bounds,
keep_original=True,
)
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 = mesh.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 = mesh.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert modifier[depth_reference_id] == 8.0
depth_direction_id = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Direction",
)
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
assert np.array_equal(direction, (0.0, 0.0, 1.0))
uniform_scale_id = mesh.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier[uniform_scale_id] > 0.0
thickness_id = mesh.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
smooth_id = mesh.modifier_input_identifier(
modifier.node_group,
"Smooth",
)
assert modifier[smooth_id] == 1.0
mesh.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()
mesh.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,
)
mesh.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
mesh.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 = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
mesh.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)
mesh.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.15,
)
mesh.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)
mesh.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = mesh.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
mesh.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 = mesh._load_normal_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_build.copy_color_image(source)
second = plane_build.copy_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():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Alpha Cutout", width=4, height=4, alpha=True)
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
image.pixels.foreach_set(np.flipud(top_down).ravel())
source = bpy.data.objects.new("Alpha Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
_rgba, selection = mesh._local_cutout_rgba(
source,
((0, 0), (4, 0), (4, 4), (0, 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)
pixels = np.ones((48, 64, 4), dtype=np.float32)
image.pixels.foreach_set(pixels.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
selection = "[[-8,8],[72,8],[72,40],[-8,40]]"
source_bounds = mesh.plane_bounds(source)
with patch.object(mesh, "push_undo") as undo:
mesh.build_local_cutout(
SimpleNamespace(mesh_detail=16),
bpy.context,
source,
"CUTOUT",
selection,
)
result = bpy.context.view_layer.objects.active
assert result is not source
assert result.type == "MESH"
assert result["anyimage_mesh_shape"] == "CUTOUT"
modifier = result.modifiers[0]
assert modifier.node_group.name == shapes.CUTOUT_NODE_GROUP_NAME
assert modifier[
mesh.modifier_input_identifier(modifier.node_group, "Shape")
] == shapes.CUTOUT_SHAPE_INDICES["CUTOUT"]
expected_cleanup = mesh._cutout_cleanup_area(
mesh._cutout_point_spacing(source, 16),
mesh._world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[mesh.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert modifier[
mesh.modifier_input_identifier(modifier.node_group, "Smooth")
] == 1.0
assert modifier[
mesh.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, 1.0)
undo.assert_called_once_with("Convert to Cutout")