Files
AnyImage/tests/test_cutout_objects.py
nan 7dec3b7ca4 Add depth surface processing and layout tools
- Implemented `cutout_depth_surface.py` for face separation and connected thickness calculations for depth surface assets.
- Created `cutout_depth_surface_layout.py` to arrange nodes for staggered calculation branches in depth surface processing.
- Authored `geometry_layouts.py` to define layouts for various geometry assets, including image cutouts and depth planes.
- Introduced `node_layout.py` for shared presentation rules, facilitating node arrangement and validation in geometry-node layouts.
2026-09-07 19:21:03 +08:00

2161 lines
77 KiB
Python

import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from mathutils import Matrix, Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common import depth as depth_data
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.common import node as node_asset
from anyimage.common import object as object_data
from anyimage.common.selection import (
ImageEditWarning,
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
from anyimage.operators.clipboard_image import actions as clipboard_actions
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import main as cutout_main
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.operators.image_edit_tool import mask as edit_mask
from anyimage.operators.image_edit_tool import frame as edit_frame
from anyimage.server.geometry.depth_texture import (
write_vector_depth_texture,
write_z_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
def _inputs(group):
return [
item.name
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
]
def _group_node(group, node_tree):
return next(
node
for node in node_tree.nodes
if node.bl_idname == "ShaderNodeGroup" and node.node_tree == group
)
def _create_cutout_shape(
source,
shape,
detail,
*,
bounds=None,
depth_image=None,
depth_metadata=None,
selection_values=None,
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
if selection_values is None:
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
source_rgba = image_data.image_rgba(source.data)
color_image = cutout_object.create_cropped_color_image(
source,
selection_mask.bounds,
source_rgba,
)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
detail,
selection_mask.values,
selection_mask.bounds,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
detail,
selection_mask.values,
selection_mask.bounds,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
inward_axis=inward_axis,
color_image=color_image,
)
def _depth_image(points, valid, name="Depth"):
points = np.asarray(points, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
pixels = np.zeros((height, width, 4), dtype=np.float32)
pixels[..., :3] = points
pixels[..., 3] = valid
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def _depth_metadata(intrinsics, image_size, reference_depth):
return {
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
"image_size": tuple(int(value) for value in image_size),
"reference_depth": float(reference_depth),
}
def test_image_edit_result_uses_complete_shared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
shared = bpy.data.objects.new("Poster Shared", None)
shared.empty_display_type = "IMAGE"
shared.data = source_image
bpy.context.collection.objects.link(source)
bpy.context.collection.objects.link(shared)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
result_image = image_data.replace_empty_image(source, result_image)
assert shared.data == source_image
assert source_image.alpha_mode == "STRAIGHT"
assert result_image.alpha_mode == "PREMUL"
assert source.use_empty_image_alpha
assert source.data == result_image
assert result_image.name == "Poster.png.001"
assert "_color" not in result_image.name
def test_image_edit_result_recovers_unshared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
bpy.context.collection.objects.link(source)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
result_image = image_data.replace_empty_image(source, result_image)
assert source.data == result_image
assert result_image.name == "Poster.png"
assert bpy.data.images.get("Poster.png") == result_image
def test_mask_frame_mask_add_preserves_packed_hidden_rgb():
from io import BytesIO
from PIL import Image
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Frame RGB", width=4, height=4, alpha=True)
rgba = np.ones((4, 4, 4), dtype=np.float32)
rgba[..., :3] = np.arange(48).reshape((4, 4, 3)) / 47.0
source_image.pixels.foreach_set(np.flipud(rgba).ravel())
source_image.update()
expected = image_data.image_rgba(source_image).copy()
source = bpy.data.objects.new("Frame RGB", None)
source.empty_display_type = "IMAGE"
source.empty_display_size = 2.0
source.empty_image_offset = (-0.5, -0.5)
source.data = source_image
bpy.context.collection.objects.link(source)
selection = SelectionMask(np.ones((4, 4), dtype=np.float32), (0, 0, 4, 4))
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
assert edit_mask.EditImageAlpha._apply_mask(
operator, SimpleNamespace(), source, selection
) == {"FINISHED"}
descriptor = edit_frame._frame_source(
source, SimpleNamespace(width=4, height=4),
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
Matrix.Identity(4), active=True,
)
np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3])
np.testing.assert_array_equal(descriptor["pixels"], 0)
framed = edit_frame.composite_frame_pixels(
(descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
)
result = image_data.create_image_edit_result(source.data, framed, (4, 4))
result = image_data.replace_empty_image(source, result)
assert result.alpha_mode == "PREMUL"
assert source.use_empty_image_alpha
assert result.packed_file is not None
packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0
np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255)
np.testing.assert_array_equal(packed[..., 3], 0)
result.reload()
assert result.alpha_mode == "PREMUL"
operator.mode = "ADD"
assert edit_mask.EditImageAlpha._apply_mask(
operator, SimpleNamespace(), source, selection
) == {"FINISHED"}
restored = image_data.image_rgba(source.data)
np.testing.assert_allclose(restored[..., :3], expected[..., :3], atol=1 / 255)
np.testing.assert_array_equal(restored[..., 3], 1)
def test_frame_restored_alpha_keeps_foreground_blended_into_white():
from io import BytesIO
from PIL import Image
bpy.ops.wm.read_factory_settings(use_empty=True)
sources = []
for name, color, depth in (("Background", (1, 1, 1, 0), 2),
("Foreground", (0.2, 0.4, 0.6, 1), 1)):
image = bpy.data.images.new(name, width=6, height=2, alpha=True)
rgba = np.tile(color, (2, 6, 1)).astype(np.float32)
if name == "Foreground":
rgba[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
rgba[:, 0, :3] = 0
image.pixels.foreach_set(np.flipud(rgba).ravel())
image.update()
source = bpy.data.objects.new(name, None)
source.empty_display_type = "IMAGE"
source.empty_display_size = 2
source.empty_image_offset = (-0.5, -0.5)
source.data = image
source.matrix_world = Matrix.Translation((0, 0, -depth))
bpy.context.collection.objects.link(source)
sources.append(edit_frame._frame_source(
source, SimpleNamespace(width=6, height=2),
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
Matrix.Identity(4), active=name == "Foreground",
))
front = sources[1]
source_alpha = front["rgba"][..., 3:4]
expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha
# The image aspect makes its orthographic screen projection two thirds of a pixel high.
pixels = edit_frame.composite_frame_pixels(
sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2)
)
result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2))
result = image_data.replace_empty_image(front["object"], result)
encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255
np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255)
np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255)
selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2))
operator = SimpleNamespace(mode="ADD", report=lambda *_args: None)
assert edit_mask.EditImageAlpha._apply_mask(
operator, SimpleNamespace(), front["object"], selection
) == {"FINISHED"}
restored = image_data.image_rgba(front["object"].data)
np.testing.assert_allclose(restored[..., :3], expected, atol=1 / 255)
np.testing.assert_array_equal(restored[..., 3], 1)
assert np.all(np.diff(restored[0, :, 0]) <= 0)
np.testing.assert_array_equal(restored[0, 0, :3], 1)
def test_brush_release_commits_one_packed_image_without_touching_shared_source():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Mask Source", width=8, height=6, alpha=True)
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
source_rgba[:, :, :3] = (0.2, 0.4, 0.6)
source_image.pixels.foreach_set(np.flipud(source_rgba).ravel())
source_image.update()
source = bpy.data.objects.new("Mask Source", None)
source.empty_display_type = "IMAGE"
source.data = source_image
source.empty_display_size = 3.0
source.empty_image_offset = (-0.25, -0.75)
source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
shared = bpy.data.objects.new("Mask Shared", None)
shared.empty_display_type = "IMAGE"
shared.data = source_image
bpy.context.collection.objects.link(source)
bpy.context.collection.objects.link(shared)
matrix = source.matrix_world.copy()
selection_mask = SelectionMask(
np.ones((2, 3), dtype=np.float32),
(2, 1, 5, 3),
)
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
status = edit_mask.EditImageAlpha._apply_mask(
operator,
SimpleNamespace(),
source,
selection_mask,
)
result = source.data
assert status == {"FINISHED"}
assert source.data == result
assert result != source_image
assert result.packed_file is not None
assert shared.data == source_image
np.testing.assert_allclose(image_data.image_rgba(source_image), source_rgba)
result_rgba = image_data.image_rgba(result)
np.testing.assert_array_equal(result_rgba[1:3, 2:5, 3], 0.0)
assert source.matrix_world == matrix
assert source.empty_display_size == 3.0
assert tuple(source.empty_image_offset) == (-0.25, -0.75)
def test_brush_drag_does_not_create_or_update_an_image():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Mask Cancel", width=4, height=3, alpha=True)
source = bpy.data.objects.new("Mask Cancel", None)
source.empty_display_type = "IMAGE"
source.data = source_image
bpy.context.collection.objects.link(source)
image_count = len(bpy.data.images)
shared = bpy.data.objects.new("Mask Cancel shared", None)
shared.empty_display_type = "IMAGE"
shared.data = source_image
before = np.array(source_image.pixels[:])
operator = edit_mask.ImageGesture()
operator.active_tool_id = "anyimage.mask"
operator.gesture = "BRUSH"
operator._path = [(10.0, 10.0)]
operator._path_samples = list(operator._path)
operator._cursor = (10.0, 10.0)
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=lambda: None),
workspace=SimpleNamespace(status_text_set=lambda value: None))
event = SimpleNamespace(
type="MOUSEMOVE",
value="NOTHING",
mouse_region_x=20,
mouse_region_y=25,
)
result = edit_mask.ImageGesture.modal(operator, context, event)
assert result == {"RUNNING_MODAL"}
assert source.data == source_image
assert len(bpy.data.images) == image_count
event.type, event.value = "ESC", "PRESS"
assert operator.modal(context, event) == {"CANCELLED"}
assert source.data == shared.data == source_image
np.testing.assert_array_equal(source_image.pixels[:], before)
assert len(bpy.data.images) == image_count
assert operator._path is operator._path_samples is operator._brush_preview is None
def test_object_normal_image_follows_cutout_inward_axis():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new(
"Object Normal",
width=1,
height=1,
alpha=True,
float_buffer=True,
)
decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32)
encoded = decoded * 0.5 + 0.5
image.pixels.foreach_set((*encoded, 0.3))
cutout_object.orient_object_normal_image(
image,
cutout_object.CutoutInwardAxis.NEGATIVE_Z,
)
actual = np.empty(4, dtype=np.float32)
image.pixels.foreach_get(actual)
assert np.allclose(actual[:3], encoded, atol=1e-6)
assert np.isclose(actual[3], 0.3)
cutout_object.orient_object_normal_image(
image,
cutout_object.CutoutInwardAxis.POSITIVE_X,
)
image.pixels.foreach_get(actual)
expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5
assert np.allclose(actual[:3], expected, atol=1e-6)
assert np.isclose(actual[3], 0.3)
@pytest.mark.parametrize("shape", ("SURFACE", "BALLOON"))
def test_cutout_axes_produce_the_same_world_geometry(shape):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
source = bpy.data.objects.new("Axis Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.matrix_world = (
Matrix.Translation((2.0, -3.0, 1.0))
@ Matrix.Rotation(0.35, 4, "Y")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
positive_x = _create_cutout_shape(
source,
shape,
8,
inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
)
negative_z = _create_cutout_shape(
source,
shape,
8,
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
)
for result in (positive_x, negative_z):
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
assert np.ptp(local[:, 0]) > 0.0
assert np.ptp(local[:, 1]) > 0.0
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
def world_positions(obj):
local = _evaluated_positions(obj)
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
from scipy.optimize import linear_sum_assignment
distances = np.linalg.norm(actual[:, None] - expected[None], axis=2)
rows, columns = linear_sum_assignment(distances)
assert distances[rows, columns].max() < 2e-6
assert np.allclose(negative_z.matrix_world, source.matrix_world)
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=2)
source = bpy.data.objects.new("Source", None)
source.data = source_image
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
frame = GeometryFrame(
depth=points[..., 2].copy(),
validity=validity,
intrinsics=np.eye(3, dtype=np.float32),
points=points,
)
path = tmp_path / "vector-depth.exr"
write_vector_depth_texture(frame, path)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = depth_data.load_depth_result_image(path, source, metadata)
packed = np.empty(16, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[..., :3], points)
assert np.array_equal(packed[..., 3], validity)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_z_depth_texture_keeps_depth_outside_positive_validity(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=1)
source = bpy.data.objects.new("Source", None)
source.data = source_image
frame = GeometryFrame(
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
path = tmp_path / "z-depth.exr"
write_z_depth_texture(frame, path)
image = depth_data.load_depth_result_image(
path,
source,
_depth_metadata(np.eye(3), (2, 1), 2.0),
)
packed = np.empty(8, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = packed.reshape((1, 2, 4))
assert np.array_equal(packed[..., :3], np.repeat(frame.depth[..., None], 3, axis=2))
assert np.array_equal(packed[..., 3], frame.validity)
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
bpy.ops.wm.read_factory_settings(use_empty=True)
plane = node_asset.load_node_group("O Mesh Plane")
depth_plane = node_asset.load_node_group("O Image Depth Plane")
cutout = cutout_object.load_cutout_node_group()
assert plane.name == "O Mesh Plane"
assert cutout.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
assert all(
cutout_object.load_cutout_node_group() == cutout
for shape in cutout_shape.CUTOUT_SHAPES
)
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness"]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Scale",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
assert _inputs(cutout) == [
"Geometry",
"Shape",
"Inward Axis",
"Thickness",
"Depth Scale",
"Double Sided",
"Smooth Iterations",
"Cleanup",
"Smooth",
"Split",
"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",
"Split",
"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"
)
labels = {
shape: shape.replace("_", " ").title()
for shape in cutout_shape.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
)
axis_switch = next(
node
for node in cutout.nodes
if node.bl_idname == "GeometryNodeMenuSwitch"
and [item.name for item in node.enum_items] == ["-Z", "+X"]
)
negative_z_link = next(
link
for link in cutout.links
if link.to_node == axis_switch and link.to_socket.name == "-Z"
)
positive_x_link = next(
link
for link in cutout.links
if link.to_node == axis_switch and link.to_socket.name == "+X"
)
assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform"
assert any(
link.from_socket == negative_z_link.from_socket
and link.to_node == positive_x_link.from_node
and link.to_socket.name == "Geometry"
for link in cutout.links
)
child_groups = {
shape: bpy.data.node_groups[name]
for shape, name in cutout_shape.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",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
]
assert _inputs(child_groups["DEPTH_SURFACE"]) == [
"Geometry",
"Thickness",
"Depth Scale",
"Split",
"Cleanup",
"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 == {
"Geometry": {"DEPTH_SURFACE"},
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Split": {"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 shape, group in child_groups.items() if shape != "DEPTH_SURFACE"
)
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"
split_control = next(
item
for item in cutout.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Split"
)
child_split = next(
item
for item in child_groups["DEPTH_SURFACE"].interface.items_tree
if item.item_type == "SOCKET" and item.name == "Split"
)
assert split_control.default_value == 0.0
assert split_control.min_value == 0.0
assert split_control.max_value == 1.0
assert split_control.subtype == "FACTOR"
assert getattr(split_control.parent, "name", "") == "Options"
assert child_split.default_value == 0.0
assert child_split.min_value == 0.0
assert child_split.max_value == 1.0
assert child_split.subtype == "FACTOR"
assert getattr(child_split.parent, "name", "") == "Options"
depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes
assert any(node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes)
from validate_cutout import validate_depth_surface_attributes
validate_depth_surface_attributes(child_groups["DEPTH_SURFACE"])
assert [node.domain for node in depth_surface_nodes
if node.bl_idname == "GeometryNodeDeleteGeometry"] == ["FACE"]
assert "Stretch Limit" not in [item.name for item in child_groups["DEPTH_SURFACE"].interface.items_tree]
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"
depth_subdivide = next(
item
for item in depth_plane.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Subdivide"
)
assert depth_subdivide.default_value == 6
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_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = material_data.material_node_group()
normal = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal"
)
assert tuple(normal.default_value) == (0.5, 0.5, 1.0, 1.0)
bump_scale = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Bump Scale"
)
assert bump_scale.default_value == 0.0
normal_scale = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal Scale"
)
assert normal_scale.default_value == 1.0
object_space = next(
item
for item in group.interface.items_tree
if item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Object Space"
)
assert object_space.default_value is False
normal_maps = {
node.space for node in group.nodes if node.bl_idname == "ShaderNodeNormalMap"
}
assert normal_maps == {"TANGENT", "OBJECT"}
assert all(
not node.label
for node in group.nodes
if node.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeMix"}
)
color = bpy.data.images.new("Color", width=2, height=2)
normal_image = bpy.data.images.new("Normal", width=2, height=2)
object_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Object Normal")),
color,
1.0,
normal_image=normal_image,
normal_space="OBJECT",
)
layer = _group_node(group, object_material.node_tree)
assert layer.inputs["Object Space"].default_value is True
tangent_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")),
color,
1.0,
normal_image=normal_image,
)
layer = _group_node(group, tangent_material.node_tree)
assert layer.inputs["Object Space"].default_value is False
reused = material_data.material_node_group()
assert {
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
} == {"TANGENT", "OBJECT"}
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source", width=4096, height=2048)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
material = bpy.data.materials.new("Source.material")
result = plane_object.create_plane_object(
bpy.context,
source,
2,
material,
)
assert result["anyimage_mesh_shape"] == "PLANE"
assert len(result.data.vertices) == 4
assert len(result.data.polygons) == 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert cutout_shape.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
assert result.data.materials[0] == material
assert evaluated_mesh.materials[0].original == material
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
finally:
evaluated.to_mesh_clear()
def test_clipboard_plane_uses_the_mesh_material_slot():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
with patch.object(clipboard_actions, "_place_in_view"):
result = clipboard_actions.add_image_plane(bpy.context, image)
assert _inputs(result.modifiers[0].node_group) == [
"Geometry",
"Subdivide",
"Thickness",
]
assert len(result.data.materials) == 1
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert evaluated_mesh.materials[0].original == result.data.materials[0]
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
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.z) < 1e-8 for vertex in result.data.vertices)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
assert profile_attribute.data_type == "FLOAT"
assert profile_attribute.domain == "POINT"
profile = np.empty(len(result.data.vertices), dtype=np.float32)
profile_attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
material_tree = result.data.materials[0].node_tree
normal_scale_attribute = next(
node
for node in material_tree.nodes
if node.bl_idname == "ShaderNodeAttribute"
)
assert normal_scale_attribute.attribute_type == "GEOMETRY"
assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME
image_layer = _group_node(material_data.material_node_group(), material_tree)
normal_scale_link = next(
link
for link in material_tree.links
if link.to_node == image_layer
and link.to_socket == image_layer.inputs["Normal Scale"]
)
assert normal_scale_link.from_node == normal_scale_attribute
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
def test_teddy_preference_writes_the_shared_balloon_attribute():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Teddy Sheet", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
with patch.object(
cutout_geometry,
"addon_preferences",
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
):
result = _create_cutout_shape(
source,
"SURFACE",
8,
)
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
profile = np.empty(len(result.data.vertices), dtype=np.float32)
attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
assert not any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
def test_cutout_material_uses_ior_1_2():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Cutout", width=16, height=16, alpha=True)
source = bpy.data.objects.new("Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
result = _create_cutout_shape(source, "SURFACE", 8)
shader = next(
node
for node in result.data.materials[0].node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(
material_data.material_node_group(),
result.data.materials[0].node_tree,
)
ior_level_link = next(
link
for link in result.data.materials[0].node_tree.links
if link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
)
assert ior_level_link.from_node == image_layer
assert ior_level_link.from_socket == image_layer.outputs["Color"]
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
material = material_data.create_image_material(source, image, 0.0)
shader = next(
node
for node in material.node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
assert any(
link.from_node == image_layer
and link.from_socket == image_layer.outputs["Color"]
and link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
for link in material.node_tree.links
)
shadeless = material_data.create_image_material(
source,
image,
0.0,
shadeless=True,
)
assert not any(
node.bl_idname == "ShaderNodeBsdfPrincipled"
for node in shadeless.node_tree.nodes
)
def test_cutout_material_uses_flat_tangent_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = bpy.data.images.new("Source", width=2, height=2)
color = bpy.data.images.new("Color", width=2, height=2)
normal = bpy.data.images.new("Normal", width=1, height=1)
result = material_data.create_image_material(
source,
color,
0.0,
normal_image=normal,
scene=bpy.context.scene,
)
layer = _group_node(material_data.material_node_group(), result.node_tree)
assert layer.inputs["Object Space"].default_value is False
def test_depth_surface_protected_smooth_evaluates():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
valid = np.ones((size, size), dtype=bool)
depth_image = _depth_image(points, valid)
depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0)
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")]
== cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 8),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")]
== 1.0
)
assert (
modifier[
object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations")
]
== 0
)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert modifier[reference_id] == 8.0
object_data.set_modifier_input(
modifier,
object_data.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_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
baseline_min = baseline_z.min()
baseline_max = baseline_z.max()
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.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
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
assert np.isclose(z.max(), baseline_max, atol=1e-5)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations"),
4,
)
object_data.set_modifier_input(
modifier,
object_data.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 test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
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
validity = np.ones((size, size), dtype=bool)
validity[[0, -1], :] = False
validity[:, [0, -1]] = False
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
positions = _evaluated_positions(result)
assert np.isfinite(positions).all()
assert np.ptp(positions[:, 0]) > 1.5
assert np.ptp(positions[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) < 1e-4
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_converted_planes_center_the_origin_without_moving_world_corners():
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_source(name):
image = bpy.data.images.new(name, width=200, height=100)
source = bpy.data.objects.new(name, None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.empty_image_offset = (0.25, -0.2)
source.matrix_world = (
Matrix.Translation((3.0, -2.0, 5.0))
@ Matrix.Rotation(0.4, 4, "Z")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
return source
def assert_centered(result, bounds, source_matrix):
x_min, x_max, y_min, y_max = bounds
center = Vector(
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
)
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(source_matrix @ center),
atol=1e-6,
)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
expected = sorted(
tuple(source_matrix @ Vector((x, y, 0.0)))
for x in (x_min, x_max)
for y in (y_min, y_max)
)
actual = sorted(
tuple(result.matrix_world @ vertex.co)
for vertex in result.data.vertices
)
assert np.allclose(actual, expected, atol=1e-6)
plane_source = create_source("Centered Plane")
plane_bounds = image_data.image_empty_bounds(plane_source)
plane_matrix = plane_source.matrix_world.copy()
plane_material = bpy.data.materials.new("Centered Plane Material")
plane = plane_object.create_plane_object(
bpy.context,
plane_source,
0,
plane_material,
)
assert_centered(plane, plane_bounds, plane_matrix)
assert plane["anyimage_mesh_shape"] == "PLANE"
depth_source = create_source("Centered Depth Plane")
depth_bounds = image_data.image_empty_bounds(depth_source)
depth_matrix = depth_source.matrix_world.copy()
depth_values = np.ones((2, 2, 3), dtype=np.float32)
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
depth_material = bpy.data.materials.new("Centered Depth Material")
depth_plane = plane_object.create_depth_plane_object(
bpy.context,
depth_source,
0,
depth_material,
depth_image,
_depth_metadata(
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
(2, 2),
1.0,
),
)
assert_centered(depth_plane, depth_bounds, depth_matrix)
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0]
uniform_scale = depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Uniform Scale",
)
]
assert np.isclose(
depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Reference Depth",
)
],
uniform_scale,
)
def test_depth_plane_builds_a_fixed_base_terrain_block():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Terrain", width=4, height=4)
source = bpy.data.objects.new("Terrain", 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("Terrain Material")
depth_image = _depth_image(
np.ones((4, 4, 3), dtype=np.float32),
np.ones((4, 4), dtype=bool),
)
result = plane_object.create_depth_plane_object(
bpy.context,
source,
2,
material,
depth_image,
_depth_metadata(
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
(4, 4),
1.0,
),
)
modifier = result.modifiers[0]
group = modifier.node_group
def set_input(name, value):
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, name),
value,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
set_input("Depth Scale", 0.0)
baseline = _evaluated_positions(result)
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
baseline_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in baseline
if np.isclose(z, 0.0, atol=1e-6)
}
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert result.data.materials[0] == material
assert evaluated_mesh.materials[0].original == material
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
assert "UVMap" in evaluated_mesh.uv_layers
finally:
evaluated.to_mesh_clear()
set_input("Depth Scale", 1.0)
set_input("Reference Depth", 1.2)
outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
outward_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in outward
if np.isclose(z, 0.0, atol=1e-6)
}
assert outward_base == baseline_base
z_levels = np.unique(np.round(outward[:, 2], 6))
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
set_input("Reference Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5)
set_input("Reference Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert limited[:, 2].max() > 0.0
assert limited[:, 2].max() < 0.001
set_input("Reference Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
def _add_cutout_modifier(obj, shape):
group = cutout_object.load_cutout_node_group()
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Shape"),
cutout_shape.CUTOUT_SHAPE_INDICES[shape],
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Inward Axis"),
0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Cleanup"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Smooth"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, "Smooth Iterations"),
1,
)
return modifier
def _depth_surface_step_object():
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Depth Surface Step")
data.from_pydata(vertices, (), faces)
uv_layer = data.uv_layers.new(name="UVMap")
for loop in data.loops:
coordinate = data.vertices[loop.vertex_index].co
uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5)
obj = bpy.data.objects.new("Depth Surface Step", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE")
points = np.empty((3, 3, 3), dtype=np.float32)
for y in range(3):
for x in range(3):
points[y, x] = (float(x), -float(y), 1.0)
points[0, 0, 2] = 6.0
depth_image = _depth_image(points, np.ones((3, 3), dtype=bool))
for name, value in (
("Depth Image", depth_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Reference Depth", 1.0),
("Thickness", 0.0),
("Smooth Iterations", 0),
):
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, name),
value,
)
return obj, modifier
def _evaluated_topology(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
face_edges = {
tuple(sorted((start, end)))
for polygon in result.polygons
for start, end in zip(
polygon.vertices,
(*polygon.vertices[1:], polygon.vertices[0]),
)
}
mesh_edges = {
tuple(sorted(edge.vertices))
for edge in result.edges
}
face_vertices = {
vertex
for polygon in result.polygons
for vertex in polygon.vertices
}
return (
len(result.vertices),
len(result.polygons),
mesh_edges - face_edges,
set(range(len(result.vertices))) - face_vertices,
)
finally:
evaluated.to_mesh_clear()
def test_depth_surface_split_preserves_original_faces():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split")
for value in (0., .5, 1.):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert vertices >= 9 and faces == 8
assert not loose_edges and not loose_points
def test_cutout_thickness_keeps_one_connected_shell():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
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")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.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
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -0.5, atol=1e-6)
assert np.isclose(z.max(), 0.5, atol=1e-6)
front_faces = [
polygon for polygon in result.polygons if polygon.normal.z > 0.9
]
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
]
assert len(front_faces) == 8
assert len(back_faces) == 8
assert len(side_faces) == 8
center = Vector((1.0, 1.0, 0.0))
for polygon in side_faces:
radial = polygon.center - center
assert polygon.normal.dot(radial) > 0.0
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.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.z) < 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 = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Balloon Profile")
data.from_pydata(vertices, (), faces)
cutout_object._assign_float_point_attribute(
data,
cutout_shape.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")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
bpy.context.view_layer.update()
positions = _evaluated_positions(obj)
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
assert np.isclose(
positions[:, 2].min(),
-positions[:, 2].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()
object_data.set_modifier_input(
modifier,
object_data.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
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -2.0, atol=1e-6)
assert np.isclose(z.max(), 2.0, atol=1e-6)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.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[:, 2], 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 = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
for island in range(12):
start = len(vertices)
x = 5.0 + island * 0.1
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
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 = object_data.modifier_input_identifier(modifier.node_group, "Cleanup")
object_data.set_modifier_input(modifier, cleanup_id, 0.0)
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 45
object_data.set_modifier_input(modifier, cleanup_id, 0.1)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_uses_metric_plane_inputs_without_controller():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Balloon", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
crop_bounds = (50, 20, 90, 80)
local_bounds = cutout_geometry.crop_plane_bounds(
source,
crop_bounds,
(100, 100),
)
y, x = np.indices((61, 41), dtype=np.float32)
normalized_x = x / 40.0 * 2.0 - 1.0
normalized_y = y / 60.0 * 2.0 - 1.0
points = np.zeros((61, 41, 3), dtype=np.float32)
points[..., 0] = normalized_x
points[..., 1] = normalized_y
points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1
points[27:34, 17:24, 2] -= 0.25
intrinsics = np.asarray(
((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
valid = np.ones((61, 41), dtype=bool)
z_depth = np.repeat(points[..., 2, None], 3, axis=2)
depth_image = _depth_image(z_depth, valid)
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
selection_values = np.ones((60, 40), dtype=np.float32)
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
8,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
selection_values=selection_values,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.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 = object_data.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 = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = object_data.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 = object_data.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier[uniform_scale_id] > 0.0
thickness_id = object_data.modifier_input_identifier(
modifier.node_group,
"Thickness",
)
smooth_id = object_data.modifier_input_identifier(
modifier.node_group,
"Smooth",
)
assert modifier[smooth_id] == 1.0
object_data.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[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_one = positions[nearest_center, 2].max()
object_data.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[:, :2] - expected_center, axis=1)
nearest_center = center_distance <= center_distance.min() + 1e-6
depth_at_thickness_two = positions[nearest_center, 2].max()
assert np.isclose(
depth_at_thickness_one,
depth_at_thickness_two,
atol=1e-5,
)
object_data.set_modifier_input(modifier, thickness_id, 1.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
before_positions = _evaluated_positions(result)
object_data.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[:, 2] - before_positions[:, 2]).max(),
0.2,
atol=1e-5,
)
depth_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Scale",
)
object_data.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)
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.15,
)
object_data.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)
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
double_sided_id = object_data.modifier_input_identifier(
modifier.node_group,
"Double Sided",
)
object_data.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(x), 6), round(float(y), 6)), []).append(z)
assert layers
for z_values in layers.values():
if len(z_values) == 1:
assert np.isclose(z_values[0], 0.0, atol=1e-5)
continue
assert np.isclose(
min(z_values) + max(z_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 = image_data.load_normal_result_image(path, source)
assert image.name == "Poster_normal.png"
assert image.colorspace_settings.name == "Non-Color"
assert image.packed_file is not None
def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Poster", width=16, height=16)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
first = plane_object.create_color_image(source)
second = plane_object.create_color_image(source)
assert first is not image
assert second is not image
assert first is not second
assert first.name == "Poster_color.png"
assert second.name == "Poster_color.png.001"
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
rgba[:, :, 3] = np.asarray(
(
(0.0, 0.1, 0.2, 0.3, 0.4),
(0.5, 0.6, 0.7, 0.8, 0.9),
(1.0, 0.9, 0.8, 0.7, 0.6),
(0.5, 0.4, 0.3, 0.2, 0.1),
),
dtype=np.float32,
)
path = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
result = None
try:
result = image_data.load_packed_rgba_image(
path,
image,
"Bounds Result",
)
loaded = image_data.image_rgba(result)
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
assert result.packed_file is not None
image_data.cleanup_image_input(path, True)
assert not path.exists()
assert result.packed_file is not None
finally:
image_data.cleanup_image_input(path, True)
if result is not None:
bpy.data.images.remove(result, do_unlink=True)
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
selection_mask = rasterize_selection_path(
(4, 4),
SelectionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
),
)
selection = cutout_main._cutout_content_values(
top_down[:, :, 3],
selection_mask.values,
)
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
assert selection[1, 2] > 0.1
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
original = selection.copy()
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
content = cutout_main._cutout_content_values(
alpha, selection, alpha_threshold=0.5
)
np.testing.assert_array_equal(selection, original)
np.testing.assert_allclose(content, alpha * original)
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
source_rgba = np.ones((48, 64, 4), dtype=np.float32)
source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None]
image.pixels.foreach_set(np.flipud(source_rgba).ravel())
source = bpy.data.objects.new("Local Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
source.select_set(True)
bpy.context.view_layer.objects.active = source
selection_path = SelectionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
)
source_bounds = image_data.image_empty_bounds(source)
with patch.object(
image_data,
"image_rgba",
wraps=image_data.image_rgba,
) as read_rgba:
rgba = image_data.image_rgba(image)
selection_mask = rasterize_selection_path(
tuple(image.size),
selection_path,
antialias=True,
)
left, top, right, bottom = selection_mask.bounds
content_values = cutout_main._cutout_content_values(
rgba[top:bottom, left:right, 3],
selection_mask.values,
alpha_threshold=0.5,
)
color_image = cutout_object.create_cropped_color_image(
source,
selection_mask.bounds,
rgba,
)
cutout_main.create_cutout_shape(
bpy.context,
source,
"SURFACE",
16,
content_values,
selection_mask.bounds,
alpha_threshold=0.5,
color_image=color_image,
)
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 == cutout_shape.CUTOUT_NODE_GROUP_NAME
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")]
== cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 16),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")],
expected_cleanup,
)
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")]
== 1.0
)
assert (
modifier[
object_data.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.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.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, 36)
copied = np.empty(36 * 64 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((36, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = selection_mask.bounds
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert cutout_shape.BALLOON_CUTOUT_SHAPES == frozenset(
{"BALLOON", "DEPTH_BALLOON"}
)
group = bpy.data.node_groups.new(
material_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
"ShaderNodeTree",
)
group.interface.new_socket(
name="Normal Scale",
in_out="INPUT",
socket_type="NodeSocketFloat",
)
image_material = bpy.data.materials.new("Balloon")
image_material.use_nodes = True
image_material.node_tree.nodes.clear()
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
image_layer.node_tree = group
cutout_object._connect_balloon_normal_scale(image_material)
assert _inputs(group) == ["Normal Scale"]
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME
link = next(iter(image_material.node_tree.links))
assert link.from_socket == attribute.outputs["Fac"]
assert link.to_socket == image_layer.inputs["Normal Scale"]