Files
AnyImage/tests/test_mesh_blender.py
nan 7d39fe8c5e Add cutout profile and teddy balloon height calculations
- Implemented cotangent Poisson field and Poisson balloon profile in cutout_profile.py.
- Added teddy balloon heights calculation and related functions in cutout_teddy.py.
- Introduced interaction utilities for image editing in interaction.py.
- Developed depth normalization and analysis functions in cutout_depth.py.
- Created a validation script for cutout assets in validate_cutout_asset.py.
2026-08-27 22:46:57 +08:00

1163 lines
40 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 import material, mesh
from anyimage.operators.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_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_shape_node_groups_expose_only_shape_parameters():
bpy.ops.wm.read_factory_settings(use_empty=True)
plane = mesh._mesh_node_group(mesh.PLANE)
depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE)
legacy_surface = bpy.data.node_groups.new(
"O Image Cutout",
"GeometryNodeTree",
)
cutout = mesh._mesh_node_group(mesh.CUTOUT)
balloon = mesh._mesh_node_group(mesh.BALLOON)
depth_balloon = mesh._mesh_node_group(mesh.DEPTH_BALLOON)
depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE)
assert plane.name == "O Mesh Plane"
assert cutout.name == "O Image Surface"
assert cutout == legacy_surface
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert _inputs(cutout) == ["Geometry", "Thickness", "Cleanup"]
assert _inputs(balloon) == ["Geometry", "Thickness", "Cleanup"]
assert _inputs(depth_balloon) == [
"Geometry",
"Thickness",
"Depth Scale",
"Double Sided",
"Depth Reference",
"Depth Image",
"Cleanup",
"Base Plane",
]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Amount",
"Depth Image",
"Material",
]
assert _inputs(depth_surface) == [
"Geometry",
"Thickness",
"Depth Scale",
"Iterations",
"Depth Image",
"Cleanup",
"Depth Continuity",
"Smooth",
]
assert len({group.as_pointer() for group in (
cutout,
balloon,
depth_balloon,
depth_surface,
)}) == 4
expected_options = {
cutout: ["Cleanup"],
balloon: ["Cleanup"],
depth_balloon: ["Cleanup", "Base Plane"],
depth_surface: ["Cleanup", "Depth Continuity", "Smooth"],
}
for group, expected in expected_options.items():
panels = [
item
for item in group.interface.items_tree
if item.item_type == "PANEL"
]
assert [panel.name for panel in panels] == ["Options"]
assert panels[0].default_closed
assert [item.name for item in panels[0].interface_items] == expected
for group in (cutout, balloon, depth_balloon, depth_surface):
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 (depth_plane, cutout, depth_surface):
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 (balloon, 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
for group, name in (
(depth_plane, "Depth Amount"),
(depth_surface, "Depth Scale"),
):
depth_amount = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.name == name
)
assert depth_amount.subtype == "NONE"
assert depth_amount.min_value == 0.0
assert depth_amount.max_value > 1e20
assert any(
node.bl_idname == "ShaderNodeMath" and node.operation == "MAXIMUM"
for node in depth_plane.nodes
)
assert any(node.bl_idname == "GeometryNodeMeshGrid" for node in plane.nodes)
assert any(
node.bl_idname == "GeometryNodeGroup" and node.node_tree == plane
for node in depth_plane.nodes
)
assert any(
node.bl_idname == "GeometryNodeDeleteGeometry" for node in depth_plane.nodes
)
smooth = next(
item
for item in depth_surface.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Smooth"
)
assert smooth.default_value == 1.0
iterations = next(
item
for item in depth_surface.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Iterations"
)
assert iterations.default_value == 1
assert iterations.min_value == 0
continuity = next(
item
for item in depth_surface.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Depth Continuity"
)
assert continuity.default_value == 0.0
assert not any(node.bl_idname == "GeometryNodeGroup" for node in depth_surface.nodes)
assert any(
node.bl_idname == "GeometryNodeRepeatInput" for node in depth_surface.nodes
)
assert any(
node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes
)
repeat_counts = {
group: sum(
node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes
)
for group in (cutout, balloon, depth_balloon, depth_surface)
}
assert repeat_counts == {
cutout: 1,
balloon: 0,
depth_balloon: 0,
depth_surface: 2,
}
assert any(
node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes
)
assert any(
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
for node in depth_surface.nodes
)
assert sum(
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
for node in depth_surface.nodes
) == 3
assert sum(
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
for node in depth_surface.nodes
) == 2
boundary_compares = [
node
for node in depth_surface.nodes
if node.bl_idname == "FunctionNodeCompare"
and node.data_type == "INT"
and node.operation == "EQUAL"
]
assert len(boundary_compares) == 4
for compare in boundary_compares:
compare_a = next(
socket for socket in compare.inputs if socket.identifier == "A_INT"
)
compare_b = next(
socket for socket in compare.inputs if socket.identifier == "B_INT"
)
assert compare_a.is_linked
assert compare_b.default_value == 1
fixed_pin_switches = [
node
for node in depth_surface.nodes
if node.bl_idname == "GeometryNodeSwitch"
and not node.inputs["Switch"].is_linked
]
assert len(fixed_pin_switches) == 2
assert all(node.inputs["Switch"].default_value for node in fixed_pin_switches)
assert any(
node.bl_idname == "ShaderNodeMix"
and not node.inputs[0].is_linked
and node.inputs[0].default_value == 1.0
for node in depth_surface.nodes
)
depth_reference = next(
item
for item in depth_balloon.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Depth Reference"
)
assert depth_reference.hide_in_modifier
assert depth_reference.subtype == "DISTANCE"
for group in (depth_balloon, depth_surface):
depth_image = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Depth Image"
)
assert depth_image.hide_in_modifier
assert any(
node.bl_idname == "GeometryNodeImageTexture" for node in group.nodes
)
for group in (cutout, balloon, depth_balloon, depth_surface):
assert not any(
node.bl_idname == "GeometryNodeInputShortestEdgePaths"
for node in group.nodes
)
assert not any(
node.bl_idname == "GeometryNodeInputNamedAttribute"
and node.inputs["Name"].default_value == "o_boundary"
for node in group.nodes
)
for group in (balloon, depth_balloon):
profile = next(
node
for node in group.nodes
if node.bl_idname == "GeometryNodeInputNamedAttribute"
and node.inputs["Name"].default_value == mesh.BALLOON_ATTRIBUTE
)
assert profile.data_type == "FLOAT"
assert any(
node.bl_idname == "ShaderNodeMapRange"
and node.interpolation_type == "SMOOTHERSTEP"
for node in group.nodes
)
for group in (depth_plane, cutout, balloon, depth_balloon, depth_surface):
input_sockets = [
item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
assert all(item.structure_type == "SINGLE" for item in input_sockets)
for group_input in (
node for node in group.nodes if node.bl_idname == "NodeGroupInput"
):
assert all(
output.is_linked or output.hide
for output in group_input.outputs
if output.name
)
assert all(
link.from_node.location.x <= link.to_node.location.x
for link in group.links
)
assert all(not node.label for node in group.nodes)
node_locations = {tuple(node.location) for node in group.nodes}
assert len(node_locations) == len(group.nodes)
if group in (cutout, balloon, depth_balloon, depth_surface):
group_nodes = list(group.nodes)
for index, node in enumerate(group_nodes):
for other in group_nodes[index + 1:]:
assert not (
abs(node.location.x - other.location.x) < 150.0
and abs(node.location.y - other.location.y) < 100.0
)
assert any(
node.bl_idname == "GeometryNodeSetShadeSmooth"
and node.inputs["Shade Smooth"].default_value
for node in depth_surface.nodes
)
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert material.SURFACE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
assert (
material.MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME
== material.BALLOON_MATERIAL_NODE_GROUP_NAME
== material.IMAGE_MATERIAL_NODE_GROUP_NAME
)
group = material.material_node_group(
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME
)
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,
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME,
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,
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME,
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(
surface_group_name=material.IMAGE_MATERIAL_NODE_GROUP_NAME
)
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 = mesh._build_mesh_object(
bpy.context,
source,
mesh.PLANE,
2,
material,
keep_original=True,
)
assert result["anyimage_mesh_shape"] == mesh.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 mesh.BALLOON_ATTRIBUTE 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_mesh_object(
bpy.context,
source,
mesh.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 mesh.BALLOON_ATTRIBUTE in result.data.attributes
profile_attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
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 == "O Image Balloon"
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_mesh_object(
bpy.context,
source,
mesh.CUTOUT,
8,
teddy_material,
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
image_size=(64, 64),
keep_original=True,
)
attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
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_mesh_depth_images_use_standard_data_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
cutout_source = bpy.data.objects.new("Cutout Source", None)
cutout_source.data = bpy.data.images.new(
"Cutout Source.png", width=2, height=2, alpha=True
)
cutout_depth = mesh._cutout_depth_image(
cutout_source,
np.ones((2, 2), dtype=np.float32),
)
assert cutout_depth.name == "Cutout Source_depth.exr"
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 = mesh._depth_field_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),
(size, size),
1.0,
)
result = mesh._build_mesh_object(
bpy.context,
source,
mesh.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 mesh.BALLOON_ATTRIBUTE in result.data.attributes
modifier = result.modifiers[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, "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, "Iterations"),
0,
)
external_group = bpy.data.node_groups.new("External O Smooth", "GeometryNodeTree")
external_group.is_modifier = True
mesh._interface_socket(
external_group, "Geometry", "OUTPUT", "NodeSocketGeometry"
)
mesh._interface_socket(
external_group, "Geometry", "INPUT", "NodeSocketGeometry"
)
external_iterations = mesh._interface_socket(
external_group, "Iterations", "INPUT", "NodeSocketInt"
)
external_iterations.default_value = 4
external_iterations.min_value = 0
external_smooth = mesh._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 = mesh._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 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 = obj.modifiers.new("O Image Surface", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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,
mesh.BALLOON_ATTRIBUTE,
(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 = obj.modifiers.new("O Image Balloon", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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_removes_only_small_mesh_island():
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)
]
vertices.extend(
((0.0, 5.0, 5.0), (0.0, 5.01, 5.0), (0.0, 5.0, 5.01))
)
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)))
faces.append((9, 10, 11))
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 = obj.modifiers.new("O Image Surface", "NODES")
modifier.node_group = mesh._mesh_node_group(mesh.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)) == 12
mesh.set_modifier_input(modifier, cleanup_id, 1.0)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction():
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),
)
depth_data = {
"depth_signal": np.ones((61, 41), dtype=np.float32),
"baseline_vector": np.zeros(3, dtype=np.float32),
"valid": np.ones((61, 41), dtype=bool),
"origin": (50, 20),
"reference_depth": 1.0,
"crop_size": (40, 60),
"source_size": (100, 100),
}
result = mesh._build_mesh_object(
bpy.context,
source,
mesh.DEPTH_BALLOON,
8,
balloon_material,
depth_data=depth_data,
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 mesh.BALLOON_ATTRIBUTE in result.data.attributes
assert "o_balloon_depth" not in result.data.attributes
assert "o_depth_offset" not in result.data.attributes
modifier = result.modifiers[0]
base_plane_id = mesh.modifier_input_identifier(
modifier.node_group,
"Base Plane",
)
depth_image_id = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Image",
)
base_plane = modifier[base_plane_id]
assert isinstance(modifier[depth_image_id], bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
assert np.allclose(base_plane.location[1:], expected_center, atol=1e-5)
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,
)
arrow = base_plane.rotation_euler.to_matrix() @ Vector((0.0, 0.0, 1.0))
assert np.allclose(tuple(arrow), (-1.0, 0.0, 0.0), atol=1e-5)
depth_reference_id = mesh.modifier_input_identifier(
modifier.node_group,
"Depth Reference",
)
assert modifier[depth_reference_id] > 0.0
thickness_id = mesh.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
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)
base_plane.location += arrow * 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)
base_plane.location += arrow * 0.15
base_plane.rotation_euler.rotate_axis("Y", np.radians(12.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 = mesh._copy_color_image(source)
second = mesh._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_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,
mesh.CUTOUT,
selection,
)
result = bpy.context.view_layer.objects.active
assert result is not source
assert result.type == "MESH"
assert result["anyimage_mesh_shape"] == mesh.CUTOUT
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 mesh.BALLOON_ATTRIBUTE 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) == (80, 32)
copied = np.empty(32 * 80 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((32, 80, 4))
assert np.allclose(copied[:, 8:72], 1.0)
assert np.allclose(copied[:, :8], 0.0)
assert np.allclose(copied[:, 72:], 0.0)
undo.assert_called_once_with("Convert to Cutout")