Files
AnyImage/tests/test_mesh_blender.py
nan 08309f719d feat: Implement cutout tool and depth generation features
- Added a new cutout tool for selecting regions in images, allowing users to choose mesh shapes based on selected areas.
- Introduced depth generation capabilities for rasterized images, including normal data extraction.
- Created jobs for generating depth planes and region data, enhancing the AI data generation workflow.
- Developed a selection refinement process for cutout regions, improving accuracy in generated outputs.
- Established a new region module for handling rasterized image regions and their associated AI generation tasks.
- Enhanced node graph builders for asset generation, facilitating smoother integration of new features.
2026-08-30 23:09:13 +08:00

1246 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 image_data as common_image
from anyimage.operators.common import mesh_result as material
from anyimage.operators.common.mesh_result import ImageGeometry
from anyimage.operators.common.image_region import (
RegionMask,
RegionPath,
rasterize_region_path,
)
from anyimage.operators.cutout_tool import region_to_shape as mesh
from anyimage.operators.cutout_tool import region_to_shape as cutout_nodes
from anyimage.operators.cutout_tool import region_to_shape 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.convert_to_plane.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 _create_cutout_shape(source, shape, detail, *, bounds=None, field=None):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
region_mask = RegionMask(
np.ones((bottom - top, right - left), dtype=np.float32),
bounds,
)
base_shape = mesh.build_base_shape(
bpy.context,
source,
detail,
region_mask,
)
return mesh.create_shape_object(
bpy.context,
source,
shape,
detail,
region_mask,
base_shape,
field=field,
)
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._cutout_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("SURFACE")
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",
"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_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)
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
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(
mesh,
"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
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_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)
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 = ImageGeometry(
points=points,
intrinsics=intrinsics,
valid=np.ones((size, size), dtype=bool),
calibration_valid=np.ones((size, size), dtype=bool),
image_size=(size, size),
reference_depth=1.0,
region_transparent_fraction=0.0,
)
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
field=field,
)
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_value(
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,
"Base Plane 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()
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_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, "SURFACE")
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, "SURFACE")
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.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)
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 = ImageGeometry(
points=points,
intrinsics=intrinsics,
valid=np.ones((61, 41), dtype=bool),
calibration_valid=np.ones((61, 41), dtype=bool),
image_size=(41, 61),
reference_depth=2.0,
region_transparent_fraction=0.05,
)
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
8,
field=field,
bounds=crop_bounds,
)
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,
"Base Plane Depth",
)
assert not np.isclose(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 not np.allclose(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 = 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_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():
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)
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
region_path = RegionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
fill_rule="NONZERO",
)
source_bounds = mesh.image_empty_bounds(source)
with patch.object(
mesh,
"image_rgba",
wraps=mesh.image_rgba,
) as read_rgba:
rgba = mesh.image_rgba(image)
region_mask = rasterize_region_path(
tuple(image.size),
region_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=mesh.CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
with patch.object(mesh, "configured_max_ai_input_size", return_value=2048):
mesh.region_to_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[
mesh.modifier_input_identifier(modifier.node_group, "Shape")
] == shapes.CUTOUT_SHAPE_INDICES["SURFACE"]
expected_cleanup = mesh._cutout_cleanup_value(
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)